Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

428 linhas
13KB

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