|
|
@@ -1,17 +1,12 @@ |
|
|
|
const isLandscape = screen.width > screen.height |
|
|
|
const State = { |
|
|
|
const State = Object.seal({ |
|
|
|
username: null, |
|
|
|
websocket: null, |
|
|
|
online: [], |
|
|
|
posts: [], |
|
|
|
rpcs: {}, |
|
|
|
streams: {}, |
|
|
|
options: {}, |
|
|
|
} |
|
|
|
const markedOptions = { |
|
|
|
breaks: true, |
|
|
|
} |
|
|
|
marked.setOptions(markedOptions) |
|
|
|
messages: [], |
|
|
|
get isConnected() { |
|
|
|
return State.websocket && State.websocket.readyState === 1 |
|
|
|
}, |
|
|
|
}) |
|
|
|
|
|
|
|
/* |
|
|
|
* |
|
|
@@ -22,11 +17,25 @@ const wire = (message) => State.websocket.send(JSON.stringify(message)) |
|
|
|
const signal = (message) => dispatchEvent(new CustomEvent(message.kind, {detail: message})) |
|
|
|
const signalPeerRequest = () => wire({kind: 'peerInfo', value: {type: 'request'}}) |
|
|
|
const signalPeerStop = (username) => signal({kind: 'peerInfo', value: {type: 'stop'}, source: username}) |
|
|
|
const listen = (kind, handler) => addEventListener(kind, handler) |
|
|
|
listen('login', ({detail}) => State.username = detail.value) |
|
|
|
listen('state', ({detail}) => Object.assign(State, detail)) |
|
|
|
listen('post', ({detail}) => {State.posts.push(detail); m.redraw()}) |
|
|
|
listen('peerInfo', (e) => onPeerInfo(e)) |
|
|
|
const listen = (kind, handler) => { |
|
|
|
addEventListener(kind, handler) |
|
|
|
} |
|
|
|
listen('login', ({detail}) => { |
|
|
|
State.username = detail.value |
|
|
|
State.messages = [] |
|
|
|
}) |
|
|
|
listen('logout', ({detail}) => { |
|
|
|
State.online = [] |
|
|
|
}) |
|
|
|
listen('state', ({detail}) => { |
|
|
|
delete detail.ts |
|
|
|
delete detail.kind |
|
|
|
Object.assign(State, detail) |
|
|
|
}) |
|
|
|
listen('post', ({detail}) => { |
|
|
|
State.messages.push(detail) |
|
|
|
m.redraw() |
|
|
|
}) |
|
|
|
const doNotLog = new Set(['login', 'state', 'post', 'peerInfo', 'join', 'leave']) |
|
|
|
|
|
|
|
/* |
|
|
@@ -40,312 +49,20 @@ listen('focus', () => {State.unseen = 0; updateTitle()}) |
|
|
|
const updateTitle = () => { |
|
|
|
document.title = location.href.split('//')[1] + (State.unseen ? ` (${State.unseen})` : ``) |
|
|
|
} |
|
|
|
|
|
|
|
/* |
|
|
|
* |
|
|
|
* WEBRTC |
|
|
|
* UTILS |
|
|
|
* |
|
|
|
*/ |
|
|
|
const getOrCreateRpc = (username) => { |
|
|
|
if(State.username === username) { |
|
|
|
return |
|
|
|
} |
|
|
|
if(!State.rpcs[username]) { |
|
|
|
const rpc = new RTCPeerConnection({iceServers: [{urls: 'stun:stun.sipgate.net:3478'}]}) |
|
|
|
rpc.onicecandidate = ({candidate}) => { |
|
|
|
if(candidate) { |
|
|
|
wire({kind: 'peerInfo', value: {type: 'candidate', candidate}}) |
|
|
|
} |
|
|
|
} |
|
|
|
rpc.ontrack = (e) => { |
|
|
|
State.streams[username] = e.streams[0] |
|
|
|
m.redraw() |
|
|
|
} |
|
|
|
rpc.onclose = (e) => { |
|
|
|
console.log(username, e) |
|
|
|
} |
|
|
|
rpc.oniceconnectionstatechange = (e) => { |
|
|
|
m.redraw() |
|
|
|
} |
|
|
|
State.rpcs[username] = rpc |
|
|
|
} |
|
|
|
return State.rpcs[username] |
|
|
|
} |
|
|
|
const setSelectedMedia = async () => { |
|
|
|
const localStream = State.streams[State.username] |
|
|
|
if(!localStream) { |
|
|
|
return |
|
|
|
} |
|
|
|
|
|
|
|
const oldTracks = localStream.getTracks() |
|
|
|
const addTrack = localStream.addTrack.bind(localStream) |
|
|
|
|
|
|
|
const muted = document.querySelector('#mute-check').checked |
|
|
|
if(!muted) { |
|
|
|
const audio = Media.audioDefaults |
|
|
|
await navigator.mediaDevices.getUserMedia({audio}) |
|
|
|
.then(s => s.getAudioTracks().forEach(addTrack)) |
|
|
|
.catch(e => console.error(e)) |
|
|
|
} |
|
|
|
|
|
|
|
const source = document.querySelector('#media-source').value |
|
|
|
if(source === 'camera') { |
|
|
|
const video = {width: {ideal: 320}, facingMode: 'user', frameRate: 26} |
|
|
|
await navigator.mediaDevices.getUserMedia({video}) |
|
|
|
.then(s => s.getVideoTracks().forEach(addTrack)) |
|
|
|
.catch(e => console.error(e)) |
|
|
|
} |
|
|
|
if(source === 'screen' && navigator.mediaDevices.getDisplayMedia) { |
|
|
|
await navigator.mediaDevices.getDisplayMedia() |
|
|
|
.then(s => s.getVideoTracks().forEach(addTrack)) |
|
|
|
.catch(e => console.error(e)) |
|
|
|
} |
|
|
|
|
|
|
|
oldTracks.forEach(track => {track.stop(); localStream.removeTrack(track)}) |
|
|
|
document.querySelectorAll('video').forEach(video => video.srcObject = video.srcObject) |
|
|
|
signalPeerRequest() |
|
|
|
} |
|
|
|
const onPeerInfo = async ({detail: message}) => { |
|
|
|
const localStream = State.streams[State.username] |
|
|
|
const rpc = localStream && getOrCreateRpc(message.source) |
|
|
|
const resetStreams = () => { |
|
|
|
rpc.getSenders().forEach(sender => rpc.removeTrack(sender)) |
|
|
|
localStream.getTracks().forEach(track => rpc.addTrack(track, localStream)) |
|
|
|
} |
|
|
|
if(rpc && message.value.type === 'request') { |
|
|
|
resetStreams() |
|
|
|
const localOffer = await rpc.createOffer() |
|
|
|
await rpc.setLocalDescription(localOffer) |
|
|
|
wire({kind: 'peerInfo', value: localOffer, target: message.source}) |
|
|
|
} |
|
|
|
else if(rpc && message.value.type === 'offer') { |
|
|
|
resetStreams() |
|
|
|
const remoteOffer = new RTCSessionDescription(message.value) |
|
|
|
await rpc.setRemoteDescription(remoteOffer) |
|
|
|
const localAnswer = await rpc.createAnswer() |
|
|
|
await rpc.setLocalDescription(localAnswer) |
|
|
|
wire({kind: 'peerInfo', value: localAnswer, target: message.source}) |
|
|
|
} |
|
|
|
else if(rpc && message.value.type === 'answer') { |
|
|
|
const remoteAnswer = new RTCSessionDescription(message.value) |
|
|
|
await rpc.setRemoteDescription(remoteAnswer) |
|
|
|
} |
|
|
|
else if(rpc && message.value.type === 'candidate') { |
|
|
|
const candidate = new RTCIceCandidate(message.value.candidate) |
|
|
|
rpc.addIceCandidate(candidate) |
|
|
|
} |
|
|
|
else if(message.value.type === 'stop') { |
|
|
|
if(State.streams[message.source]) { |
|
|
|
State.streams[message.source].getTracks().map(track => track.stop()) |
|
|
|
delete State.streams[message.source] |
|
|
|
} |
|
|
|
if(State.rpcs[message.source]) { |
|
|
|
State.rpcs[message.source].close() |
|
|
|
delete State.rpcs[message.source] |
|
|
|
} |
|
|
|
} |
|
|
|
else if(rpc) { |
|
|
|
console.log('uncaught', message) |
|
|
|
} |
|
|
|
const autoFocus = (vnode) => { |
|
|
|
vnode.dom.focus() |
|
|
|
} |
|
|
|
|
|
|
|
/* |
|
|
|
* |
|
|
|
* GUI |
|
|
|
* BASE |
|
|
|
* |
|
|
|
*/ |
|
|
|
const autoFocus = (vnode) => { |
|
|
|
vnode.dom.focus() |
|
|
|
} |
|
|
|
const scrollIntoView = (vnode) => { |
|
|
|
vnode.dom.scrollIntoView() |
|
|
|
} |
|
|
|
const TextBox = { |
|
|
|
autoSize: () => { |
|
|
|
textbox.style.height = `0px` |
|
|
|
textbox.style.height = `${textbox.scrollHeight}px` |
|
|
|
}, |
|
|
|
sendPost: () => { |
|
|
|
if(textbox.value) { |
|
|
|
wire({kind: 'post', value: textbox.value}) |
|
|
|
textbox.value = '' |
|
|
|
textbox.focus() |
|
|
|
TextBox.autoSize() |
|
|
|
} |
|
|
|
}, |
|
|
|
blockIndent: (text, iStart, iEnd, nLevels) => { |
|
|
|
const startLine = text.slice(0, iStart).split('\n').length - 1 |
|
|
|
const endLine = text.slice(0, iEnd).split('\n').length - 1 |
|
|
|
const newText = text |
|
|
|
.split('\n') |
|
|
|
.map((line, i) => { |
|
|
|
if(i < startLine || i > endLine || nLevels === 0) { |
|
|
|
newLine = line |
|
|
|
} |
|
|
|
else if(nLevels > 0) { |
|
|
|
newLine = line.replace(/^/, ' ') |
|
|
|
} |
|
|
|
else if(nLevels < 0) { |
|
|
|
newLine = line.replace(/^ /, '') |
|
|
|
} |
|
|
|
|
|
|
|
if(i === startLine) { |
|
|
|
iStart = iStart + newLine.length - line.length |
|
|
|
} |
|
|
|
iEnd = iEnd + newLine.length - line.length |
|
|
|
return newLine |
|
|
|
}) |
|
|
|
.join('\n') |
|
|
|
return [newText, Math.max(0, iStart), Math.max(0, iEnd)] |
|
|
|
}, |
|
|
|
hotKey: (e) => { |
|
|
|
// if isDesktop, Enter posts, unless Shift+Enter |
|
|
|
// use isLandscape as proxy for isDesktop |
|
|
|
if(e.key === 'Enter' && isLandscape && !e.shiftKey) { |
|
|
|
e.preventDefault() |
|
|
|
TextBox.sendPost() |
|
|
|
} |
|
|
|
// indent and dedent |
|
|
|
const modKey = e.ctrlKey || e.metaKey |
|
|
|
const {value: text, selectionStart: A, selectionEnd: B} = textbox |
|
|
|
if(e.key === 'Tab') { |
|
|
|
e.preventDefault() |
|
|
|
const regex = new RegExp(`([\\s\\S]{${A}})([\\s\\S]{${B - A}})`) |
|
|
|
textbox.value = text.replace(regex, (m, a, b) => a + ' '.repeat(4)) |
|
|
|
textbox.setSelectionRange(A + 4, A + 4) |
|
|
|
} |
|
|
|
if(']['.includes(e.key) && modKey) { |
|
|
|
e.preventDefault() |
|
|
|
const nLevels = {']': 1, '[': -1}[e.key] |
|
|
|
const [newText, newA, newB] = TextBox.blockIndent(text, A, B, nLevels) |
|
|
|
textbox.value = newText |
|
|
|
textbox.setSelectionRange(newA, newB) |
|
|
|
} |
|
|
|
}, |
|
|
|
view() { |
|
|
|
return m('.actions', |
|
|
|
m('textarea#textbox', { |
|
|
|
oncreate: (vnode) => { |
|
|
|
TextBox.autoSize() |
|
|
|
autoFocus(vnode) |
|
|
|
}, |
|
|
|
onkeydown: TextBox.hotKey, |
|
|
|
oninput: TextBox.autoSize, |
|
|
|
}), |
|
|
|
m('button', { |
|
|
|
onclick: ({target}) => { |
|
|
|
TextBox.sendPost() |
|
|
|
TextBox.autoSize() |
|
|
|
}, |
|
|
|
}, |
|
|
|
'Send'), |
|
|
|
) |
|
|
|
}, |
|
|
|
} |
|
|
|
const VideoOptions = { |
|
|
|
available: ['mirror', 'square', 'full-screen'], |
|
|
|
anyFullScreen: () => { |
|
|
|
for(const username of State.online) { |
|
|
|
if(State.options[username].has('full-screen')) { |
|
|
|
return 'full-screen' |
|
|
|
} |
|
|
|
} |
|
|
|
return '' |
|
|
|
}, |
|
|
|
getFor: (username) => { |
|
|
|
if(!State.options[username]) { |
|
|
|
State.options[username] = new Set(['mirror', 'square']) |
|
|
|
} |
|
|
|
return State.options[username] |
|
|
|
}, |
|
|
|
getClassListFor: (username) => { |
|
|
|
return [...VideoOptions.getFor(username)].join(' ') |
|
|
|
}, |
|
|
|
toggle: (options, string) => () => options.has(string) |
|
|
|
? options.delete(string) |
|
|
|
: options.add(string), |
|
|
|
view({attrs: {username}}) { |
|
|
|
const options = VideoOptions.getFor(username) |
|
|
|
return VideoOptions.available.map((string) => |
|
|
|
m('label.video-option', |
|
|
|
m('input', { |
|
|
|
type: 'checkbox', |
|
|
|
checked: options.has(string), |
|
|
|
onchange: VideoOptions.toggle(options, string), |
|
|
|
}), |
|
|
|
string, |
|
|
|
) |
|
|
|
) |
|
|
|
} |
|
|
|
} |
|
|
|
const Video = { |
|
|
|
keepRatio: {observe: () => {}}, |
|
|
|
appendStream: ({username}) => ({dom}) => { |
|
|
|
dom.autoplay = true |
|
|
|
dom.muted = (username === State.username) |
|
|
|
dom.srcObject = State.streams[username] |
|
|
|
}, |
|
|
|
view({attrs}) { |
|
|
|
const classList = VideoOptions.getClassListFor(attrs.username) |
|
|
|
const rpc = State.rpcs[attrs.username] || {iceConnectionState: null} |
|
|
|
const options = VideoOptions.getFor(attrs.username) |
|
|
|
return m('.video-container', {class: classList, oncreate: ({dom}) => Video.keepRatio.observe(dom)}, |
|
|
|
m('.video-meta', |
|
|
|
m('span.video-source', attrs.username), |
|
|
|
m('.video-state', rpc.iceConnectionState), |
|
|
|
), |
|
|
|
m('video', { |
|
|
|
playsinline: true, |
|
|
|
oncreate: Video.appendStream(attrs), |
|
|
|
ondblclick: VideoOptions.toggle(options, 'full-screen'), |
|
|
|
}), |
|
|
|
) |
|
|
|
}, |
|
|
|
} |
|
|
|
if(window.ResizeObserver) { |
|
|
|
const doOne = ({target}) => target.style.setProperty('--height', `${target.clientHeight}px`) |
|
|
|
const doAll = (entries) => entries.forEach(doOne) |
|
|
|
Video.keepRatio = new ResizeObserver(doAll) |
|
|
|
} |
|
|
|
const Media = { |
|
|
|
videoSources: ['camera', 'screen', 'none'], |
|
|
|
audioDefaults: { |
|
|
|
noiseSuppresion: true, |
|
|
|
echoCancellation: true, |
|
|
|
}, |
|
|
|
turnOn: async () => { |
|
|
|
State.streams[State.username] = new MediaStream() |
|
|
|
await setSelectedMedia() |
|
|
|
m.redraw() |
|
|
|
}, |
|
|
|
turnOff: () => { |
|
|
|
wire({kind: 'peerInfo', value: {type: 'stop'}}) |
|
|
|
State.online.forEach(signalPeerStop) |
|
|
|
}, |
|
|
|
view() { |
|
|
|
return m('.media', |
|
|
|
m('.media-settings', |
|
|
|
State.streams[State.username] |
|
|
|
? m('button', {onclick: Media.turnOff}, 'turn off') |
|
|
|
: m('button', {onclick: Media.turnOn}, 'turn on') |
|
|
|
, |
|
|
|
m('select#media-source', {onchange: setSelectedMedia}, |
|
|
|
Media.videoSources.map(option => m('option', option)) |
|
|
|
), |
|
|
|
m('label', |
|
|
|
m('input#mute-check', {onchange: setSelectedMedia, type: 'checkbox'}), |
|
|
|
m('span', 'mute'), |
|
|
|
), |
|
|
|
), |
|
|
|
m('.videos', {className: VideoOptions.anyFullScreen()}, |
|
|
|
Object.keys(State.streams).map((username) => |
|
|
|
m(Video, {key: username, username}) |
|
|
|
), |
|
|
|
), |
|
|
|
) |
|
|
|
}, |
|
|
|
} |
|
|
|
const Login = { |
|
|
|
const Base = { |
|
|
|
sendLogin: (e) => { |
|
|
|
e.preventDefault() |
|
|
|
const username = e.target.username.value |
|
|
@@ -353,9 +70,9 @@ const Login = { |
|
|
|
connect(username) |
|
|
|
}, |
|
|
|
sendLogout: (e) => { |
|
|
|
Media.turnOff() |
|
|
|
e.preventDefault() |
|
|
|
wire({kind: 'logout'}) |
|
|
|
State.posts = [] |
|
|
|
signal({kind: 'logout'}) |
|
|
|
}, |
|
|
|
view() { |
|
|
|
const attrs = { |
|
|
@@ -364,73 +81,28 @@ const Login = { |
|
|
|
autocomplete: 'off', |
|
|
|
value: localStorage.username, |
|
|
|
} |
|
|
|
return m('.login', |
|
|
|
m('form', {onsubmit: Login.sendLogin}, |
|
|
|
m('input', attrs), |
|
|
|
m('button', 'Login'), |
|
|
|
), |
|
|
|
m('.error', State.info), |
|
|
|
) |
|
|
|
}, |
|
|
|
} |
|
|
|
const Chat = { |
|
|
|
log: (message) => { |
|
|
|
signal({kind: 'post', ts: +new Date(), value: '' + message}) |
|
|
|
}, |
|
|
|
prettifyTime: (ts) => { |
|
|
|
const dt = new Date(ts) |
|
|
|
const H = `0${dt.getHours()}`.slice(-2) |
|
|
|
const M = `0${dt.getMinutes()}`.slice(-2) |
|
|
|
const S = `0${dt.getSeconds()}`.slice(-2) |
|
|
|
return `${H}:${M}:${S}` |
|
|
|
}, |
|
|
|
outboundLinks: (vnode) => { |
|
|
|
vnode.dom.querySelectorAll('a').forEach(anchor => { |
|
|
|
anchor.target = '_blank' |
|
|
|
anchor.rel = 'noopener' |
|
|
|
}) |
|
|
|
}, |
|
|
|
view() { |
|
|
|
return m('.chat', |
|
|
|
m('.posts', |
|
|
|
State.posts.map(post => m('.post', {oncreate: scrollIntoView}, |
|
|
|
m('.ts', Chat.prettifyTime(post.ts)), |
|
|
|
m('.source', post.source || '~'), |
|
|
|
m('.text', {oncreate: Chat.outboundLinks}, |
|
|
|
m.trust(DOMPurify.sanitize(marked(post.value))) |
|
|
|
), |
|
|
|
)), |
|
|
|
), |
|
|
|
m(TextBox), |
|
|
|
m('.online', |
|
|
|
m('button', {onclick: Login.sendLogout}, 'Logout'), |
|
|
|
m('.user-list', State.online.map(username => |
|
|
|
m('details', |
|
|
|
m('summary', |
|
|
|
m('span', username), |
|
|
|
), |
|
|
|
m(VideoOptions, {username}), |
|
|
|
), |
|
|
|
)), |
|
|
|
), |
|
|
|
m('.tabbed-area', |
|
|
|
m(Media), |
|
|
|
m(Hanabi), |
|
|
|
return m('main', |
|
|
|
m('.login-container', |
|
|
|
m('form.login' + (State.isConnected ? '.hidden' : ''), |
|
|
|
{onsubmit: Base.sendLogin}, |
|
|
|
m('input', attrs), |
|
|
|
m('button', 'Login'), |
|
|
|
), |
|
|
|
m('form.logout' + (State.isConnected ? '' : '.hidden'), |
|
|
|
{onsubmit: Base.sendLogout}, |
|
|
|
m('button', 'Logout'), |
|
|
|
), |
|
|
|
m('.error', State.info), |
|
|
|
), |
|
|
|
m(StreamContainer), |
|
|
|
) |
|
|
|
}, |
|
|
|
} |
|
|
|
const Main = { |
|
|
|
view() { |
|
|
|
const connected = State.websocket && State.websocket.readyState === 1 |
|
|
|
return connected ? m(Chat) : m(Login) |
|
|
|
}, |
|
|
|
} |
|
|
|
m.mount(document.body, Main) |
|
|
|
m.mount(document.body, Base) |
|
|
|
|
|
|
|
/* |
|
|
|
* |
|
|
|
* WEBSOCKETS |
|
|
|
* WEBSOCKET |
|
|
|
* |
|
|
|
*/ |
|
|
|
const connect = (username) => { |
|
|
@@ -476,4 +148,3 @@ const connect = (username) => { |
|
|
|
if(localStorage.username) { |
|
|
|
connect(localStorage.username) |
|
|
|
} |
|
|
|
addEventListener('pagehide', Media.turnOff) |