Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

260 lines
7.4KB

  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. const dt = new Date(ts)
  100. const H = `0${dt.getHours()}`.slice(-2)
  101. const M = `0${dt.getMinutes()}`.slice(-2)
  102. const S = `0${dt.getSeconds()}`.slice(-2)
  103. return `${H}:${M}:${S}`
  104. }
  105. const Video = {
  106. appendStream: ({username, stream}) => ({dom}) => {
  107. dom.autoplay = true
  108. dom.muted = (username === State.username)
  109. dom.srcObject = stream
  110. },
  111. view({attrs}) {
  112. const rpc = State.rpcs[attrs.username] || {iceConnectionState: m.trust(' ')}
  113. return m('.video-container',
  114. m('.video-source', attrs.username),
  115. m('.video-state', rpc.iceConnectionState),
  116. m('video', {playsinline: true, oncreate: Video.appendStream(attrs)}),
  117. )
  118. },
  119. }
  120. const Media = {
  121. constraints: {audio: true, video: {width: {ideal: 320}, facingMode: 'user'}},
  122. turnOn: async () => {
  123. const media = await navigator.mediaDevices.getUserMedia(Media.constraints)
  124. State.media[State.username] = media
  125. wire({kind: 'peerInfo', value: {type: 'request'}})
  126. m.redraw()
  127. },
  128. turnOff: () => {
  129. wire({kind: 'peerInfo', value: {type: 'stop'}})
  130. State.online.forEach(signalPeerStop)
  131. },
  132. view() {
  133. if(!State.media[State.username]) {
  134. return m('.media',
  135. m('button', {onclick: Media.turnOn}, 'turn media on'),
  136. )
  137. }
  138. else {
  139. return m('.media',
  140. m('button', {onclick: Media.turnOff}, 'turn media off'),
  141. m('.videos',
  142. Object.entries(State.media).map(([username, stream]) =>
  143. m(Video, {username, stream})
  144. ),
  145. ),
  146. )
  147. }
  148. }
  149. }
  150. const Login = {
  151. sendLogin: (e) => {
  152. e.preventDefault()
  153. const username = e.target.username.value
  154. localStorage.username = username
  155. connect(username)
  156. },
  157. sendLogout: (e) => {
  158. Media.turnOff()
  159. wire({kind: 'logout'})
  160. State.posts = []
  161. },
  162. view() {
  163. const attrs = {
  164. oncreate: autoFocus,
  165. name: 'username',
  166. autocomplete: 'off',
  167. value: localStorage.username,
  168. }
  169. return m('.login',
  170. m('form', {onsubmit: Login.sendLogin},
  171. m('input', attrs),
  172. m('button', 'Login'),
  173. ),
  174. m('.error', State.info),
  175. )
  176. },
  177. }
  178. const Chat = {
  179. sendPost: (e) => {
  180. e.preventDefault()
  181. const field = e.target.text
  182. if(field.value) {
  183. wire({kind: 'post', value: field.value})
  184. field.value = ''
  185. }
  186. },
  187. view() {
  188. return m('.chat',
  189. m('.posts',
  190. State.posts.map(post => m('.post', {oncreate: scrollIntoView},
  191. m('.ts', prettyTime(post.ts)),
  192. m('.source', post.source || '~'),
  193. m('.text', post.value),
  194. )),
  195. ),
  196. m('form.actions', {onsubmit: Chat.sendPost},
  197. m('input', {oncreate: autoFocus, name: 'text', autocomplete: 'off'}),
  198. m('button', 'Send'),
  199. ),
  200. m('.online',
  201. m('button', {onclick: Login.sendLogout}, 'Logout'),
  202. m('ul.user-list', State.online.map(username => m('li', username))),
  203. ),
  204. m(Media),
  205. )
  206. },
  207. }
  208. const Main = {
  209. view() {
  210. const connected = State.websocket && State.websocket.readyState === 1
  211. return connected ? m(Chat) : m(Login)
  212. },
  213. }
  214. m.mount(document.body, Main)
  215. /*
  216. *
  217. * WEBSOCKETS
  218. *
  219. */
  220. const connect = (username) => {
  221. const wsUrl = location.href.replace('http', 'ws')
  222. State.websocket = new WebSocket(wsUrl)
  223. State.websocket.onopen = (e) => {
  224. wire({kind: 'login', value: username})
  225. }
  226. State.websocket.onmessage = (e) => {
  227. const message = JSON.parse(e.data)
  228. if(message.online) {
  229. State.online = message.online
  230. }
  231. if(!doNotLog.has(message.kind)) {
  232. console.log(message)
  233. }
  234. signal(message)
  235. m.redraw()
  236. }
  237. State.websocket.onclose = (e) => {
  238. if(!e.wasClean) {
  239. setTimeout(connect, 1000, username)
  240. }
  241. m.redraw()
  242. }
  243. }
  244. if(localStorage.username) {
  245. connect(localStorage.username)
  246. }
  247. addEventListener('pagehide', Media.turnOff)