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