Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

455 lines
14KB

  1. const isLandscape = screen.width > screen.height
  2. const State = {
  3. username: null,
  4. websocket: null,
  5. online: [],
  6. posts: [],
  7. rpcs: {},
  8. streams: {},
  9. options: {},
  10. }
  11. const markedOptions = {
  12. breaks: true,
  13. }
  14. marked.setOptions(markedOptions)
  15. /*
  16. *
  17. * SIGNALING
  18. *
  19. */
  20. const wire = (message) => State.websocket.send(JSON.stringify(message))
  21. const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message}))
  22. const signalPeerStop = (username) => signal({kind: 'peerInfo', value: {type: 'stop'}, source: username})
  23. const listen = (kind, handler) => addEventListener(kind, handler)
  24. listen('login', ({detail}) => State.username = detail.value)
  25. listen('state', ({detail}) => Object.assign(State, detail))
  26. listen('post', ({detail}) => State.posts.push(detail))
  27. listen('peerInfo', (e) => onPeerInfo(e))
  28. const doNotLog = new Set(['login', 'state', 'post', 'peerInfo'])
  29. /*
  30. *
  31. * ALERTS
  32. *
  33. */
  34. State.unseen = 0
  35. listen('post', () => {State.unseen += !document.hasFocus(); updateTitle()})
  36. listen('focus', () => {State.unseen = 0; updateTitle()})
  37. const updateTitle = () => {
  38. document.title = `pico.chat` + (State.unseen ? ` (${State.unseen})` : ``)
  39. }
  40. /*
  41. *
  42. * WEBRTC
  43. *
  44. */
  45. const getOrCreateRpc = (username) => {
  46. if(State.username === username) {
  47. return
  48. }
  49. if(!State.rpcs[username]) {
  50. const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]})
  51. rpc.onicecandidate = ({candidate}) => {
  52. if(candidate) {
  53. wire({kind: 'peerInfo', value: {type: 'candidate', candidate}})
  54. }
  55. }
  56. rpc.ontrack = (e) => {
  57. State.streams[username] = e.streams[0]
  58. m.redraw()
  59. }
  60. rpc.onclose = (e) => {
  61. console.log(username, e)
  62. }
  63. rpc.oniceconnectionstatechange = (e) => {
  64. m.redraw()
  65. }
  66. State.rpcs[username] = rpc
  67. }
  68. return State.rpcs[username]
  69. }
  70. const setSelectedMedia = async () => {
  71. const localStream = State.streams[State.username]
  72. if(!localStream) {
  73. return
  74. }
  75. localStream.getTracks().forEach(track => {
  76. track.stop()
  77. localStream.removeTrack(track)
  78. })
  79. const addTrack = localStream.addTrack.bind(localStream)
  80. const muted = document.querySelector('#mute-check').checked
  81. if(!muted) {
  82. const audio = Media.audioDefaults
  83. await navigator.mediaDevices.getUserMedia({audio})
  84. .then(s => s.getAudioTracks().forEach(addTrack))
  85. .catch(e => console.error(e))
  86. }
  87. const source = document.querySelector('#media-source').value
  88. if(source === 'camera') {
  89. const video = {width: {ideal: 320}, facingMode: 'user', frameRate: 26}
  90. await navigator.mediaDevices.getUserMedia({video})
  91. .then(s => s.getVideoTracks().forEach(addTrack))
  92. .catch(e => console.error(e))
  93. }
  94. if(source === 'screen' && navigator.mediaDevices.getDisplayMedia) {
  95. await navigator.mediaDevices.getDisplayMedia()
  96. .then(s => s.getVideoTracks().forEach(addTrack))
  97. .catch(e => console.error(e))
  98. }
  99. document.querySelectorAll('video').forEach(video => video.srcObject = video.srcObject)
  100. wire({kind: 'peerInfo', value: {type: 'request'}})
  101. }
  102. const onPeerInfo = async ({detail: message}) => {
  103. const localStream = State.streams[State.username]
  104. const rpc = localStream && getOrCreateRpc(message.source)
  105. const resetStreams = () => {
  106. rpc.getSenders().forEach(sender => rpc.removeTrack(sender))
  107. localStream.getTracks().forEach(track => rpc.addTrack(track, localStream))
  108. }
  109. if(rpc && message.value.type === 'request') {
  110. resetStreams()
  111. const localOffer = await rpc.createOffer()
  112. await rpc.setLocalDescription(localOffer)
  113. wire({kind: 'peerInfo', value: localOffer, target: message.source})
  114. }
  115. else if(rpc && message.value.type === 'offer') {
  116. resetStreams()
  117. const remoteOffer = new RTCSessionDescription(message.value)
  118. await rpc.setRemoteDescription(remoteOffer)
  119. const localAnswer = await rpc.createAnswer()
  120. await rpc.setLocalDescription(localAnswer)
  121. wire({kind: 'peerInfo', value: localAnswer, target: message.source})
  122. }
  123. else if(rpc && message.value.type === 'answer') {
  124. const remoteAnswer = new RTCSessionDescription(message.value)
  125. await rpc.setRemoteDescription(remoteAnswer)
  126. }
  127. else if(rpc && message.value.type === 'candidate') {
  128. const candidate = new RTCIceCandidate(message.value.candidate)
  129. rpc.addIceCandidate(candidate)
  130. }
  131. else if(message.value.type === 'stop') {
  132. if(State.streams[message.source]) {
  133. State.streams[message.source].getTracks().map(track => track.stop())
  134. delete State.streams[message.source]
  135. }
  136. if(State.rpcs[message.source]) {
  137. State.rpcs[message.source].close()
  138. delete State.rpcs[message.source]
  139. }
  140. }
  141. else if(rpc) {
  142. console.log('uncaught', message)
  143. }
  144. }
  145. /*
  146. *
  147. * GUI
  148. *
  149. */
  150. const autoFocus = (vnode) => {
  151. vnode.dom.focus()
  152. }
  153. const scrollIntoView = (vnode) => {
  154. vnode.dom.scrollIntoView()
  155. }
  156. const TextBox = {
  157. autoSize: () => {
  158. textbox.rows = textbox.value.split('\n').length
  159. },
  160. sendPost: () => {
  161. if(textbox.value) {
  162. wire({kind: 'post', value: textbox.value})
  163. textbox.value = ''
  164. textbox.focus()
  165. }
  166. },
  167. blockIndent: (text, iStart, iEnd, nLevels) => {
  168. const startLine = text.slice(0, iStart).split('\n').length - 1
  169. const endLine = text.slice(0, iEnd).split('\n').length - 1
  170. const newText = text
  171. .split('\n')
  172. .map((line, i) => {
  173. if(i < startLine || i > endLine || nLevels === 0) {
  174. newLine = line
  175. }
  176. else if(nLevels > 0) {
  177. newLine = line.replace(/^/, ' ')
  178. }
  179. else if(nLevels < 0) {
  180. newLine = line.replace(/^ /, '')
  181. }
  182. if(i === startLine) {
  183. iStart = iStart + newLine.length - line.length
  184. }
  185. iEnd = iEnd + newLine.length - line.length
  186. return newLine
  187. })
  188. .join('\n')
  189. return [newText, Math.max(0, iStart), Math.max(0, iEnd)]
  190. },
  191. hotKey: (e) => {
  192. // if isDesktop, Enter posts, unless Shift+Enter
  193. // use isLandscape as proxy for isDesktop
  194. if(e.key === 'Enter' && isLandscape && !e.shiftKey) {
  195. e.preventDefault()
  196. TextBox.sendPost()
  197. }
  198. // indent and dedent
  199. const modKey = e.ctrlKey || e.metaKey
  200. const {value: text, selectionStart: A, selectionEnd: B} = textbox
  201. if(e.key === 'Tab') {
  202. e.preventDefault()
  203. const regex = new RegExp(`([\\s\\S]{${A}})([\\s\\S]{${B - A}})`)
  204. textbox.value = text.replace(regex, (m, a, b) => a + ' '.repeat(4))
  205. textbox.setSelectionRange(A + 4, A + 4)
  206. }
  207. if(']['.includes(e.key) && modKey) {
  208. e.preventDefault()
  209. const nLevels = {']': 1, '[': -1}[e.key]
  210. const [newText, newA, newB] = TextBox.blockIndent(text, A, B, nLevels)
  211. textbox.value = newText
  212. textbox.setSelectionRange(newA, newB)
  213. }
  214. },
  215. view() {
  216. return m('.actions',
  217. m('textarea#textbox', {
  218. oncreate: (vnode) => {
  219. TextBox.autoSize()
  220. autoFocus(vnode)
  221. },
  222. onkeydown: TextBox.hotKey,
  223. onkeyup: TextBox.autoSize,
  224. }),
  225. m('button', {
  226. onclick: ({target}) => {
  227. TextBox.sendPost()
  228. TextBox.autoSize()
  229. },
  230. },
  231. 'Send'),
  232. )
  233. },
  234. }
  235. const VideoOptions = {
  236. available: ['mirror', 'square', 'full-screen'],
  237. anyFullScreen: () => {
  238. for(const username of State.online) {
  239. if(State.options[username].has('full-screen')) {
  240. return 'full-screen'
  241. }
  242. }
  243. return ''
  244. },
  245. getFor: (username) => {
  246. if(!State.options[username]) {
  247. State.options[username] = new Set(['mirror', 'square'])
  248. }
  249. return State.options[username]
  250. },
  251. getClassListFor: (username) => {
  252. return [...VideoOptions.getFor(username)].join(' ')
  253. },
  254. toggle: (options, string) => () => options.has(string)
  255. ? options.delete(string)
  256. : options.add(string),
  257. view({attrs: {username}}) {
  258. const options = VideoOptions.getFor(username)
  259. return VideoOptions.available.map((string) =>
  260. m('label.video-option',
  261. m('input', {
  262. type: 'checkbox',
  263. checked: options.has(string),
  264. onchange: VideoOptions.toggle(options, string),
  265. }),
  266. string,
  267. )
  268. )
  269. }
  270. }
  271. const Video = {
  272. keepRatio: {observe: () => {}},
  273. appendStream: ({username}) => ({dom}) => {
  274. dom.autoplay = true
  275. dom.muted = (username === State.username)
  276. dom.srcObject = State.streams[username]
  277. },
  278. view({attrs}) {
  279. const classList = VideoOptions.getClassListFor(attrs.username)
  280. const rpc = State.rpcs[attrs.username] || {iceConnectionState: null}
  281. return m('.video-container', {class: classList, oncreate: ({dom}) => Video.keepRatio.observe(dom)},
  282. m('.video-meta',
  283. m('span.video-source', attrs.username),
  284. m('.video-state', rpc.iceConnectionState),
  285. ),
  286. m('video', {playsinline: true, oncreate: Video.appendStream(attrs)}),
  287. )
  288. },
  289. }
  290. if(window.ResizeObserver) {
  291. const doOne = ({target}) => target.style.setProperty('--height', `${target.clientHeight}px`)
  292. const doAll = (entries) => entries.forEach(doOne)
  293. Video.keepRatio = new ResizeObserver(doAll)
  294. }
  295. const Media = {
  296. videoSources: ['camera', 'screen', 'none'],
  297. audioDefaults: {
  298. noiseSuppresion: true,
  299. echoCancellation: true,
  300. },
  301. turnOn: async () => {
  302. State.streams[State.username] = new MediaStream()
  303. await setSelectedMedia()
  304. m.redraw()
  305. },
  306. turnOff: () => {
  307. wire({kind: 'peerInfo', value: {type: 'stop'}})
  308. State.online.forEach(signalPeerStop)
  309. },
  310. view() {
  311. return m('.media',
  312. m('.media-settings',
  313. State.streams[State.username]
  314. ? m('button', {onclick: Media.turnOff}, 'turn off')
  315. : m('button', {onclick: Media.turnOn}, 'turn on')
  316. ,
  317. m('select#media-source', {onchange: setSelectedMedia},
  318. Media.videoSources.map(option => m('option', option))
  319. ),
  320. m('label',
  321. m('input#mute-check', {onchange: setSelectedMedia, type: 'checkbox'}),
  322. 'mute'
  323. ),
  324. ),
  325. m('.videos', {className: VideoOptions.anyFullScreen()},
  326. Object.keys(State.streams).map((username) =>
  327. m(Video, {key: username, username})
  328. ),
  329. ),
  330. )
  331. },
  332. }
  333. const Login = {
  334. sendLogin: (e) => {
  335. e.preventDefault()
  336. const username = e.target.username.value
  337. localStorage.username = username
  338. connect(username)
  339. },
  340. sendLogout: (e) => {
  341. Media.turnOff()
  342. wire({kind: 'logout'})
  343. State.posts = []
  344. },
  345. view() {
  346. const attrs = {
  347. oncreate: autoFocus,
  348. name: 'username',
  349. autocomplete: 'off',
  350. value: localStorage.username,
  351. }
  352. return m('.login',
  353. m('form', {onsubmit: Login.sendLogin},
  354. m('input', attrs),
  355. m('button', 'Login'),
  356. ),
  357. m('.error', State.info),
  358. )
  359. },
  360. }
  361. const Chat = {
  362. prettifyTime: (ts) => {
  363. const dt = new Date(ts)
  364. const H = `0${dt.getHours()}`.slice(-2)
  365. const M = `0${dt.getMinutes()}`.slice(-2)
  366. const S = `0${dt.getSeconds()}`.slice(-2)
  367. return `${H}:${M}:${S}`
  368. },
  369. view() {
  370. return m('.chat',
  371. m('.posts',
  372. State.posts.map(post => m('.post', {oncreate: scrollIntoView},
  373. m('.ts', Chat.prettifyTime(post.ts)),
  374. m('.source', post.source || '~'),
  375. m('.text', m.trust(DOMPurify.sanitize(marked(post.value)))),
  376. )),
  377. ),
  378. m(TextBox),
  379. m('.online',
  380. m('button', {onclick: Login.sendLogout}, 'Logout'),
  381. m('.user-list', State.online.map(username =>
  382. m('details',
  383. m('summary', username),
  384. m(VideoOptions, {username}),
  385. ),
  386. )),
  387. ),
  388. m(Media),
  389. )
  390. },
  391. }
  392. const Main = {
  393. view() {
  394. const connected = State.websocket && State.websocket.readyState === 1
  395. return connected ? m(Chat) : m(Login)
  396. },
  397. }
  398. m.mount(document.body, Main)
  399. /*
  400. *
  401. * WEBSOCKETS
  402. *
  403. */
  404. const connect = (username) => {
  405. const wsUrl = location.href.replace('http', 'ws')
  406. State.websocket = new WebSocket(wsUrl)
  407. State.websocket.onopen = (e) => {
  408. wire({kind: 'login', value: username})
  409. }
  410. State.websocket.onmessage = (e) => {
  411. const message = JSON.parse(e.data)
  412. if(message.online) {
  413. const difference = (l1, l2) => l1.filter(u => !l2.includes(u))
  414. difference(message.online, State.online).forEach(username =>
  415. State.posts.push({ts: message.ts, value: `${username} joined`}))
  416. difference(State.online, message.online).forEach(username =>
  417. State.posts.push({ts: message.ts, value: `${username} left`}))
  418. }
  419. if(!doNotLog.has(message.kind)) {
  420. console.log(message)
  421. }
  422. signal(message)
  423. m.redraw()
  424. }
  425. State.websocket.onclose = (e) => {
  426. State.online.forEach(signalPeerStop)
  427. if(!e.wasClean) {
  428. setTimeout(connect, 1000, username)
  429. }
  430. m.redraw()
  431. }
  432. }
  433. if(localStorage.username) {
  434. connect(localStorage.username)
  435. }
  436. addEventListener('pagehide', Media.turnOff)