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

pico.js 13KB

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年前
5年前
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  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)