marked.setOptions({ | |||||
breaks: true, | |||||
}) | |||||
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 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), | |||||
), | |||||
) | |||||
}, | |||||
} |
.chat { | |||||
display: grid; | |||||
grid-template-areas: | |||||
'posts online' | |||||
'actions actions' | |||||
'tabbed-area tabbed-area' | |||||
; | |||||
grid-template-columns: 1fr auto; | |||||
grid-template-rows: 140px auto 1fr; | |||||
position: fixed; | |||||
top: 0; | |||||
--pad: 3px; | |||||
padding: var(--pad); | |||||
width: calc(100vw - 2 * var(--pad)); | |||||
height: 100%; | |||||
} | |||||
.online { | |||||
grid-area: online; | |||||
} | |||||
.posts { | |||||
grid-area: posts; | |||||
overflow-y: scroll; | |||||
} | |||||
.post > div { | |||||
display: inline; | |||||
padding-left: var(--pad); | |||||
} | |||||
.post .ts { | |||||
color: rgba(0, 0, 0, 0.4); | |||||
font-family: monospace; | |||||
} | |||||
.post .source { | |||||
font-weight: bold; | |||||
} | |||||
.post .text p { | |||||
margin: 0; | |||||
} | |||||
.post .text p:first-child { | |||||
display: inline; | |||||
} | |||||
.actions { | |||||
grid-area: actions; | |||||
display: grid; | |||||
grid-template-columns: 1fr auto; | |||||
} | |||||
#textbox { | |||||
resize: none; | |||||
} | |||||
.media { | |||||
display: grid; | |||||
grid-template-rows: auto 1fr; | |||||
} | |||||
.video-meta { | |||||
position: absolute; | |||||
z-index: 999; | |||||
} | |||||
.video-option { | |||||
display: block; | |||||
} | |||||
.videos { | |||||
display: grid; | |||||
grid-auto-flow: column; | |||||
justify-content: start; | |||||
overflow-x: scroll; | |||||
height: 50px; | |||||
resize: vertical; | |||||
position: relative; | |||||
} | |||||
.videos.full-screen { | |||||
height: 100% !important; | |||||
resize: none; | |||||
} | |||||
.video-container { | |||||
display: inline-block; | |||||
position: relative; | |||||
height: 100%; | |||||
overflow: scroll; | |||||
} | |||||
.video-container video { | |||||
background-color: black; | |||||
height: 100%; | |||||
width: 100%; | |||||
} | |||||
/* mirror */ | |||||
.video-container.mirror video { | |||||
transform: scaleX(-1); | |||||
} | |||||
/* square */ | |||||
.video-container.square { | |||||
width: var(--height); | |||||
overflow: hidden; | |||||
} | |||||
.video-container.square video { | |||||
object-fit: cover; | |||||
} | |||||
/* full-screen */ | |||||
.video-container.full-screen { | |||||
position: absolute; | |||||
top: 0; | |||||
left: 0; | |||||
right: 0; | |||||
width: unset; | |||||
z-index: 999; | |||||
overflow: scroll; | |||||
background-color: black; | |||||
} | |||||
.video-container.full-screen video { | |||||
object-fit: contain; | |||||
width: unset; | |||||
height: unset; | |||||
} | |||||
.tabbed-area { | |||||
grid-area: tabbed-area; | |||||
grid-template-columns: 1fr 1fr; | |||||
} | |||||
@media only screen and (min-width: 800px) { | |||||
.chat { | |||||
grid-template-areas: | |||||
'online tabbed-area' | |||||
'posts tabbed-area' | |||||
'actions tabbed-area' | |||||
; | |||||
grid-template-columns: 320px 1fr; | |||||
grid-template-rows: auto 1fr auto; | |||||
height: calc(100vh - 2 * var(--pad)); | |||||
} | |||||
} |
toggle: (property) => () => { | toggle: (property) => () => { | ||||
VideoConfig[property] = !VideoConfig[property] | VideoConfig[property] = !VideoConfig[property] | ||||
updateSelfVideo() | updateSelfVideo() | ||||
}, | |||||
view() { | |||||
return [ | |||||
m('button', {onclick: VideoConfig.toggle('videoOn')}, 'video'), | |||||
m('button', {onclick: VideoConfig.toggle('audioOn')}, 'audio'), | |||||
] | |||||
} | } | ||||
}) | }) | ||||
const updateSelfVideo = async () => { | const updateSelfVideo = async () => { | ||||
const dims = [StreamContainer.getRows(), StreamContainer.getColumns()] | const dims = [StreamContainer.getRows(), StreamContainer.getColumns()] | ||||
if(screen.height > screen.width) dims.reverse() | if(screen.height > screen.width) dims.reverse() | ||||
const style = { | const style = { | ||||
overflow: 'hidden', | |||||
display: 'grid', | display: 'grid', | ||||
padding: '3px', | padding: '3px', | ||||
gridGap: '3px', | gridGap: '3px', | ||||
height: '90vh', | |||||
gridTemplateRows: dims[0], | gridTemplateRows: dims[0], | ||||
gridTemplateColumns: dims[1], | gridTemplateColumns: dims[1], | ||||
} | } | ||||
return [ | |||||
m('span.video-controls', | |||||
m('button', {onclick: VideoConfig.toggle('videoOn')}, 'video'), | |||||
m('button', {onclick: VideoConfig.toggle('audioOn')}, 'audio'), | |||||
), | |||||
m('.videos', {style}, | |||||
m(Video, {username: State.username}), | |||||
State.online.filter(username => username != State.username) | |||||
.map(username => m(Video, {username})) | |||||
), | |||||
] | |||||
return m('.videos', {style}, | |||||
m(Video, {username: State.username}), | |||||
State.online.filter(username => username != State.username) | |||||
.map(username => m(Video, {username})) | |||||
) | |||||
}, | }, | ||||
} | } | ||||
<script src="/libs/marked.min.js"></script> | |||||
<script src="/libs/purify.min.js"></script> | |||||
<script src="/libs/mobile-drag-drop.min.js"></script> | |||||
<link rel="stylesheet" href="/apps/hanabi.css" /> | |||||
<script src="/apps/hanabi.js" defer></script> |
const getOrCreateRpc = (username) => { | |||||
if(State.username === username) { | |||||
return | |||||
} | |||||
if(!State.rpcs[username]) { | |||||
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 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) | |||||
} | |||||
} | |||||
autocomplete: 'off', | autocomplete: 'off', | ||||
value: localStorage.username, | value: localStorage.username, | ||||
} | } | ||||
const style = { | |||||
display: 'inline', | |||||
const mainStyle = { | |||||
display: 'grid', | |||||
gridTemplateRows: 'auto 1fr', | |||||
height: '100vh', | |||||
overflow: 'hidden', | |||||
} | } | ||||
return m('main', | |||||
m('span.login-container', | |||||
const headerStyle = { | |||||
display: 'grid', | |||||
gridAutoFlow: 'column', | |||||
justifyItems: 'start', | |||||
marginRight: 'auto', | |||||
} | |||||
return m('main', {style: mainStyle}, | |||||
m('header', {style: headerStyle}, | |||||
State.isConnected ? null : m('form.login', | State.isConnected ? null : m('form.login', | ||||
{style, onsubmit: Base.sendLogin}, | |||||
{onsubmit: Base.sendLogin}, | |||||
m('input', attrs), | m('input', attrs), | ||||
m('button', 'Login'), | m('button', 'Login'), | ||||
), | ), | ||||
State.isConnected ? m('form.logout', | State.isConnected ? m('form.logout', | ||||
{style, onsubmit: Base.sendLogout}, | |||||
{onsubmit: Base.sendLogout}, | |||||
m('button', 'Logout'), | m('button', 'Logout'), | ||||
m('input[readonly]', {value: location}), | m('input[readonly]', {value: location}), | ||||
) : null, | ) : null, | ||||
State.isConnected ? m(VideoConfig) : null, | |||||
m('span.error', State.info), | m('span.error', State.info), | ||||
), | ), | ||||
State.isConnected ? m(StreamContainer) : null, | State.isConnected ? m(StreamContainer) : null, | ||||
} | } | ||||
if(!doNotLog.has(message.kind)) { | if(!doNotLog.has(message.kind)) { | ||||
console.log('@', message) | |||||
console.log(message) | |||||
} | } | ||||
signal(message) | signal(message) | ||||