@@ -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; |