| @@ -1,131 +1,66 @@ | |||
| const connections = {} | |||
| const datachannels = {} | |||
| const streams = {} | |||
| const screen = {} | |||
| const allStreams = {} | |||
| function setStream({source, stream}) { | |||
| if(streams[source] && streams[source].id !== stream.id) { | |||
| streams[source].getTracks().forEach(track => track.stop()) | |||
| } | |||
| streams[source] = stream | |||
| m.redraw() | |||
| if(source === State.username) { | |||
| reloadAllStreams() | |||
| } | |||
| } | |||
| function reloadAllStreams() { | |||
| State.online.forEach(username => { | |||
| const rpc = connections[username] | |||
| if(rpc) { | |||
| rpc.getSenders().map(s => rpc.removeTrack(s)) | |||
| } | |||
| signal({kind: 'rpc', value: {type: 'request'}, source: username}) | |||
| }) | |||
| // https://stackoverflow.com/a/2117523 | |||
| function uuidv4() { | |||
| return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c => | |||
| (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16) | |||
| ) | |||
| } | |||
| function createConnection(target) { | |||
| function newRPC(uid, target) { | |||
| const rpc = new RTCPeerConnection(rpcConfig) | |||
| rpc.onicecandidate = ({candidate}) => { | |||
| if(candidate && candidate.candidate) { | |||
| const value = {type: 'candidate', candidate} | |||
| wire({kind: 'rpc', value, target}) | |||
| const value = JSON.parse(JSON.stringify(candidate)) | |||
| wire({kind: 'ice-candidate', value: {...value, uid}, target}) | |||
| } | |||
| } | |||
| rpc.ontrack = ({streams: [stream]}) => { | |||
| setStream({source: target, stream}) | |||
| stream.getTracks().forEach(tr => ScreenShare.stream.addTrack(tr, stream)) | |||
| } | |||
| rpc.onconnectionstatechange = () => { | |||
| if(rpc.connectionState === 'failed') { | |||
| console.log(target, 'failed, retry!') | |||
| wire({kind: 'rpc', value: {type: 'request'}, target}) | |||
| } | |||
| console.log(rpc.connectionState) | |||
| } | |||
| rpc.ondatachannel = ({channel}) => { | |||
| datachannels[target] = channel | |||
| datachannels[target].onmessage = ({data}) => console.log(data) | |||
| return rpc | |||
| } | |||
| // for testing purposes | |||
| const msg = `rpc established from ${target} to ${State.username}` | |||
| datachannels[target].send(msg) | |||
| console.log(msg) | |||
| } | |||
| addEventListener('screen-start', async ({detail}) => { | |||
| const {target, stream} = detail.value | |||
| const uid = uuidv4() | |||
| const rpc = newRPC(uid, target) | |||
| allStreams[uid] = {target, rpc} | |||
| 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}) | |||
| } | |||
| } | |||
| stream.getTracks().forEach(tr => rpc.addTrack(tr, stream)) | |||
| const localOffer = await rpc.createOffer() | |||
| await rpc.setLocalDescription(localOffer) | |||
| async function handlePeerInfo({source: target, value}) { | |||
| const rpc = connections[target] | |||
| wire({kind: 'screen-offer', value: {...localOffer, uid}, target}) | |||
| }) | |||
| if(!rpc) { | |||
| return | |||
| } | |||
| addEventListener('screen-offer', async ({detail}) => { | |||
| const target = detail.source | |||
| const uid = detail.value.uid | |||
| const rpc = newRPC(uid, target) | |||
| allStreams[uid] = {target, rpc} | |||
| const stream = streams[State.username] | |||
| if(stream) { | |||
| stream.getTracks().forEach(track => { | |||
| try { rpc.addTrack(track, stream) } | |||
| catch { } | |||
| }) | |||
| } | |||
| await rpc.setRemoteDescription(detail.value) | |||
| const localAnswer = await rpc.createAnswer() | |||
| await rpc.setLocalDescription(localAnswer) | |||
| if(value.type === 'request') { | |||
| const localOffer = await rpc.createOffer() | |||
| await rpc.setLocalDescription(localOffer) | |||
| wire({kind: 'rpc', 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: 'rpc', 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) | |||
| } | |||
| } | |||
| wire({kind: 'screen-answer', value: {...localAnswer, uid}, target}) | |||
| }) | |||
| function destroyConnection(username) { | |||
| if(streams[username]) { | |||
| streams[username].getTracks().forEach(t => t.stop()) | |||
| delete streams[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] | |||
| } | |||
| } | |||
| addEventListener('screen-answer', async ({detail}) => { | |||
| await allStreams[detail.value.uid].rpc.setRemoteDescription(detail.value) | |||
| }) | |||
| 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('ice-candidate', async ({detail}) => { | |||
| await allStreams[detail.value.uid].rpc.addIceCandidate(detail.value) | |||
| }) | |||
| 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('load', () => { | |||
| doNotLog.add('screen-start') | |||
| doNotLog.add('screen-offer') | |||
| doNotLog.add('screen-answer') | |||
| doNotLog.add('ice-candidate') | |||
| }) | |||
| @@ -0,0 +1,133 @@ | |||
| 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,57 +1,71 @@ | |||
| const ScreenShareConfig = { | |||
| toggle() { | |||
| if(ScreenShare.isOn && ScreenShare.isStreaming) { | |||
| wire({kind: 'screen-sharing-stop'}) | |||
| view() { | |||
| const start = async () => { | |||
| await ScreenShare.getStream() | |||
| wire({kind: 'screen-share-start'}) | |||
| } | |||
| const stop = () => wire({kind: 'screen-share-stop'}) | |||
| if (ScreenShare.source === null) { | |||
| return m('button', {onclick: start}, 'start screen sharing') | |||
| } | |||
| else if (ScreenShare.isSelf) { | |||
| return m('button', {onclick: stop}, 'stop screen sharing') | |||
| } | |||
| else { | |||
| ScreenShare.requestScreen() | |||
| return m('button[disabled]', `${ScreenShare.source} is screen sharing`) | |||
| } | |||
| }, | |||
| view() { | |||
| const checked = ScreenShare.isOn | |||
| const onclick = ScreenShareConfig.toggle | |||
| return m('label.styled', | |||
| m('input[type=checkbox]', {checked, onclick}), | |||
| 'screen-share', | |||
| ) | |||
| } | |||
| } | |||
| const ScreenShare = { | |||
| streamer: null, | |||
| stream: null, | |||
| get isStreaming() { | |||
| return ScreenShare.streamer === State.username | |||
| source: null, | |||
| stream: new MediaStream(), | |||
| get isSelf() { | |||
| return ScreenShare.source === State.username | |||
| }, | |||
| get isOn() { | |||
| return !! ScreenShare.streamer | |||
| async getStream() { | |||
| const stream = await navigator.mediaDevices.getDisplayMedia({}) | |||
| stream.getTracks() | |||
| .forEach(tr => ScreenShare.stream.addTrack(tr, stream)) | |||
| }, | |||
| async requestScreen() { | |||
| // ScreenShare.stream = await navigator.mediaDevices.getDisplayMedia() | |||
| // ScreenShare.streamer = State.username | |||
| // wire({kind: 'screen-sharing-start'}) | |||
| async stopStream() { | |||
| ScreenShare.stream.getTracks() | |||
| .forEach(tr => tr.stop()) | |||
| ScreenShare.stream = new MediaStream() | |||
| }, | |||
| view() { | |||
| const style = { | |||
| overflow: 'scroll', | |||
| backgroundColor: 'black', | |||
| backgroundColor: 'gray', | |||
| color: 'white', | |||
| fontFamily: 'monospace', | |||
| position: 'relative', | |||
| } | |||
| return ScreenShare.isOn && m('.screen-share', {style}, | |||
| m('.streamer', `${ScreenShare.streamer}'s stream`), | |||
| m('video.screen[playsinline][autoplay]', {srcObject: ScreenShare.stream}), | |||
| return m('.screen-share', {style}, | |||
| ScreenShare.isSelf ? m('span', 'You are sharing your screen') : | |||
| ScreenShare.source ? m('video[playsinline][autoplay]', {srcObject: ScreenShare.stream}) | |||
| : [], | |||
| ) | |||
| }, | |||
| } | |||
| addEventListener('screen-sharing-start', ({detail}) => { | |||
| ScreenShare.streamer = detail.source | |||
| function sendScreen(target, stream) { | |||
| signal({kind: 'screen-start', value: {target, stream}}) | |||
| } | |||
| addEventListener('screen-share-start', async ({detail: {source}}) => { | |||
| const isNew = ScreenShare.source === null | |||
| ScreenShare.source = source | |||
| if(isNew && ScreenShare.isSelf) { | |||
| State.others.forEach(user => sendScreen(user, ScreenShare.stream)) | |||
| } | |||
| }) | |||
| addEventListener('screen-share-stop', () => { | |||
| ScreenShare.stopStream() | |||
| ScreenShare.source = null | |||
| }) | |||
| addEventListener('screen-sharing-stop', ({detail}) => { | |||
| if(ScreenShare.stream) { | |||
| ScreenShare.stream.getTracks().forEach(track => track.stop()) | |||
| ScreenShare.stream = null | |||
| addEventListener('join', ({detail: {value: user}}) => { | |||
| if(ScreenShare.isSelf) { | |||
| wire({kind: 'screen-share-start', target: user}) | |||
| sendScreen(user, ScreenShare.stream) | |||
| } | |||
| ScreenShare.streamer = null | |||
| }) | |||
| addEventListener('load', () => { | |||
| doNotLog.add('screen-share-start') | |||
| @@ -59,4 +73,3 @@ addEventListener('load', () => { | |||
| Headers.push([ScreenShareConfig]) | |||
| Apps.push([ScreenShare, {key: 'screen-share-container'}]) | |||
| }) | |||
| setTimeout(ScreenShare.requestScreen, 200) | |||
| @@ -30,18 +30,15 @@ const VideoConfig = { | |||
| } | |||
| } | |||
| const requestStream = async () => { | |||
| if(StreamContainer.videoOn || StreamContainer.audioOn) { | |||
| await navigator.mediaDevices | |||
| .getUserMedia(VideoConfig) | |||
| .then(stream => | |||
| signal({kind: 'stream', value: {source: State.username, stream}}) | |||
| ) | |||
| .catch(e => console.log(e)) | |||
| } | |||
| else { | |||
| 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}) => { | |||
| @@ -49,6 +46,13 @@ const Video = { | |||
| 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 = { | |||
| @@ -79,8 +83,8 @@ const Video = { | |||
| ), | |||
| m('video[playsinline][autoplay]', { | |||
| style: styleVideo, | |||
| srcObject: streams[username], | |||
| oncreate: this.setUp(username), | |||
| onremove: this.tearDown(username), | |||
| }), | |||
| ) | |||
| }, | |||
| @@ -6,11 +6,12 @@ | |||
| <link rel="stylesheet" href="/apps/chat.css"/> | |||
| <script src="/libs/mithril.min.js"></script> | |||
| <script src="/libs/marked.min.js"></script> | |||
| <!-- <script src="/libs/purify.min.js"></script> --> | |||
| <script src="/libs/purify.min.js"></script> | |||
| <script src="/pico.js" defer></script> | |||
| <script src="/apps/rpc.js"></script> | |||
| <script src="/apps/streams.js"></script> | |||
| <!-- <script src="/apps/screen.js"></script> --> | |||
| <script src="/apps/screen.js"></script> | |||
| <!-- <script src="/apps/rpc.js"></script> --> | |||
| <!-- <script src="/apps/streams.js"></script> --> | |||
| <!-- <script src="/apps/chat.js"></script> --> | |||
| <!-- <script src="/apps/volume.js"></script> --> | |||
| </head> | |||
| @@ -38,6 +39,9 @@ header { | |||
| justify-items: start; | |||
| margin-right: auto; | |||
| } | |||
| video { | |||
| background-color: black; | |||
| } | |||
| main { | |||
| overflow: hidden; | |||
| display: grid; | |||
| @@ -21,6 +21,7 @@ const params = (new URL(document.location)).searchParams | |||
| * | |||
| */ | |||
| addEventListener('resize', () => m.redraw()) | |||
| // message: {kind, value, target?} | |||
| const wire = (message) => State.websocket.send(JSON.stringify(message)) | |||
| const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message})) | |||
| const listen = (kind, handler) => { | |||
| @@ -209,11 +210,7 @@ const Base = { | |||
| } | |||
| const Headers = [ | |||
| ['button', {onclick: Base.sendLogout}, 'log-out'], | |||
| // m('button', {onclick: VolumeMap.toggle}, 'volume'), | |||
| // m('button', {onclick: ScreenShare.toggle}, 'screen'), | |||
| ] | |||
| const Apps = [ | |||
| // m(Shadow, {key: 'map-shadow', app: VolumeMap}), | |||
| // m(Shadow, {key: 'screen-shadow', app: ScreenShare}), | |||
| ] | |||
| addEventListener('load', () => m.mount(document.body, Base)) | |||
| @@ -127,8 +127,7 @@ async def handle(ws, path, server_name): | |||
| else: | |||
| value = data.get('value') | |||
| if 'target' in data: | |||
| recipients = {username, data['target']} | |||
| targets = {v for k, v in room.items() if k in recipients} | |||
| targets = {v for k, v in room.items() if k == data['target']} | |||
| await broadcast(source=username, value=value, targets=targets) | |||
| else: | |||
| await broadcast(source=username, value=value) | |||
| @@ -147,7 +147,6 @@ def test_private_message(): | |||
| - {'kind': 'state', 'online': ['Norman', 'Ray']} | |||
| - {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | |||
| + {'kind': 'post', 'value': '1', 'target': 'Ray'} | |||
| - {'kind': 'post', 'value': '1', 'source': 'Norman'} | |||
| - {'kind': 'post', 'value': '3', 'source': 'Emma'} | |||
| ) | |||
| client2 = _make_client('ws://localhost:8642/x', 0.11, Script() | |||
| @@ -157,7 +156,6 @@ def test_private_message(): | |||
| - {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | |||
| - {'kind': 'post', 'value': '1', 'source': 'Norman'} | |||
| + {'kind': 'post', 'value': '2', 'target': 'Emma'} | |||
| - {'kind': 'post', 'value': '2', 'source': 'Ray'} | |||
| - {'kind': 'state', 'online': ['Ray', 'Emma']} | |||
| ) | |||
| client3 = _make_client('ws://localhost:8642/x', 0.12, Script() | |||
| @@ -166,7 +164,6 @@ def test_private_message(): | |||
| - {'kind': 'state', 'online': ['Norman', 'Ray', 'Emma']} | |||
| - {'kind': 'post', 'value': '2', 'source': 'Ray'} | |||
| + {'kind': 'post', 'value': '3', 'target': 'Norman'} | |||
| - {'kind': 'post', 'value': '3', 'source': 'Emma'} | |||
| - {'kind': 'state', 'online': ['Ray', 'Emma']} | |||
| - {'kind': 'state', 'online': ['Emma']} | |||
| ) | |||