@@ -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']} | |||
) |