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

278 行
7.9KB

  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. /*
  11. *
  12. * SIGNALING
  13. *
  14. */
  15. const wire = (message) => State.websocket.send(JSON.stringify(message))
  16. const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message}))
  17. const signalPeerStop = (username) => signal({kind: 'peerInfo', value: {type: 'stop'}, source: username})
  18. const listen = (kind, handler) => addEventListener(kind, handler)
  19. listen('login', ({detail}) => State.username = detail.value)
  20. listen('post', ({detail}) => State.posts.push(detail))
  21. listen('peerInfo', (e) => onPeerInfo(e))
  22. const doNotLog = new Set(['login', 'post', 'peerInfo'])
  23. /*
  24. *
  25. * ALERTS
  26. *
  27. */
  28. State.unseen = 0
  29. listen('post', () => {State.unseen += !document.hasFocus(); updateTitle()})
  30. listen('focus', () => {State.unseen = 0; updateTitle()})
  31. const updateTitle = () => {
  32. document.title = `Pico.Chat` + (State.unseen ? ` (${State.unseen})` : ``)
  33. }
  34. /*
  35. *
  36. * WEBRTC
  37. *
  38. */
  39. const getOrCreateRpc = (username) => {
  40. if(State.username === username) {
  41. return
  42. }
  43. const myStream = State.media[State.username]
  44. if(!State.rpcs[username] && myStream) {
  45. const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]})
  46. myStream.getTracks().forEach(track => rpc.addTrack(track, myStream))
  47. rpc.onicecandidate = ({candidate}) => {
  48. if(candidate) {
  49. wire({kind: 'peerInfo', value: {type: 'candidate', candidate}})
  50. }
  51. }
  52. rpc.ontrack = (e) => {
  53. State.media[username] = e.streams[0]
  54. m.redraw()
  55. }
  56. rpc.onclose = (e) => {
  57. console.log(username, e)
  58. }
  59. State.rpcs[username] = rpc
  60. }
  61. return State.rpcs[username]
  62. }
  63. const onPeerInfo = async ({detail: message}) => {
  64. const rpc = getOrCreateRpc(message.source)
  65. if(rpc && message.value.type === 'request') {
  66. const localOffer = await rpc.createOffer()
  67. await rpc.setLocalDescription(localOffer)
  68. wire({kind: 'peerInfo', value: localOffer})
  69. }
  70. else if(rpc && message.value.type === 'offer') {
  71. const remoteOffer = new RTCSessionDescription(message.value)
  72. await rpc.setRemoteDescription(remoteOffer)
  73. const localAnswer = await rpc.createAnswer()
  74. await rpc.setLocalDescription(localAnswer)
  75. wire({kind: 'peerInfo', value: localAnswer})
  76. }
  77. else if(rpc && message.value.type === 'answer') {
  78. const remoteAnswer = new RTCSessionDescription(message.value)
  79. await rpc.setRemoteDescription(remoteAnswer)
  80. }
  81. else if(rpc && message.value.type === 'candidate') {
  82. const candidate = new RTCIceCandidate(message.value.candidate)
  83. rpc.addIceCandidate(candidate)
  84. }
  85. else if(message.value.type === 'stop') {
  86. if(State.media[message.source]) {
  87. State.media[message.source].getTracks().map(track => track.stop())
  88. delete State.media[message.source]
  89. }
  90. if(State.rpcs[message.source]) {
  91. State.rpcs[message.source].close()
  92. delete State.rpcs[message.source]
  93. }
  94. }
  95. else if(rpc) {
  96. console.log('uncaught', message)
  97. }
  98. }
  99. /*
  100. *
  101. * GUI
  102. *
  103. */
  104. const autoFocus = (vnode) => {
  105. vnode.dom.focus()
  106. }
  107. const scrollIntoView = (vnode) => {
  108. vnode.dom.scrollIntoView()
  109. }
  110. const prettyTime = (ts) => {
  111. const dt = new Date(ts)
  112. const H = `0${dt.getHours()}`.slice(-2)
  113. const M = `0${dt.getMinutes()}`.slice(-2)
  114. const S = `0${dt.getSeconds()}`.slice(-2)
  115. return `${H}:${M}:${S}`
  116. }
  117. const hotKey = (e) => {
  118. // if isDesktop, Enter posts, unless Shift+Enter
  119. // use isLandscape as proxy for isDesktop
  120. if(e.key === 'Enter' && isLandscape && !e.shiftKey) {
  121. e.preventDefault()
  122. Chat.sendPost()
  123. }
  124. }
  125. const Video = {
  126. appendStream: ({username, stream}) => ({dom}) => {
  127. dom.autoplay = true
  128. dom.muted = (username === State.username)
  129. dom.srcObject = stream
  130. },
  131. view({attrs}) {
  132. const rpc = State.rpcs[attrs.username] || {iceConnectionState: m.trust(' ')}
  133. return m('.video-container',
  134. m('.video-source', attrs.username),
  135. m('.video-state', rpc.iceConnectionState),
  136. m('video', {playsinline: true, oncreate: Video.appendStream(attrs)}),
  137. )
  138. },
  139. }
  140. const Media = {
  141. constraints: {audio: true, video: {width: {ideal: 320}, facingMode: 'user'}},
  142. turnOn: async () => {
  143. const media = await navigator.mediaDevices.getUserMedia(Media.constraints)
  144. State.media[State.username] = media
  145. wire({kind: 'peerInfo', value: {type: 'request'}})
  146. m.redraw()
  147. },
  148. turnOff: () => {
  149. wire({kind: 'peerInfo', value: {type: 'stop'}})
  150. State.online.forEach(signalPeerStop)
  151. },
  152. view() {
  153. if(!State.media[State.username]) {
  154. return m('.media',
  155. m('button', {onclick: Media.turnOn}, 'turn media on'),
  156. )
  157. }
  158. else {
  159. return m('.media',
  160. m('button', {onclick: Media.turnOff}, 'turn media off'),
  161. m('.videos',
  162. Object.entries(State.media).map(([username, stream]) =>
  163. m(Video, {username, stream})
  164. ),
  165. ),
  166. )
  167. }
  168. }
  169. }
  170. const Login = {
  171. sendLogin: (e) => {
  172. e.preventDefault()
  173. const username = e.target.username.value
  174. localStorage.username = username
  175. connect(username)
  176. },
  177. sendLogout: (e) => {
  178. Media.turnOff()
  179. wire({kind: 'logout'})
  180. State.posts = []
  181. },
  182. view() {
  183. const attrs = {
  184. oncreate: autoFocus,
  185. name: 'username',
  186. autocomplete: 'off',
  187. value: localStorage.username,
  188. }
  189. return m('.login',
  190. m('form', {onsubmit: Login.sendLogin},
  191. m('input', attrs),
  192. m('button', 'Login'),
  193. ),
  194. m('.error', State.info),
  195. )
  196. },
  197. }
  198. const Chat = {
  199. sendPost: () => {
  200. if(textbox.value) {
  201. wire({kind: 'post', value: textbox.value})
  202. textbox.value = ''
  203. }
  204. },
  205. view() {
  206. return m('.chat',
  207. m('.posts',
  208. State.posts.map(post => m('.post', {oncreate: scrollIntoView},
  209. m('.ts', prettyTime(post.ts)),
  210. m('.source', post.source || '~'),
  211. m('.text', post.value),
  212. )),
  213. ),
  214. m('.actions',
  215. m('textarea#textbox', {oncreate: autoFocus, onkeydown: hotKey}),
  216. m('button', {onclick: Chat.sendPost}, 'Send'),
  217. ),
  218. m('.online',
  219. m('button', {onclick: Login.sendLogout}, 'Logout'),
  220. m('ul.user-list', State.online.map(username => m('li', username))),
  221. ),
  222. m(Media),
  223. )
  224. },
  225. }
  226. const Main = {
  227. view() {
  228. const connected = State.websocket && State.websocket.readyState === 1
  229. return connected ? m(Chat) : m(Login)
  230. },
  231. }
  232. m.mount(document.body, Main)
  233. /*
  234. *
  235. * WEBSOCKETS
  236. *
  237. */
  238. const connect = (username) => {
  239. const wsUrl = location.href.replace('http', 'ws')
  240. State.websocket = new WebSocket(wsUrl)
  241. State.websocket.onopen = (e) => {
  242. wire({kind: 'login', value: username})
  243. }
  244. State.websocket.onmessage = (e) => {
  245. const message = JSON.parse(e.data)
  246. if(message.online) {
  247. State.online = message.online
  248. }
  249. if(!doNotLog.has(message.kind)) {
  250. console.log(message)
  251. }
  252. signal(message)
  253. m.redraw()
  254. }
  255. State.websocket.onclose = (e) => {
  256. if(!e.wasClean) {
  257. setTimeout(connect, 1000, username)
  258. }
  259. m.redraw()
  260. }
  261. }
  262. if(localStorage.username) {
  263. connect(localStorage.username)
  264. }
  265. addEventListener('pagehide', Media.turnOff)