| @@ -1,4 +1,4 @@ | |||
| const allStreams = {} | |||
| const allRPCs = {} | |||
| // https://stackoverflow.com/a/2117523 | |||
| function uuidv4() { | |||
| @@ -7,60 +7,85 @@ 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) | |||
| rpc.onicecandidate = ({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]}) => { | |||
| 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() | |||
| 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) | |||
| const localAnswer = await rpc.createAnswer() | |||
| 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}) => { | |||
| await allStreams[detail.value.uid].rpc.addIceCandidate(detail.value) | |||
| const {uid} = detail.value | |||
| await allRPCs[uid].rpc.addIceCandidate(detail.value) | |||
| }) | |||
| 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') | |||
| }) | |||
| @@ -1,133 +0,0 @@ | |||
| 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')) | |||
| @@ -1,3 +1,11 @@ | |||
| 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 = { | |||
| view() { | |||
| const start = async () => { | |||
| @@ -1,59 +1,12 @@ | |||
| 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 = { | |||
| 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}}) { | |||
| const styleOuter = { | |||
| position: 'relative', | |||
| @@ -83,15 +36,68 @@ const Video = { | |||
| ), | |||
| m('video[playsinline][autoplay]', { | |||
| 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, | |||
| 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() { | |||
| const dims = [ | |||
| Math.floor((1 + Math.sqrt(4 * State.online.length - 3)) / 2), | |||
| @@ -111,7 +117,10 @@ const StreamContainer = { | |||
| ) | |||
| }, | |||
| } | |||
| addEventListener('join', ({detail: {value: target}}) => { | |||
| signal({kind: 'rpc-needed', value: {kind: 'video', target}}) | |||
| }) | |||
| addEventListener('load', () => { | |||
| Headers.push([VideoConfig]) | |||
| Apps.push([StreamContainer, {key: 'stream-container'}]) | |||
| Headers.push([VideoShareConfig]) | |||
| Apps.push([VideoShare, {key: 'stream-container'}]) | |||
| }) | |||
| @@ -9,9 +9,8 @@ | |||
| <script src="/libs/purify.min.js"></script> | |||
| <script src="/pico.js" defer></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/volume.js"></script> --> | |||
| </head> | |||
| @@ -32,6 +31,7 @@ label.styled { | |||
| justify-content: center; | |||
| align-items: center; | |||
| padding: 0 0.5em; | |||
| user-select: none; | |||
| } | |||
| header { | |||
| display: grid; | |||