Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

256 lines
7.2KB

  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. * WEBRTC
  25. *
  26. */
  27. const getOrCreateRpc = (username) => {
  28. if(State.username === username) {
  29. return
  30. }
  31. const myStream = State.media[State.username]
  32. if(!State.rpcs[username] && myStream) {
  33. const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]})
  34. myStream.getTracks().forEach(track => rpc.addTrack(track, myStream))
  35. rpc.onicecandidate = ({candidate}) => {
  36. if(candidate) {
  37. wire({kind: 'peerInfo', value: {type: 'candidate', candidate}})
  38. }
  39. }
  40. rpc.ontrack = (e) => {
  41. State.media[username] = e.streams[0]
  42. m.redraw()
  43. }
  44. rpc.onclose = (e) => {
  45. console.log(username, e)
  46. }
  47. State.rpcs[username] = rpc
  48. }
  49. return State.rpcs[username]
  50. }
  51. const onPeerInfo = async ({detail: message}) => {
  52. const rpc = getOrCreateRpc(message.source)
  53. if(rpc && message.value.type === 'request') {
  54. const localOffer = await rpc.createOffer()
  55. await rpc.setLocalDescription(localOffer)
  56. wire({kind: 'peerInfo', value: localOffer})
  57. }
  58. else if(rpc && message.value.type === 'offer') {
  59. const remoteOffer = new RTCSessionDescription(message.value)
  60. await rpc.setRemoteDescription(remoteOffer)
  61. const localAnswer = await rpc.createAnswer()
  62. await rpc.setLocalDescription(localAnswer)
  63. wire({kind: 'peerInfo', value: localAnswer})
  64. }
  65. else if(rpc && message.value.type === 'answer') {
  66. const remoteAnswer = new RTCSessionDescription(message.value)
  67. await rpc.setRemoteDescription(remoteAnswer)
  68. }
  69. else if(rpc && message.value.type === 'candidate') {
  70. const candidate = new RTCIceCandidate(message.value.candidate)
  71. rpc.addIceCandidate(candidate)
  72. }
  73. else if(message.value.type === 'stop') {
  74. if(State.media[message.source]) {
  75. State.media[message.source].getTracks().map(track => track.stop())
  76. delete State.media[message.source]
  77. }
  78. if(State.rpcs[message.source]) {
  79. State.rpcs[message.source].close()
  80. delete State.rpcs[message.source]
  81. }
  82. }
  83. else if(rpc) {
  84. console.log('uncaught', message)
  85. }
  86. }
  87. /*
  88. *
  89. * GUI
  90. *
  91. */
  92. const autoFocus = (vnode) => {
  93. vnode.dom.focus()
  94. }
  95. const scrollIntoView = (vnode) => {
  96. vnode.dom.scrollIntoView()
  97. }
  98. const prettyTime = (ts) => {
  99. return ts.slice(11, 19)
  100. }
  101. const Video = {
  102. appendStream: ({username, stream}) => ({dom}) => {
  103. dom.autoplay = true
  104. dom.muted = (username === State.username)
  105. dom.srcObject = stream
  106. },
  107. view({attrs}) {
  108. const rpc = State.rpcs[attrs.username] || {iceConnectionState: m.trust(' ')}
  109. return m('.video-container',
  110. m('.video-source', attrs.username),
  111. m('.video-state', rpc.iceConnectionState),
  112. m('video', {playsinline: true, oncreate: Video.appendStream(attrs)}),
  113. )
  114. },
  115. }
  116. const Media = {
  117. constraints: {audio: true, video: {width: {ideal: 320}, facingMode: 'user'}},
  118. turnOn: async () => {
  119. const media = await navigator.mediaDevices.getUserMedia(Media.constraints)
  120. State.media[State.username] = media
  121. wire({kind: 'peerInfo', value: {type: 'request'}})
  122. m.redraw()
  123. },
  124. turnOff: () => {
  125. wire({kind: 'peerInfo', value: {type: 'stop'}})
  126. State.online.forEach(signalPeerStop)
  127. },
  128. view() {
  129. if(!State.media[State.username]) {
  130. return m('.media',
  131. m('button', {onclick: Media.turnOn}, 'turn media on'),
  132. )
  133. }
  134. else {
  135. return m('.media',
  136. m('button', {onclick: Media.turnOff}, 'turn media off'),
  137. m('.videos',
  138. Object.entries(State.media).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. 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. 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. }
  243. addEventListener('pagehide', Media.turnOff)