| const State = { | const State = { | ||||
| username: null, | |||||
| websocket: null, | websocket: null, | ||||
| online: [], | online: [], | ||||
| posts: [], | posts: [], | ||||
| media: null, | |||||
| username: null, | |||||
| rpcs: {}, | |||||
| media: {}, | |||||
| } | } | ||||
| /* | /* | ||||
| */ | */ | ||||
| const wire = (message) => State.websocket.send(JSON.stringify(message)) | const wire = (message) => State.websocket.send(JSON.stringify(message)) | ||||
| const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: 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) | const listen = (kind, handler) => addEventListener(kind, handler) | ||||
| listen('login', ({detail}) => State.username = detail.value) | listen('login', ({detail}) => State.username = detail.value) | ||||
| listen('post', ({detail}) => State.posts.push(detail)) | listen('post', ({detail}) => State.posts.push(detail)) | ||||
| listen('peerInfo', (e) => onPeerInfo(e)) | listen('peerInfo', (e) => onPeerInfo(e)) | ||||
| const doNotLog = new Set(['login', 'post', 'peerInfo']) | const doNotLog = new Set(['login', 'post', 'peerInfo']) | ||||
| /* | /* | ||||
| * | * | ||||
| * WEBRTC | * WEBRTC | ||||
| * | * | ||||
| */ | */ | ||||
| const rpcs = {} | |||||
| const streams = {} | |||||
| const getOrCreateRpc = (username) => { | const getOrCreateRpc = (username) => { | ||||
| if(!rpcs[username] && State.media) { | |||||
| 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'}]}) | const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]}) | ||||
| State.media.getTracks().forEach(track => rpc.addTrack(track, State.media)) | |||||
| myStream.getTracks().forEach(track => rpc.addTrack(track, myStream)) | |||||
| rpc.onicecandidate = ({candidate}) => { | rpc.onicecandidate = ({candidate}) => { | ||||
| if(candidate) { | if(candidate) { | ||||
| wire({kind: 'peerInfo', value: {type: 'candidate', candidate}}) | wire({kind: 'peerInfo', value: {type: 'candidate', candidate}}) | ||||
| } | } | ||||
| } | } | ||||
| rpc.ontrack = (e) => { | rpc.ontrack = (e) => { | ||||
| streams[username] = e.streams[0] | |||||
| State.media[username] = e.streams[0] | |||||
| m.redraw() | m.redraw() | ||||
| } | } | ||||
| rpc.onclose = (e) => { | rpc.onclose = (e) => { | ||||
| console.log(username, e) | console.log(username, e) | ||||
| } | } | ||||
| rpcs[username] = rpc | |||||
| State.rpcs[username] = rpc | |||||
| } | } | ||||
| return rpcs[username] | |||||
| return State.rpcs[username] | |||||
| } | } | ||||
| const onPeerInfo = async ({detail: message}) => { | const onPeerInfo = async ({detail: message}) => { | ||||
| if(State.username === message.source) { | |||||
| return | |||||
| } | |||||
| const rpc = getOrCreateRpc(message.source) | const rpc = getOrCreateRpc(message.source) | ||||
| if(rpc && message.value.type === 'request') { | if(rpc && message.value.type === 'request') { | ||||
| const localOffer = await rpc.createOffer() | const localOffer = await rpc.createOffer() | ||||
| rpc.addIceCandidate(candidate) | rpc.addIceCandidate(candidate) | ||||
| } | } | ||||
| else if(message.value.type === 'stop') { | else if(message.value.type === 'stop') { | ||||
| if(streams[message.source]) { | |||||
| streams[message.source].getTracks().map(track => track.stop()) | |||||
| delete streams[message.source] | |||||
| if(State.media[message.source]) { | |||||
| State.media[message.source].getTracks().map(track => track.stop()) | |||||
| delete State.media[message.source] | |||||
| } | } | ||||
| if(rpcs[message.source]) { | |||||
| rpcs[message.source].close() | |||||
| delete rpcs[message.source] | |||||
| if(State.rpcs[message.source]) { | |||||
| State.rpcs[message.source].close() | |||||
| delete State.rpcs[message.source] | |||||
| } | } | ||||
| } | } | ||||
| else { | |||||
| else if(rpc) { | |||||
| console.log('uncaught', message) | console.log('uncaught', message) | ||||
| } | } | ||||
| } | } | ||||
| return ts.slice(11, 19) | return ts.slice(11, 19) | ||||
| } | } | ||||
| const Video = { | const Video = { | ||||
| appendStream: (stream) => ({dom}) => { | |||||
| appendStream: ({username, stream}) => ({dom}) => { | |||||
| dom.autoplay = true | dom.autoplay = true | ||||
| dom.muted = true | |||||
| dom.muted = (username === State.username) | |||||
| dom.srcObject = stream | dom.srcObject = stream | ||||
| }, | }, | ||||
| view({attrs}) { | view({attrs}) { | ||||
| const rpc = State.rpcs[attrs.username] || {iceConnectionState: m.trust(' ')} | |||||
| return m('.video-container', | return m('.video-container', | ||||
| m('.video-source', attrs.username), | m('.video-source', attrs.username), | ||||
| m('video', {playsinline: true, oncreate: Video.appendStream(attrs.stream)}), | |||||
| m('.video-state', rpc.iceConnectionState), | |||||
| m('video', {playsinline: true, oncreate: Video.appendStream(attrs)}), | |||||
| ) | ) | ||||
| }, | }, | ||||
| } | } | ||||
| const Media = { | const Media = { | ||||
| constraints: {audio: true, video: {width: {ideal: 320}, facingMode: 'user'}}, | constraints: {audio: true, video: {width: {ideal: 320}, facingMode: 'user'}}, | ||||
| turnOn: async () => { | turnOn: async () => { | ||||
| State.media = await navigator.mediaDevices.getUserMedia(Media.constraints) | |||||
| const media = await navigator.mediaDevices.getUserMedia(Media.constraints) | |||||
| State.media[State.username] = media | |||||
| wire({kind: 'peerInfo', value: {type: 'request'}}) | wire({kind: 'peerInfo', value: {type: 'request'}}) | ||||
| m.redraw() | m.redraw() | ||||
| }, | }, | ||||
| turnOff: () => { | turnOff: () => { | ||||
| wire({kind: 'peerInfo', value: {type: 'stop'}}) | wire({kind: 'peerInfo', value: {type: 'stop'}}) | ||||
| // signal internally | |||||
| Object.keys(rpcs).forEach(source => signal({kind: 'peerInfo', value: {type: 'stop'}, source})) | |||||
| State.media.getTracks().map(track => track.stop()) | |||||
| delete State.media | |||||
| State.online.forEach(signalPeerStop) | |||||
| }, | }, | ||||
| view() { | view() { | ||||
| if(!State.media) { | |||||
| if(!State.media[State.username]) { | |||||
| return m('.media', | return m('.media', | ||||
| m('button', {onclick: Media.turnOn}, 'turn media on'), | m('button', {onclick: Media.turnOn}, 'turn media on'), | ||||
| ) | ) | ||||
| return m('.media', | return m('.media', | ||||
| m('button', {onclick: Media.turnOff}, 'turn media off'), | m('button', {onclick: Media.turnOff}, 'turn media off'), | ||||
| m('.videos', | m('.videos', | ||||
| m(Video, {username: State.username, stream: State.media}), | |||||
| Object.entries(streams).map(([username, stream]) => | |||||
| Object.entries(State.media).map(([username, stream]) => | |||||
| m(Video, {username, stream}) | m(Video, {username, stream}) | ||||
| ), | ), | ||||
| ), | ), | ||||
| connect(username) | connect(username) | ||||
| }, | }, | ||||
| sendLogout: (e) => { | sendLogout: (e) => { | ||||
| State.media && Media.turnOff() | |||||
| Media.turnOff() | |||||
| wire({kind: 'logout'}) | wire({kind: 'logout'}) | ||||
| State.posts = [] | State.posts = [] | ||||
| }, | }, | ||||
| m('input', attrs), | m('input', attrs), | ||||
| m('button', 'Login'), | m('button', 'Login'), | ||||
| ), | ), | ||||
| State.kind === 'error' && m('.error', State.info), | |||||
| m('.error', State.info), | |||||
| ) | ) | ||||
| }, | }, | ||||
| } | } | ||||
| if(localStorage.username) { | if(localStorage.username) { | ||||
| connect(localStorage.username) | connect(localStorage.username) | ||||
| } | } | ||||
| addEventListener('pagehide', Media.turnOff) |