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.

254 lines
7.0KB

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