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

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