const Toggle = { view({attrs: {label, value}}) { const onclick = () => { StreamContainer[value] = !StreamContainer[value] updateSelfVideo() } const style = { 'font-family': 'monospace', 'display': 'inline-flex', 'justify-content': 'center', 'align-items': 'center', 'padding': '0 0.5em', } const checked = StreamContainer[value] return m('label', {style}, 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'}), ] } } const updateSelfVideo = async () => { const video = document.querySelector('video.self') video.srcObject.getTracks().forEach(track => { track.stop() video.srcObject.removeTrack(track) delete track }) if(StreamContainer.videoOn || StreamContainer.audioOn) { await navigator.mediaDevices .getUserMedia(VideoConfig) .then(s => s.getTracks().forEach(t => video.srcObject.addTrack(t))) .catch(e => console.error(e)) } video.srcObject = video.srcObject // safari wire({kind: 'peerInfo', value: {type: 'request'}}) } const updateOtherVideo = (target, dom) => { dom.srcObject = new MediaStream() let rpc = null const rpcConfig = {iceServers: [{ urls: ['stun:stun.pico.chat:5349', 'turn:turn.pico.chat:5349'], username: 'roderic', credential: 'tomodachi', }]} const stopRpc = () => { rpc && rpc.close() dom.srcObject.getTracks().forEach(track => { track.stop() dom.srcObject.removeTrack(track) delete track }) } const resetRpc = () => { stopRpc() rpc = new RTCPeerConnection(rpcConfig) document.querySelector('video.self').srcObject.getTracks() .forEach(t => rpc.addTrack(t)) rpc.onicecandidate = ({candidate}) => { if(candidate && candidate.candidate) { const value = {type: 'candidate', candidate} wire({kind: 'peerInfo', value, target}) } } rpc.ontrack = ({track}) => { dom.srcObject.addTrack(track) dom.srcObject = dom.srcObject } rpc.onconnectionstatechange = () => { if(rpc.connectionState === 'failed') { console.log(target, 'failed, retry!') wire({kind: 'peerInfo', value: {type: 'request'}, target}) } } } dom.listener = async ({detail: {source, value}}) => { if(source !== target) { return } console.log(source, value.type) if(value.type === 'request') { resetRpc() const localOffer = await rpc.createOffer() await rpc.setLocalDescription(localOffer) wire({kind: 'peerInfo', value: localOffer, target}) } else if(value.type === 'offer') { resetRpc() const remoteOffer = new RTCSessionDescription(value) await rpc.setRemoteDescription(remoteOffer) const localAnswer = await rpc.createAnswer() await rpc.setLocalDescription(localAnswer) wire({kind: 'peerInfo', value: localAnswer, target}) } else if(value.type === 'answer') { const remoteAnswer = new RTCSessionDescription(value) await rpc.setRemoteDescription(remoteAnswer) } else if(value.type === 'candidate') { const candidate = new RTCIceCandidate(value.candidate) await rpc.addIceCandidate(candidate) } else if(value.type === 'stop') { stopRpc() } } addEventListener('peerInfo', dom.listener) } const Video = { setUp: (username) => ({dom}) => { dom.username = username dom.srcObject = new MediaStream() if(username === State.username) { dom.classList.add('self') dom.muted = true updateSelfVideo() } else { updateOtherVideo(username, dom) } }, tearDown: (username) => ({dom}) => { removeEventListener('peerInfo', dom.listener) dom.srcObject.getTracks().forEach(track => { track.stop() dom.srcObject.removeTrack(track) delete track }) }, view({attrs: {username}}) { const styleOuter = { position: 'relative', display: 'block', color: 'white', overflow: 'hidden', } const styleMeta = { position: 'absolute', display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%', width: '100%', fontFamily: 'monospace', fontSize: 'xxx-large', } const styleVideo = { objectFit: Settings.get('blackBars') ? 'contain' : 'cover', width: '100%', height: '100%', transform: 'scaleX(-1)', } return m('.video-container', {style: styleOuter}, m('.video-info', {style: styleMeta}, m('.username', username), ), m('video[playsinline][autoplay]', { style: styleVideo, oncreate: this.setUp(username), onremove: this.tearDown(username), }), ) }, } const StreamContainer = { videoOn: true, audioOn: true, view() { const dims = [ Math.floor((1 + Math.sqrt(4 * State.online.length - 3)) / 2), Math.ceil(Math.sqrt(State.online.length)), ].map(n => Array(n).fill('1fr').join(' ')) if(innerHeight > innerWidth) dims.reverse() const style = { backgroundColor: 'black', overflow: 'hidden', display: 'grid', gridTemplateRows: dims[0], gridTemplateColumns: dims[1], } return m('.videos', {style}, State.online.map(username => m(Video, {key: username, username})) ) }, } const signalPeerStop = (username) => signal({kind: 'peerInfo', value: {type: 'stop'}, source: username}) addEventListener('pagehide', () => State.online.forEach(signalPeerStop)) addEventListener('logout', () => State.online.forEach(signalPeerStop))