Roderic Day преди 5 години
родител
ревизия
14bf157e8d
променени са 4 файла, в които са добавени 183 реда и са изтрити 524 реда
  1. +121
    -0
      apps/streams.js
  2. +0
    -133
      pico.css
  3. +15
    -15
      pico.html
  4. +47
    -376
      pico.js

+ 121
- 0
apps/streams.js Целия файл

@@ -0,0 +1,121 @@
const VideoConfig = Object.seal({
videoOn: true,
audioOn: true,
get video() {
return VideoConfig.videoOn && {width: {ideal: 320}, facingMode: 'user', frameRate: 26}
},
get audio() {
return VideoConfig.audioOn
},
toggle: (property) => () => {
VideoConfig[property] = !VideoConfig[property]
VideoSelf.update()
}
})
const VideoSelf = {
update() {
const stream = document.querySelector('video.self').srcObject
stream.getTracks().forEach(track => {
track.stop()
stream.removeTrack(track)
delete track
})
if(VideoConfig.videoOn || VideoConfig.audioOn) {
navigator.mediaDevices
.getUserMedia(VideoConfig)
.then(s => s.getTracks().forEach(t => stream.addTrack(t)))
.catch(e => console.error(e))
}
},
setUp: ({dom}) => {
dom.playsinline = true
dom.autoplay = true
dom.muted = true
dom.srcObject = new MediaStream()
VideoSelf.update()
},
view({attrs: {username}}) {
const styleOuter = {
position: 'relative',
display: 'block',
backgroundColor: 'black',
color: 'white',
overflow: 'hidden',
}
const styleInner = {
objectFit: 'cover',
width: '100%',
height: '100%',
transform: 'scaleX(-1)',
}
return m('.video-container', {style: styleOuter},
m('.video-info', {style: {position: 'absolute', zIndex: 999}},
m('span', {style: {padding: '5px'}}, username),
),
m('video.self', {style: styleInner, oncreate: this.setUp}),
)
},
}
const VideoOther = {
setUp: ({dom}) => {
dom.playsinline = true
dom.autoplay = true
dom.srcObject = new MediaStream()
},
view({attrs: {username}}) {
const styleOuter = {
position: 'relative',
display: 'block',
backgroundColor: 'black',
color: 'white',
overflow: 'hidden',
}
const styleInner = {
objectFit: 'cover',
width: '100%',
height: '100%',
transform: 'scaleX(-1)',
}
return m('.video-container', {style: styleOuter},
m('.video-info', {style: {position: 'absolute', zIndex: 999}},
m('span', {style: {padding: '5px'}}, username),
),
m('video.self', {style: styleInner, oncreate: this.setUp}),
)
},
}
const StreamContainer = {
getColumns() {
const n = State.online.length
if(n > 4) return '1fr 1fr 1fr'
if(n > 1) return '1fr 1fr'
return '1fr'
},
getRows() {
const n = State.online.length
if(n > 6) return '1fr 1fr 1fr'
if(n > 2) return '1fr 1fr'
return '1fr'
},
view() {
const style = {
display: 'grid',
padding: '3px',
gridGap: '3px',
height: '80vh',
gridTemplateColumns: StreamContainer.getColumns(),
gridTemplateRows: StreamContainer.getRows(),
}
return m('div',
m('.video-controls',
m('button', {onclick: VideoConfig.toggle('videoOn')}, 'video'),
m('button', {onclick: VideoConfig.toggle('audioOn')}, 'audio'),
),
m('.videos', {style},
m(VideoSelf, {username: State.username}),
State.online.filter(username => username != State.username)
.map(username => m(VideoOther, {username}))
),
)
},
}

+ 0
- 133
pico.css Целия файл

@@ -1,133 +0,0 @@
body {
margin: 0;
padding: 0;
}
.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));
}
}

+ 15
- 15
pico.html Целия файл

@@ -1,18 +1,18 @@
<!DOCTYPE html>
<html>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="icon" href="/pico.svg" />
<script src="/libs/mithril.min.js"></script>
<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>
<link rel="stylesheet" href="/pico.css" />
<script src="/pico.js" defer></script>
<body>PicoChat requires JS</body>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="icon" href="/pico.svg" />
<script src="/libs/mithril.min.js"></script>
<script src="/apps/streams.js"></script>
<script src="/pico.js" defer></script>
<style>
.hidden {
display: none;
}
</style>
</head>
<body style="margin: 0; padding: 0;">
<div>PicoChat requires JS</div>
</body>
</html>

+ 47
- 376
pico.js Целия файл

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

Loading…
Отказ
Запис