You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

271 lines
7.6KB

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