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

pico.js 15KB

5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
5年前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  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 = `pico.chat` + (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. /* we could make these into like tabs .... */
  405. // m(Media),
  406. m(Hanabi),
  407. )
  408. },
  409. }
  410. const Main = {
  411. view() {
  412. const connected = State.websocket && State.websocket.readyState === 1
  413. return connected ? m(Chat) : m(Login)
  414. },
  415. }
  416. m.mount(document.body, Main)
  417. /*
  418. *
  419. * WEBSOCKETS
  420. *
  421. */
  422. const connect = (username) => {
  423. const wsUrl = location.href.replace('http', 'ws')
  424. State.websocket = new WebSocket(wsUrl)
  425. State.websocket.onopen = (e) => {
  426. wire({kind: 'login', value: username})
  427. }
  428. State.websocket.onmessage = (e) => {
  429. const message = JSON.parse(e.data)
  430. if(message.online) {
  431. const difference = (l1, l2) => l1.filter(u => !l2.includes(u))
  432. difference(message.online, State.online).forEach(username => {
  433. State.posts.push({ts: message.ts, value: `${username} joined`})
  434. signal({kind: 'join', username: username})
  435. })
  436. difference(State.online, message.online).forEach(username => {
  437. State.posts.push({ts: message.ts, value: `${username} left`})
  438. signal({kind: 'leave', username: username})
  439. })
  440. }
  441. if(!doNotLog.has(message.kind)) {
  442. console.log(message)
  443. }
  444. signal(message)
  445. m.redraw()
  446. }
  447. State.websocket.onclose = (e) => {
  448. State.online.forEach(signalPeerStop)
  449. if(!e.wasClean) {
  450. setTimeout(connect, 1000, username)
  451. }
  452. m.redraw()
  453. }
  454. }
  455. if(localStorage.username) {
  456. connect(localStorage.username)
  457. }
  458. addEventListener('pagehide', Media.turnOff)