|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255 |
- const State = {
- username: null,
- websocket: null,
- online: [],
- posts: [],
- rpcs: {},
- media: {},
- }
-
- /*
- *
- * SIGNALING
- *
- */
- const wire = (message) => State.websocket.send(JSON.stringify(message))
- const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message}))
- const signalPeerStop = (username) => signal({kind: 'peerInfo', value: {type: 'stop'}, source: username})
- const listen = (kind, handler) => addEventListener(kind, handler)
- listen('login', ({detail}) => State.username = detail.value)
- listen('post', ({detail}) => State.posts.push(detail))
- listen('peerInfo', (e) => onPeerInfo(e))
- const doNotLog = new Set(['login', 'post', 'peerInfo'])
-
-
- /*
- *
- * WEBRTC
- *
- */
- const getOrCreateRpc = (username) => {
- if(State.username === username) {
- return
- }
- const myStream = State.media[State.username]
- if(!State.rpcs[username] && myStream) {
- const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]})
- myStream.getTracks().forEach(track => rpc.addTrack(track, myStream))
- rpc.onicecandidate = ({candidate}) => {
- if(candidate) {
- wire({kind: 'peerInfo', value: {type: 'candidate', candidate}})
- }
- }
- rpc.ontrack = (e) => {
- State.media[username] = e.streams[0]
- m.redraw()
- }
- rpc.onclose = (e) => {
- console.log(username, e)
- }
- State.rpcs[username] = rpc
- }
- return State.rpcs[username]
- }
- const onPeerInfo = async ({detail: message}) => {
- const rpc = getOrCreateRpc(message.source)
- if(rpc && message.value.type === 'request') {
- const localOffer = await rpc.createOffer()
- await rpc.setLocalDescription(localOffer)
- wire({kind: 'peerInfo', value: localOffer})
- }
- else if(rpc && message.value.type === 'offer') {
- const remoteOffer = new RTCSessionDescription(message.value)
- await rpc.setRemoteDescription(remoteOffer)
- const localAnswer = await rpc.createAnswer()
- await rpc.setLocalDescription(localAnswer)
- wire({kind: 'peerInfo', value: localAnswer})
- }
- else if(rpc && message.value.type === 'answer') {
- const remoteAnswer = new RTCSessionDescription(message.value)
- await rpc.setRemoteDescription(remoteAnswer)
- }
- else if(rpc && message.value.type === 'candidate') {
- const candidate = new RTCIceCandidate(message.value.candidate)
- rpc.addIceCandidate(candidate)
- }
- else if(message.value.type === 'stop') {
- if(State.media[message.source]) {
- State.media[message.source].getTracks().map(track => track.stop())
- delete State.media[message.source]
- }
- if(State.rpcs[message.source]) {
- State.rpcs[message.source].close()
- delete State.rpcs[message.source]
- }
- }
- else if(rpc) {
- console.log('uncaught', message)
- }
- }
-
- /*
- *
- * GUI
- *
- */
- const autoFocus = (vnode) => {
- vnode.dom.focus()
- }
- const scrollIntoView = (vnode) => {
- vnode.dom.scrollIntoView()
- }
- const prettyTime = (ts) => {
- return ts.slice(11, 19)
- }
- const Video = {
- appendStream: ({username, stream}) => ({dom}) => {
- dom.autoplay = true
- dom.muted = (username === State.username)
- dom.srcObject = stream
- },
- view({attrs}) {
- const rpc = State.rpcs[attrs.username] || {iceConnectionState: m.trust(' ')}
- return m('.video-container',
- m('.video-source', attrs.username),
- m('.video-state', rpc.iceConnectionState),
- m('video', {playsinline: true, oncreate: Video.appendStream(attrs)}),
- )
- },
- }
- const Media = {
- constraints: {audio: true, video: {width: {ideal: 320}, facingMode: 'user'}},
- turnOn: async () => {
- const media = await navigator.mediaDevices.getUserMedia(Media.constraints)
- State.media[State.username] = media
- wire({kind: 'peerInfo', value: {type: 'request'}})
- m.redraw()
- },
- turnOff: () => {
- wire({kind: 'peerInfo', value: {type: 'stop'}})
- State.online.forEach(signalPeerStop)
- },
- view() {
- if(!State.media[State.username]) {
- return m('.media',
- m('button', {onclick: Media.turnOn}, 'turn media on'),
- )
- }
- else {
- return m('.media',
- m('button', {onclick: Media.turnOff}, 'turn media off'),
- m('.videos',
- Object.entries(State.media).map(([username, stream]) =>
- m(Video, {username, stream})
- ),
- ),
- )
- }
- }
- }
- const Login = {
- sendLogin: (e) => {
- e.preventDefault()
- const username = e.target.username.value
- localStorage.username = username
- connect(username)
- },
- sendLogout: (e) => {
- Media.turnOff()
- wire({kind: 'logout'})
- State.posts = []
- },
- view() {
- const attrs = {
- oncreate: autoFocus,
- name: 'username',
- autocomplete: 'off',
- value: localStorage.username,
- }
- return m('.login',
- m('form', {onsubmit: Login.sendLogin},
- m('input', attrs),
- m('button', 'Login'),
- ),
- m('.error', State.info),
- )
- },
- }
- const Chat = {
- sendPost: (e) => {
- e.preventDefault()
- const field = e.target.text
- if(field.value) {
- wire({kind: 'post', value: field.value})
- field.value = ''
- }
- },
- view() {
- return m('.chat',
- m('.posts',
- State.posts.map(post => m('.post', {oncreate: scrollIntoView},
- m('.ts', prettyTime(post.ts)),
- m('.source', post.source || '~'),
- m('.text', post.value),
- )),
- ),
- m('form.actions', {onsubmit: Chat.sendPost},
- m('input', {oncreate: autoFocus, name: 'text', autocomplete: 'off'}),
- m('button', 'Send'),
- ),
- m('.online',
- m('button', {onclick: Login.sendLogout}, 'Logout'),
- m('ul.user-list', State.online.map(username => m('li', username))),
- ),
- m(Media),
- )
- },
- }
- const Main = {
- view() {
- const connected = State.websocket && State.websocket.readyState === 1
- return connected ? m(Chat) : m(Login)
- },
- }
- m.mount(document.body, Main)
-
- /*
- *
- * WEBSOCKETS
- *
- */
- const connect = (username) => {
- const wsUrl = location.href.replace('http', 'ws')
-
- State.websocket = new WebSocket(wsUrl)
-
- State.websocket.onopen = (e) => {
- wire({kind: 'login', value: username})
- }
-
- State.websocket.onmessage = (e) => {
- const message = JSON.parse(e.data)
-
- if(message.online) {
- State.online = message.online
- }
-
- if(!doNotLog.has(message.kind)) {
- console.log(message)
- }
- signal(message)
-
- m.redraw()
- }
-
- State.websocket.onclose = (e) => {
- if(!e.wasClean) {
- setTimeout(connect, 1000, username)
- }
- m.redraw()
- }
- }
- if(localStorage.username) {
- connect(localStorage.username)
- }
- addEventListener('pagehide', Media.turnOff)
|