選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

477 行
15KB

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