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

pico.js 14KB

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年前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433
  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. if(!State.media[State.username]) {
  292. return m('.media',
  293. m('.media-settings',
  294. m('button', {onclick: Media.turnOn}, 'turn on'),
  295. m('select#media-source', Media.videoSources.map(option => m('option', option))),
  296. m('label', m('input#mute-check', {type: 'checkbox'}), 'mute'),
  297. ),
  298. )
  299. }
  300. else {
  301. return m('.media',
  302. m('.media-settings',
  303. m('button', {onclick: Media.turnOff}, 'turn off'),
  304. ),
  305. m('.videos',
  306. Object.entries(State.media).map(([username, stream]) =>
  307. m(Video, {username, stream})
  308. ),
  309. ),
  310. )
  311. }
  312. }
  313. }
  314. const Login = {
  315. sendLogin: (e) => {
  316. e.preventDefault()
  317. const username = e.target.username.value
  318. localStorage.username = username
  319. connect(username)
  320. },
  321. sendLogout: (e) => {
  322. Media.turnOff()
  323. wire({kind: 'logout'})
  324. State.posts = []
  325. },
  326. view() {
  327. const attrs = {
  328. oncreate: autoFocus,
  329. name: 'username',
  330. autocomplete: 'off',
  331. value: localStorage.username,
  332. }
  333. return m('.login',
  334. m('form', {onsubmit: Login.sendLogin},
  335. m('input', attrs),
  336. m('button', 'Login'),
  337. ),
  338. m('.error', State.info),
  339. )
  340. },
  341. }
  342. const Chat = {
  343. prettifyTime: (ts) => {
  344. const dt = new Date(ts)
  345. const H = `0${dt.getHours()}`.slice(-2)
  346. const M = `0${dt.getMinutes()}`.slice(-2)
  347. const S = `0${dt.getSeconds()}`.slice(-2)
  348. return `${H}:${M}:${S}`
  349. },
  350. view() {
  351. return m('.chat',
  352. m('.posts',
  353. State.posts.map(post => m('.post', {oncreate: scrollIntoView},
  354. m('.ts', Chat.prettifyTime(post.ts)),
  355. m('.source', post.source || '~'),
  356. m('.text', m.trust(DOMPurify.sanitize(marked(post.value)))),
  357. )),
  358. ),
  359. m(TextBox),
  360. m('.online',
  361. m('button', {onclick: Login.sendLogout}, 'Logout'),
  362. m('.user-list', State.online.map(username =>
  363. m('details',
  364. m('summary', username),
  365. m(VideoOptions, {username}),
  366. ),
  367. )),
  368. ),
  369. m(Media),
  370. )
  371. },
  372. }
  373. const Main = {
  374. view() {
  375. const connected = State.websocket && State.websocket.readyState === 1
  376. return connected ? m(Chat) : m(Login)
  377. },
  378. }
  379. m.mount(document.body, Main)
  380. /*
  381. *
  382. * WEBSOCKETS
  383. *
  384. */
  385. const connect = (username) => {
  386. const wsUrl = location.href.replace('http', 'ws')
  387. State.websocket = new WebSocket(wsUrl)
  388. State.websocket.onopen = (e) => {
  389. wire({kind: 'login', value: username})
  390. }
  391. State.websocket.onmessage = (e) => {
  392. const message = JSON.parse(e.data)
  393. if(message.online) {
  394. const difference = (l1, l2) => l1.filter(u => !l2.includes(u))
  395. difference(message.online, State.online).forEach(username =>
  396. State.posts.push({ts: message.ts, value: `${username} joined`}))
  397. difference(State.online, message.online).forEach(username =>
  398. State.posts.push({ts: message.ts, value: `${username} left`}))
  399. }
  400. if(!doNotLog.has(message.kind)) {
  401. console.log(message)
  402. }
  403. signal(message)
  404. m.redraw()
  405. }
  406. State.websocket.onclose = (e) => {
  407. State.online.forEach(signalPeerStop)
  408. if(!e.wasClean) {
  409. setTimeout(connect, 1000, username)
  410. }
  411. m.redraw()
  412. }
  413. }
  414. if(localStorage.username) {
  415. connect(localStorage.username)
  416. }
  417. addEventListener('pagehide', Media.turnOff)