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

pico.js 12KB

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