Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

377 lines
12KB

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