| const allStreams = {} | |||||
| const allRPCs = {} | |||||
| // https://stackoverflow.com/a/2117523 | // https://stackoverflow.com/a/2117523 | ||||
| function uuidv4() { | function uuidv4() { | ||||
| ) | ) | ||||
| } | } | ||||
| function newRPC(uid, target) { | |||||
| addEventListener('rpc-needed', ({detail}) => { | |||||
| const {kind, target} = detail.value | |||||
| const isInitiatedLocally = !detail.source | |||||
| const uid = detail.value.uid || uuidv4() | |||||
| const rpc = new RTCPeerConnection(rpcConfig) | const rpc = new RTCPeerConnection(rpcConfig) | ||||
| rpc.onicecandidate = ({candidate}) => { | rpc.onicecandidate = ({candidate}) => { | ||||
| if(candidate && candidate.candidate) { | if(candidate && candidate.candidate) { | ||||
| const value = JSON.parse(JSON.stringify(candidate)) | |||||
| wire({kind: 'ice-candidate', value: {...value, uid}, target}) | |||||
| const cand = JSON.parse(JSON.stringify(candidate)) | |||||
| wire({kind: 'ice-candidate', value: {...cand, uid}, target}) | |||||
| } | } | ||||
| } | } | ||||
| rpc.ontrack = ({streams: [stream]}) => { | rpc.ontrack = ({streams: [stream]}) => { | ||||
| stream.getTracks().forEach(tr => ScreenShare.stream.addTrack(tr, stream)) | |||||
| stream.getTracks().forEach(track => | |||||
| allRPCs[uid].receiver.addTrack(track, stream) | |||||
| ) | |||||
| } | } | ||||
| rpc.onconnectionstatechange = () => { | |||||
| console.log(rpc.connectionState) | |||||
| rpc.oniceconnectionstatechange = (e) => { | |||||
| console.log(rpc.iceConnectionState) | |||||
| } | } | ||||
| return rpc | |||||
| } | |||||
| rpc.onnegotiationneeded = (e) => { | |||||
| // if(isInitiatedLocally) { | |||||
| // signal({kind: 'rpc-initiate', value: {uid}}) | |||||
| // } | |||||
| } | |||||
| allRPCs[uid] = {kind, target, rpc, isInitiatedLocally} | |||||
| signal({kind: 'rpc-new', value: {kind, target, uid}}) | |||||
| if(isInitiatedLocally) { | |||||
| wire({kind: 'rpc-needed', value: {kind, target: State.username, uid}, target}) | |||||
| } | |||||
| }) | |||||
| addEventListener('rpc-setup', async ({detail}) => { | |||||
| const {uid, sender, receiver} = detail.value | |||||
| allRPCs[uid].sender = sender | |||||
| allRPCs[uid].receiver = receiver | |||||
| if(sender) { | |||||
| sender.getTracks().forEach(tr => allRPCs[uid].rpc.addTrack(tr, sender)) | |||||
| } | |||||
| if(allRPCs[uid].isInitiatedLocally) { | |||||
| signal({kind: 'rpc-initiate', value: {uid}}) | |||||
| } | |||||
| }) | |||||
| addEventListener('screen-start', async ({detail}) => { | |||||
| const {target, stream} = detail.value | |||||
| const uid = uuidv4() | |||||
| const rpc = newRPC(uid, target) | |||||
| allStreams[uid] = {target, rpc} | |||||
| addEventListener('rpc-initiate', async({detail}) => { | |||||
| const {uid} = detail.value | |||||
| const {rpc, target} = allRPCs[uid] | |||||
| stream.getTracks().forEach(tr => rpc.addTrack(tr, stream)) | |||||
| const localOffer = await rpc.createOffer() | const localOffer = await rpc.createOffer() | ||||
| await rpc.setLocalDescription(localOffer) | await rpc.setLocalDescription(localOffer) | ||||
| wire({kind: 'screen-offer', value: {...localOffer, uid}, target}) | |||||
| wire({kind: 'rpc-offer', value: {...localOffer, uid}, target}) | |||||
| }) | }) | ||||
| addEventListener('screen-offer', async ({detail}) => { | |||||
| const target = detail.source | |||||
| const uid = detail.value.uid | |||||
| const rpc = newRPC(uid, target) | |||||
| allStreams[uid] = {target, rpc} | |||||
| addEventListener('rpc-offer', async ({detail}) => { | |||||
| const {uid} = detail.value | |||||
| const {rpc, target} = allRPCs[uid] | |||||
| await rpc.setRemoteDescription(detail.value) | await rpc.setRemoteDescription(detail.value) | ||||
| const localAnswer = await rpc.createAnswer() | const localAnswer = await rpc.createAnswer() | ||||
| await rpc.setLocalDescription(localAnswer) | await rpc.setLocalDescription(localAnswer) | ||||
| wire({kind: 'screen-answer', value: {...localAnswer, uid}, target}) | |||||
| wire({kind: 'rpc-answer', value: {...localAnswer, uid}, target}) | |||||
| }) | }) | ||||
| addEventListener('screen-answer', async ({detail}) => { | |||||
| await allStreams[detail.value.uid].rpc.setRemoteDescription(detail.value) | |||||
| addEventListener('rpc-answer', async ({detail}) => { | |||||
| const {uid} = detail.value | |||||
| await allRPCs[uid].rpc.setRemoteDescription(detail.value) | |||||
| }) | }) | ||||
| addEventListener('ice-candidate', async ({detail}) => { | addEventListener('ice-candidate', async ({detail}) => { | ||||
| await allStreams[detail.value.uid].rpc.addIceCandidate(detail.value) | |||||
| const {uid} = detail.value | |||||
| await allRPCs[uid].rpc.addIceCandidate(detail.value) | |||||
| }) | }) | ||||
| addEventListener('load', () => { | addEventListener('load', () => { | ||||
| doNotLog.add('screen-start') | |||||
| doNotLog.add('screen-offer') | |||||
| doNotLog.add('screen-answer') | |||||
| doNotLog.add('rpc-needed') | |||||
| doNotLog.add('rpc-offer') | |||||
| doNotLog.add('rpc-answer') | |||||
| doNotLog.add('ice-candidate') | doNotLog.add('ice-candidate') | ||||
| }) | }) |
| const connections = {} | |||||
| const datachannels = {} | |||||
| const videoElements = {} | |||||
| function setStream({source, stream}) { | |||||
| const element = videoElements[source] | |||||
| const active = element && element.srcObject | |||||
| if(active && active.id !== stream.id) { | |||||
| videoElements[source].srcObject.getTracks().forEach(tr => tr.stop()) | |||||
| } | |||||
| element.srcObject = stream | |||||
| if(source === State.username) { | |||||
| signal({kind: 'stream-refresh'}) | |||||
| } | |||||
| } | |||||
| function reloadAllStreams() { | |||||
| State.online.forEach(username => { | |||||
| const rpc = connections[username] | |||||
| if(rpc) { | |||||
| rpc.getSenders().map(s => console.log(s) || rpc.removeTrack(s)) | |||||
| } | |||||
| signal({kind: 'rpc', value: {type: 'request'}, source: username}) | |||||
| }) | |||||
| } | |||||
| function createConnection(target) { | |||||
| const rpc = new RTCPeerConnection(rpcConfig) | |||||
| rpc.onicecandidate = ({candidate}) => { | |||||
| if(candidate && candidate.candidate) { | |||||
| const value = {type: 'candidate', candidate} | |||||
| wire({kind: 'rpc', value, target}) | |||||
| } | |||||
| } | |||||
| rpc.ontrack = ({streams: [stream]}) => { | |||||
| setStream({source: target, stream}) | |||||
| } | |||||
| rpc.onconnectionstatechange = () => { | |||||
| if(rpc.connectionState === 'failed') { | |||||
| console.log(target, 'failed, retry!') | |||||
| wire({kind: 'rpc', value: {type: 'request'}, target}) | |||||
| } | |||||
| } | |||||
| rpc.ondatachannel = ({channel}) => { | |||||
| datachannels[target] = channel | |||||
| datachannels[target].onmessage = ({data}) => console.log(data) | |||||
| // for testing purposes | |||||
| const msg = `rpc established from ${target} to ${State.username}` | |||||
| datachannels[target].send(msg) | |||||
| console.log(msg) | |||||
| } | |||||
| rpc.onnegotiationneeded = () => { | |||||
| console.log('lmao renegotiate bi') | |||||
| } | |||||
| connections[target] = rpc | |||||
| if(State.username > target) { | |||||
| datachannels[target] = rpc.createDataChannel('test') | |||||
| datachannels[target].onmessage = ({data}) => console.log(data) | |||||
| signal({kind: 'rpc', value: {type: 'request'}, source: target}) | |||||
| } | |||||
| } | |||||
| function setSenders(rpc, username) { | |||||
| const element = videoElements[username] | |||||
| const stream = element && element.srcObject | |||||
| rpc.getSenders().forEach(se => rpc.removeTrack(se)) | |||||
| if(stream) { | |||||
| stream.getTracks().forEach(tr => rpc.addTrack(tr, stream)) | |||||
| } | |||||
| } | |||||
| async function handlePeerInfo({source: target, value, kind}) { | |||||
| const rpc = connections[target] | |||||
| if(!rpc) { | |||||
| return | |||||
| } | |||||
| if(value.type === 'request') { | |||||
| const localOffer = await rpc.createOffer() | |||||
| await rpc.setLocalDescription(localOffer) | |||||
| wire({kind, value: localOffer, target}) | |||||
| } | |||||
| else if(value.type === 'offer') { | |||||
| const remoteOffer = new RTCSessionDescription(value) | |||||
| await rpc.setRemoteDescription(remoteOffer) | |||||
| const localAnswer = await rpc.createAnswer() | |||||
| await rpc.setLocalDescription(localAnswer) | |||||
| wire({kind, value: localAnswer, target}) | |||||
| } | |||||
| else if(value.type === 'answer') { | |||||
| const remoteAnswer = new RTCSessionDescription(value) | |||||
| await rpc.setRemoteDescription(remoteAnswer).catch(e => e) | |||||
| } | |||||
| else if(value.type === 'candidate') { | |||||
| const candidate = new RTCIceCandidate(value.candidate) | |||||
| await rpc.addIceCandidate(candidate).catch(e => e) | |||||
| } | |||||
| } | |||||
| function destroyConnection(username) { | |||||
| if(datachannels[username]) { | |||||
| datachannels[username].close() | |||||
| delete datachannels[username] | |||||
| } | |||||
| if(connections[username]) { | |||||
| connections[username].getReceivers().forEach(r => r.track.stop()) | |||||
| connections[username].close() | |||||
| delete connections[username] | |||||
| } | |||||
| } | |||||
| function destroyAll() { | |||||
| const people = new Set() | |||||
| const collect = [connections, datachannels, streams, screen] | |||||
| .map(collection => Object.keys(collection)) | |||||
| .forEach(keys => keys.forEach(key => people.add(key))) | |||||
| people.forEach(destroyConnection) | |||||
| } | |||||
| addEventListener('stream-refresh', reloadAllStreams) | |||||
| addEventListener('stream', (e) => setStream(e.detail.value)) | |||||
| addEventListener('rpc', (e) => handlePeerInfo(e.detail)) | |||||
| addEventListener('join', (e) => createConnection(e.detail.value)) | |||||
| addEventListener('leave', (e) => destroyConnection(e.detail.value)) | |||||
| addEventListener('logout', () => destroyAll()) | |||||
| addEventListener('load', () => doNotLog.add('rpc')) |
| addEventListener('rpc-new', ({detail}) => { | |||||
| const {uid, kind} = detail.value | |||||
| if(kind === 'screen') { | |||||
| const sender = ScreenShare.isSelf && ScreenShare.stream | |||||
| const receiver = !ScreenShare.isSelf && ScreenShare.stream | |||||
| signal({kind: 'rpc-setup', value: {uid, sender, receiver}}) | |||||
| } | |||||
| }) | |||||
| const ScreenShareConfig = { | const ScreenShareConfig = { | ||||
| view() { | view() { | ||||
| const start = async () => { | const start = async () => { |
| const Toggle = { | |||||
| view({attrs: {label, value}}) { | |||||
| const onclick = () => { | |||||
| StreamContainer[value] = !StreamContainer[value] | |||||
| requestStream() | |||||
| } | |||||
| const checked = StreamContainer[value] | |||||
| return m('label.styled', | |||||
| m('input[type=checkbox]', {checked, onclick}), | |||||
| label, | |||||
| ) | |||||
| } | |||||
| } | |||||
| const VideoConfig = { | |||||
| get video() { | |||||
| return StreamContainer.videoOn | |||||
| && State.online.length < 10 | |||||
| && params.get('v') !== '0' | |||||
| && {width: {ideal: 320}, facingMode: 'user', frameRate: 26} | |||||
| }, | |||||
| get audio() { | |||||
| return StreamContainer.audioOn | |||||
| && params.get('a') !== '0' | |||||
| }, | |||||
| view() { | |||||
| return [ | |||||
| m(Toggle, {label: 'video', value: 'videoOn'}), | |||||
| m(Toggle, {label: 'audio', value: 'audioOn'}), | |||||
| ] | |||||
| addEventListener('rpc-new', ({detail}) => { | |||||
| const {uid, kind, target} = detail.value | |||||
| if(kind === 'video') { | |||||
| const sender = VideoShare.streams[State.username] | |||||
| const receiver = VideoShare.resetStream(target) | |||||
| signal({kind: 'rpc-setup', value: {uid, sender, receiver}}) | |||||
| } | } | ||||
| } | |||||
| const requestStream = async () => { | |||||
| await navigator.mediaDevices | |||||
| .getUserMedia(VideoConfig) | |||||
| .then(stream => | |||||
| signal({kind: 'stream', value: {source: State.username, stream}}) | |||||
| ) | |||||
| .catch(() => { | |||||
| const stream = new MediaStream() | |||||
| signal({kind: 'stream', value: {source: State.username, stream}}) | |||||
| }) | |||||
| } | |||||
| }) | |||||
| const Video = { | const Video = { | ||||
| setUp: (username) => ({dom}) => { | |||||
| if(username === State.username) { | |||||
| dom.muted = true | |||||
| requestStream() | |||||
| } | |||||
| videoElements[username] = dom | |||||
| }, | |||||
| tearDown: (username) => () => { | |||||
| const stream = videoElements[username].srcObject | |||||
| if(stream) stream.getTracks().forEach(tr => tr.stop()) | |||||
| videoElements[username] = null | |||||
| delete videoElements[username] | |||||
| }, | |||||
| view({attrs: {username}}) { | view({attrs: {username}}) { | ||||
| const styleOuter = { | const styleOuter = { | ||||
| position: 'relative', | position: 'relative', | ||||
| ), | ), | ||||
| m('video[playsinline][autoplay]', { | m('video[playsinline][autoplay]', { | ||||
| style: styleVideo, | style: styleVideo, | ||||
| oncreate: this.setUp(username), | |||||
| onremove: this.tearDown(username), | |||||
| srcObject: VideoShare.streams[username], | |||||
| oncreate: ({dom}) => {dom.muted = username === State.username}, | |||||
| onremove: () => VideoShare.resetStream(username), | |||||
| }), | }), | ||||
| ) | ) | ||||
| }, | }, | ||||
| } | } | ||||
| const StreamContainer = { | |||||
| const Toggle = { | |||||
| view({attrs: {key, label}}) { | |||||
| const onclick = () => { | |||||
| VideoShare[key] = !VideoShare[key] | |||||
| VideoShare.getStream() | |||||
| } | |||||
| const checked = VideoShare[key] | |||||
| return m('label.styled', | |||||
| m('input[type=checkbox]', {onclick, checked}), | |||||
| label, | |||||
| ) | |||||
| }, | |||||
| } | |||||
| const VideoShareConfig = { | |||||
| get video() { | |||||
| return VideoShare.videoOn | |||||
| && State.online.length < 10 | |||||
| && params.get('v') !== '0' | |||||
| && {width: {ideal: 320}, facingMode: 'user', frameRate: 26} | |||||
| }, | |||||
| get audio() { | |||||
| return VideoShare.audioOn | |||||
| && params.get('a') !== '0' | |||||
| }, | |||||
| view() { | |||||
| return [ | |||||
| m(Toggle, {key: 'videoOn', label: 'video'}), | |||||
| m(Toggle, {key: 'audioOn', label: 'audio'}), | |||||
| ] | |||||
| }, | |||||
| } | |||||
| const VideoShare = { | |||||
| videoOn: true, | videoOn: true, | ||||
| audioOn: true, | audioOn: true, | ||||
| streams: {}, | |||||
| oncreate(e) { | |||||
| VideoShare.getStream() | |||||
| }, | |||||
| resetStream(target) { | |||||
| if(VideoShare.streams[target]) { | |||||
| VideoShare.streams[target].getTracks().forEach(tr => tr.stop()) | |||||
| } | |||||
| VideoShare.streams[target] = new MediaStream() | |||||
| return VideoShare.streams[target] | |||||
| }, | |||||
| async getStream() { | |||||
| VideoShare.resetStream(State.username) | |||||
| await navigator.mediaDevices.getUserMedia(VideoShareConfig) | |||||
| .catch(error => console.error(error)) | |||||
| .then(stream => {VideoShare.streams[State.username] = stream}) | |||||
| m.redraw() | |||||
| State.others.forEach(target => | |||||
| signal({kind: 'rpc-needed', value: {kind: 'video', target}}) | |||||
| ) | |||||
| }, | |||||
| view() { | view() { | ||||
| const dims = [ | const dims = [ | ||||
| Math.floor((1 + Math.sqrt(4 * State.online.length - 3)) / 2), | Math.floor((1 + Math.sqrt(4 * State.online.length - 3)) / 2), | ||||
| ) | ) | ||||
| }, | }, | ||||
| } | } | ||||
| addEventListener('join', ({detail: {value: target}}) => { | |||||
| signal({kind: 'rpc-needed', value: {kind: 'video', target}}) | |||||
| }) | |||||
| addEventListener('load', () => { | addEventListener('load', () => { | ||||
| Headers.push([VideoConfig]) | |||||
| Apps.push([StreamContainer, {key: 'stream-container'}]) | |||||
| Headers.push([VideoShareConfig]) | |||||
| Apps.push([VideoShare, {key: 'stream-container'}]) | |||||
| }) | }) |
| <script src="/libs/purify.min.js"></script> | <script src="/libs/purify.min.js"></script> | ||||
| <script src="/pico.js" defer></script> | <script src="/pico.js" defer></script> | ||||
| <script src="/apps/rpc.js"></script> | <script src="/apps/rpc.js"></script> | ||||
| <script src="/apps/screen.js"></script> | |||||
| <!-- <script src="/apps/rpc.js"></script> --> | |||||
| <!-- <script src="/apps/streams.js"></script> --> | |||||
| <script src="/apps/video.js"></script> | |||||
| <!-- <script src="/apps/screen.js"></script> --> | |||||
| <!-- <script src="/apps/chat.js"></script> --> | <!-- <script src="/apps/chat.js"></script> --> | ||||
| <!-- <script src="/apps/volume.js"></script> --> | <!-- <script src="/apps/volume.js"></script> --> | ||||
| </head> | </head> | ||||
| justify-content: center; | justify-content: center; | ||||
| align-items: center; | align-items: center; | ||||
| padding: 0 0.5em; | padding: 0 0.5em; | ||||
| user-select: none; | |||||
| } | } | ||||
| header { | header { | ||||
| display: grid; | display: grid; |