streams, message time, space zoom+pan

main
jerl 2025-03-20 00:01:15 -07:00
parent bb31b56929
commit 779aaece08
8 changed files with 208 additions and 153 deletions

View File

@ -162,6 +162,7 @@
} }
> .title { > .title {
width: 100%; width: 100%;
padding-inline: 4px;
} }
} }
.expander { .expander {
@ -301,6 +302,12 @@
.message { .message {
margin-bottom: 5px; margin-bottom: 5px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
display: flex;
}
.time {
color: #999;
font-size: small;
vertical-align: top;
} }
#loadmore { #loadmore {
margin-bottom: 10px; margin-bottom: 10px;
@ -321,9 +328,12 @@
} }
#space { #space {
margin: -2px; /* offset column margin */ margin: -2px; /* offset column margin */
position: relative;
overflow: auto; overflow: auto;
} }
#spacediv {
position: relative;
transform-origin: 0 0;
}
.span { .span {
position: absolute; position: absolute;
white-space: nowrap; white-space: nowrap;

View File

@ -1,45 +1,56 @@
import { render, html } from '/uhtml.js'; import { render, html } from '/uhtml.js';
let msg; let msg;
function sendMessage(event) {
event.preventDefault();
if (!msg.value)
return;
window.currentInstance.emit('send_message', {
message: msg.value,
thread: window.currentThread.id
});
msg.value = '';
}
let earliestMessage; let earliestMessage;
function loadMessages(callback) { function loadMessages(callback) {
let instance = window.currentInstance; let instance = window.currentInstance;
if (!msg) { if (!msg) {
render(document.getElementById('message'), html` render(document.getElementById('message'), html`
<button id='loadmore' class='hidden' onclick=${loadMessages}> <button id='loadmore' class='hidden' onclick=${loadMessages}>
load more messages load more messages
</button> </button>
<div id='messages'></div> <div id='messages'></div>
<form id='msginput' onsubmit=${sendMessage}> <form id='msginput' onsubmit=${function(event) {
<input id='msg' placeholder='write a message...' /> event.preventDefault();
<button type='submit' id='sendmsg'>send</button> if (!msg.value)
</form> return;
`); window.currentInstance.emit('send_message', {
msg = document.getElementById('msg'); message: msg.value,
} thread: window.currentThread.id
const messages = document.getElementById('messages'); });
if (!this) { // called from chooseThread, initializing thread msg.value = '';
messages.innerHTML = ''; }}>
earliestMessage = null; <input id='msg' placeholder='write a message...' />
<button type='submit' id='sendmsg'>send</button>
</form>
`);
msg = document.getElementById('msg');
}
const messages = document.getElementById('messages');
if (!this) { // called from chooseThread, initializing thread
messages.innerHTML = '';
earliestMessage = null;
if (window.currentThread.permissions.post) if (window.currentThread.permissions.post)
document.getElementById('msginput').classList.remove('hidden'); document.getElementById('msginput').classList.remove('hidden');
else else
document.getElementById('msginput').classList.add('hidden'); document.getElementById('msginput').classList.add('hidden');
} }
function addMessage(message, user) {
let now = new Date(), date = new Date(message.created);
let div = html.node`<div class='message'><span class='time'>${
now.getDate() === date.getDate() && date.getTime() > now.getTime() - 24 * 60 * 60000
? date.toLocaleTimeString() : date.toLocaleString()
}</span></div>`;
let content = html.node`<span class='content'>: ${message.content}</span>`;
content.prepend(user ? window.makeUser(user, user.id.split?.('@')[1] || instance.url)
: html.node`<span>${message.user.id}</span>`);
div.prepend(content);
messages.append(div);
}
instance.emit('get_history', { instance.emit('get_history', {
before: earliestMessage, before: earliestMessage,
thread: window.currentThread.id thread: window.currentThread.id
@ -73,10 +84,7 @@ function loadMessages(callback) {
user.permissions = message.user.permissions; user.permissions = message.user.permissions;
} }
} }
let div = html.node`<div class='message'>: ${message.content}</div>`; addMessage(message, user);
div.prepend(user ? window.makeUser(user, user.id.split?.('@')[1] || instance.url)
: html.node`<span>${message.user.id}</span>`);
messages.prepend(div);
} }
} }
if (msg.more) if (msg.more)
@ -92,10 +100,7 @@ function loadMessages(callback) {
return; return;
const messages = document.getElementById('messages'); const messages = document.getElementById('messages');
let scroll = messages.scrollTop + 10 >= messages.scrollHeight - messages.clientHeight; let scroll = messages.scrollTop + 10 >= messages.scrollHeight - messages.clientHeight;
let div = html.node`<div class='message'>: ${message.content}</div>`; addMessage(message, message.user);
div.prepend(window.makeUser(message.user,
message.user.id.split?.('@')[1] || instance.url));
messages.append(div);
if (scroll) if (scroll)
messages.scroll(0, messages.scrollHeight - messages.clientHeight); messages.scroll(0, messages.scrollHeight - messages.clientHeight);
if (!earliestMessage) if (!earliestMessage)

View File

@ -1,23 +1,30 @@
let space; let spaceContainer, space;
let scale = 1; // todo: make zooming work
let editing; let editing;
let dragging; let dragging;
let moved; let moved;
let movedFrom; let movedFrom;
let offset; let offset;
let clicked;
document.onmousemove = event => { document.addEventListener('mouseup', function(event) {
clicked = false;
});
document.addEventListener('mousemove', function(event) {
moved = true; moved = true;
if (!dragging) if (dragging) {
return; let left = event.clientX - spaceContainer.offsetLeft - offset.x;
let left = (event.clientX - space.offsetLeft) * scale - offset.x; let top = event.clientY - spaceContainer.offsetTop - offset.y;
let top = (event.clientY - space.offsetTop) * scale - offset.y; dragging.style.left = `${left < 0 ? 0 : left}px`;
dragging.style.left = `${left < 0 ? 0 : left}px`; dragging.style.top = `${top < 0 ? 0 : top}px`;
dragging.style.top = `${top < 0 ? 0 : top}px`; save(dragging);
save(dragging); }
}; else if (clicked) {
spaceContainer.scrollLeft -= event.movementX;
spaceContainer.scrollTop -= event.movementY;
}
});
let saving; let saving;
let queue; let queue;
@ -79,24 +86,28 @@ function add(s) {
}; };
span.onwheel = function(event) { span.onwheel = function(event) {
event.preventDefault(); event.preventDefault();
if (event.deltaY < 0 && this.scale >= 200) event.stopPropagation();
return;
this.scale *= 1 - event.deltaY * .001; this.scale *= 1 - event.deltaY * .001;
if (this.scale > 100)
this.scale = 100;
this.style.transform = `translate(-50%, -50%) scale(${this.scale})`; this.style.transform = `translate(-50%, -50%) scale(${this.scale})`;
save(this); save(this);
}; };
span.onmousedown = function(event) { span.onmousedown = function(event) {
if (dragging || editing === this) if (dragging || editing === this)
return; return;
dragging = this;
event.preventDefault(); event.preventDefault();
if (event.button !== 0)
return;
dragging = this;
offset = { offset = {
x: event.clientX - (space.offsetLeft + this.offsetLeft), x: event.clientX - (spaceContainer.offsetLeft + this.offsetLeft),
y: event.clientY - (space.offsetTop + this.offsetTop) y: event.clientY - (spaceContainer.offsetTop + this.offsetTop)
}; };
}; };
span.onmouseup = function(event) { span.onmouseup = function(event) {
event.stopPropagation(); if (event.button !== 0)
return;
dragging = null; dragging = null;
if (moved) if (moved)
return; return;
@ -109,14 +120,36 @@ function add(s) {
export default function loadSpace(callback) { export default function loadSpace(callback) {
let instance = window.currentInstance; let instance = window.currentInstance;
if (!instance.spaceid) { let scale = 1;
space = document.getElementById('space');
space.onmousedown = event => { if (!instance.spaced) {
spaceContainer = document.getElementById('space');
space = document.getElementById('spacediv');
spaceContainer.onwheel = function(event) {
if (event.getModifierState('Shift'))
return;
event.preventDefault();
scale *= 1 - event.deltaY * .001;
if (scale < .5)
scale = .5;
space.style.transform = `scale(${scale})`;
spaceContainer.scrollLeft += event.offsetX * (scale - scale / (1 - event.deltaY * .001));
spaceContainer.scrollTop += event.offsetY * (scale - scale / (1 - event.deltaY * .001));
//spaceContainer.scrollLeft += spaceContainer.clientWidth * (1 - spaceContainer.clientWidth / spaceContainer.scrollWidth + event.offsetX / spaceContainer.clientWidth) * (scale - scale / (1 - event.deltaY * .001));
//spaceContainer.scrollTop += spaceContainer.clientHeight * (1 - spaceContainer.clientHeight / spaceContainer.scrollHeight + event.offsetY / spaceContainer.clientHeight) * (scale - scale / (1 - event.deltaY * .001));
};
spaceContainer.onmousedown = function(event) {
if (event.button !== 0)
return;
clicked = true;
moved = false; moved = false;
movedFrom = { x: event.offsetX, y: event.offsetY }; movedFrom = { x: event.offsetX, y: event.offsetY };
}; };
space.onmouseup = event => { spaceContainer.onmouseup = function(event) {
if (event.button !== 0)
return;
if (dragging) { if (dragging) {
dragging.onmouseup(event); dragging.onmouseup(event);
return; return;
@ -130,8 +163,8 @@ export default function loadSpace(callback) {
+ (event.offsetY - movedFrom.y) * (event.offsetY - movedFrom.y) > 100) + (event.offsetY - movedFrom.y) * (event.offsetY - movedFrom.y) > 100)
return; return;
editing = add({ editing = add({
x: event.offsetX + space.scrollLeft, x: (event.offsetX + spaceContainer.scrollLeft) / scale,
y: event.offsetY + space.scrollTop, y: (event.offsetY + spaceContainer.scrollTop) / scale,
scale: 1, scale: 1,
content: '' content: ''
}); });
@ -139,7 +172,7 @@ export default function loadSpace(callback) {
}; };
instance.socket.on('span', msg => { instance.socket.on('span', msg => {
if (msg.thread !== instance.spaceid || window.currentInstance !== instance) if (msg.thread !== window.currentSpace.id || window.currentInstance !== instance)
return; return;
let span = document.getElementById('span' + msg.id); let span = document.getElementById('span' + msg.id);
if (span) { if (span) {
@ -152,13 +185,15 @@ export default function loadSpace(callback) {
else else
add(msg); add(msg);
}); });
instance.spaced = true;
} }
else if (instance.spaceid === window.currentThread.id) else if (window.currentSpace === window.currentThread)
return; return;
instance.spaceid = window.currentThread.id; window.currentSpace = window.currentThread;
space.innerHTML = ''; space.innerHTML = '';
space.style.transform = '';
instance.emit('get_space', { instance.emit('get_space', {
thread: window.currentThread.id thread: window.currentSpace.id
}, msg => { }, msg => {
if (!msg.success) { if (!msg.success) {
console.log('get space failed: ' + msg.message); console.log('get space failed: ' + msg.message);

View File

@ -2,8 +2,7 @@ import { render, html } from '/uhtml.js';
let mediaStream; let mediaStream;
async function stream() { async function stream(thread) {
let thread = window.currentThread;
if (thread.handle) { if (thread.handle) {
clearInterval(thread.handle); clearInterval(thread.handle);
delete thread.handle; delete thread.handle;
@ -35,40 +34,40 @@ async function stream() {
thread.instance.emit('stream', { thread.instance.emit('stream', {
thread: thread.id, thread: thread.id,
name: document.getElementById('streamname').value name: document.getElementById('streamname').value
}, msg => { }, async msg => {
if (!msg.success) { if (!msg.success) {
console.log('stream failed:', msg.message); console.log('stream failed:', msg.message);
return; return;
} }
thread.streamid = msg.id; thread.streamid = msg.id;
document.getElementById('streaming').innerText = 'stop streaming'; document.getElementById('streaming').innerText = 'stop streaming';
function record() { thread.recorder = new MediaRecorder(mediaStream, {
let r = thread.recorder = new MediaRecorder(mediaStream); mimeType: 'audio/webm;codecs=opus'
let chunks = []; });
r.ondataavailable = event => { thread.recorder.start();
if (!event.data.size) thread.recorder.ondataavailable = async event => {
if (!event.data.size || !thread.handle)
return;
thread.instance.emit('streamdata', {
id: thread.streamid,
audio: await event.data.arrayBuffer()
}, msg => {
if (msg.success)
return; return;
chunks.push(event.data); console.log('streamdata failed:', msg.message);
}; if (msg.message === 'stream not found' && thread.handle)
r.onstop = async () => { stream(thread);
if (!chunks.length || !thread.handle) });
};
// first 200ms chunk will be used as stream header
thread.handle = setTimeout(() => {
thread.recorder.requestData();
thread.handle = setInterval(() => {
if (!thread.handle)
return; return;
//console.log(`${Date.now()} ${chunks.length}`); thread.recorder.requestData();
thread.instance.emit('streamdata', { }, 500);
id: thread.streamid, }, 200);
audio: await (new Blob(chunks, { type: chunks[0].type })).arrayBuffer()
}, msg => {
if (!msg.success)
console.log('streamdata failed:', msg.message);
});
};
r.onstart = () => {
setTimeout(() => r.state === 'recording' && r.stop(), 500);
};
r.start();
}
record();
thread.handle = setInterval(record, 500);
}); });
} }
@ -79,24 +78,24 @@ function loadStreams() {
div.innerHTML = ''; div.innerHTML = '';
if (window.currentThread?.permissions.post) { if (window.currentThread?.permissions.post) {
render(div, html.node` render(div, html.node`
<button id='streaming' onclick=${stream}> <button id='streaming' onclick=${function(event){
${window.currentThread.handle ? 'stop' : 'start'} streaming stream(currentThread);
</button> }}>${currentThread.handle ? 'stop' : 'start'} streaming</button>
<span>stream name:</span> <span>stream name:</span>
<input id='streamname' oninput=${function(event) { <input id='streamname' oninput=${function(event) {
if (window.currentThread.handle) if (currentThread.handle)
instance.emit('stream', { instance.emit('stream', {
id: window.currentThread.streamid, id: currentThread.streamid,
thread: window.currentThread.id, thread: currentThread.id,
name: this.value name: this.value
}); });
window.currentThread.streamname = this.value; currentThread.streamname = this.value;
}}> }}>
<p id='listeners'>${window.currentThread.listeners ? <p id='listeners'>${currentThread.listeners ?
window.currentThread.listeners + ' listeners' : ''} currentThread.listeners + ' listeners' : ''}
</p>`); </p>`);
if (window.currentThread.streamname) if (currentThread.streamname)
document.getElementById('streamname').value = window.currentThread.streamname; document.getElementById('streamname').value = currentThread.streamname;
} }
div.insertAdjacentHTML('beforeend', ` div.insertAdjacentHTML('beforeend', `
<p>streams:</p> <p>streams:</p>
@ -107,16 +106,23 @@ function loadStreams() {
<p> <p>
<button id='play' onclick=${function(event) { <button id='play' onclick=${function(event) {
if (stream.playing) { if (stream.playing) {
stream.audioctx.suspend(); stream.audio.pause();
delete instance.streaming[stream.id]; delete instance.streaming[stream.id];
stream.playing = false; stream.playing = false;
this.innerText = '▶'; this.innerText = '▶';
} }
else { else {
stream.audioctx = new AudioContext(); stream.audio = new Audio();
stream.gain = stream.audioctx.createGain(); // play 200ms stream header silently
stream.gain.connect(stream.audioctx.destination); stream.audio.volume = 0;
stream.gain.gain.value = p.children['volume'].value / 100; stream.audio.onplaying = () => setTimeout(() => {
stream.audio.volume = p.children['volume'].value / 100;
p.children['volume'].oninput = function(event) {
stream.audio.volume = this.value / 100;
};
}, 200);
stream.audio.src = '/stream/' + stream.id;
stream.audio.play();
instance.streaming[stream.id] = stream; instance.streaming[stream.id] = stream;
stream.playing = true; stream.playing = true;
this.innerText = '⏹'; this.innerText = '⏹';
@ -127,13 +133,10 @@ function loadStreams() {
playing: stream.playing playing: stream.playing
}, msg => { }, msg => {
if (!msg.success) if (!msg.success)
console.log('play stream failed: ', msg.message); console.log('play_stream failed: ', msg.message);
}); });
}}>${instance.streaming[stream.id] ? '⏹' : '▶'}</button> }}>${instance.streaming[stream.id] ? '⏹' : '▶'}</button>
<input id='volume' type='range' oninput=${function(event) { <input id='volume' type='range'>
if (stream.gain)
stream.gain.gain.value = this.value / 100;
}}>
<span id='name'>${stream.name ? ` - ${stream.name}` : ''}</span> <span id='name'>${stream.name ? ` - ${stream.name}` : ''}</span>
</p>`; </p>`;
p.insertBefore(window.makeUser(stream.user, p.insertBefore(window.makeUser(stream.user,
@ -171,16 +174,6 @@ function loadStreams() {
} }
}); });
instance.socket.on('streamdata', async msg => {
let stream = instance.streaming[msg.id];
if (!stream)
return;
let source = stream.audioctx.createBufferSource();
source.buffer = await stream.audioctx.decodeAudioData(msg.audio);
source.connect(stream.gain);
source.start(/*audioStartTime*/);
});
instance.socket.on('listeners', msg => { instance.socket.on('listeners', msg => {
document.querySelector( document.querySelector(
`#instance${instance.id} > #threads > #threadlist > #thread${msg.thread}`) `#instance${instance.id} > #threads > #threadlist > #thread${msg.thread}`)

View File

@ -419,7 +419,9 @@ async function loadThreads(instancediv, select) {
}>members</button> }>members</button>
</div> </div>
<div id='message' class='tabcontent'></div> <div id='message' class='tabcontent'></div>
<div id='space' class='tabcontent hidden'></div> <div id='space' class='tabcontent hidden'>
<div id='spacediv'></div>
</div>
<div id='call' class='tabcontent hidden'></div> <div id='call' class='tabcontent hidden'></div>
</div> </div>
<hr class='separator' color='#505050'> <hr class='separator' color='#505050'>

View File

@ -4,6 +4,7 @@ const http = require('http');
const { Server } = require('socket.io'); const { Server } = require('socket.io');
const ioclient = require('socket.io-client'); const ioclient = require('socket.io-client');
const compression = require('compression'); const compression = require('compression');
const { PassThrough } = require('stream');
const events = {}; const events = {};
for (let file of fs.readdirSync('./src/events')) { for (let file of fs.readdirSync('./src/events')) {
@ -89,6 +90,20 @@ const io = new Server(server, {
app.use(express.static('client')); app.use(express.static('client'));
app.get('/stream/:id', (req, res) => {
let stream = vybe.streams[req.params.id];
if (!stream) {
res.sendStatus(404);
return;
}
res.status(200);
let passthrough = new PassThrough();
passthrough.pipe(res);
stream.streams.add(passthrough);
res.set('Content-Type', 'audio/webm');
req.on('close', () => stream.streams.delete(passthrough));
});
io.on('connection', (socket) => { io.on('connection', (socket) => {
for (let event in events) { for (let event in events) {
socket.on(event, (msg, callback) => { socket.on(event, (msg, callback) => {

View File

@ -46,7 +46,8 @@ async function send_message(msg, respond) {
permissions: userperms permissions: userperms
}, },
content: msg.message, content: msg.message,
thread: msg.thread thread: msg.thread,
created: Date.now()
}); });
} }
} }
@ -77,12 +78,12 @@ async function get_history(msg, respond) {
}); });
} }
const messages = (await db.query(` const messages = (await db.query(`
select coalesce(displayname, name) as displayname, name, user as userid, post.id, content select coalesce(displayname, name) as displayname, name, user as userid, post.id, content, post.created
from post from post
left join user on post.user = user.id left join user on post.user = user.id
where ${msg.before ? 'post.id < ? and' : ''} where ${msg.before ? 'post.id < ? and' : ''}
thread = ? thread = ?
order by post.created desc order by post.created asc
limit 101`, limit 101`,
msg.before ? [msg.before, msg.thread] : [msg.thread] msg.before ? [msg.before, msg.thread] : [msg.thread]
)).rows; )).rows;
@ -103,7 +104,8 @@ async function get_history(msg, respond) {
displayname: message.displayname, displayname: message.displayname,
permissions: perms[message.userid] permissions: perms[message.userid]
}, },
content: message.content content: message.content,
created: new Date(message.created + 'Z').getTime()
})), })),
more: messages.length > 100 more: messages.length > 100
}); });

View File

@ -32,24 +32,22 @@ async function stream(msg, respond, socket) {
} }
} }
if (typeof msg.id === 'number') { if (typeof msg.id === 'number') {
stream = vybe.streams[msg.id]; let vstream = vybe.streams[msg.id];
if (!stream) if (!vstream)
return respond({ return respond({
success: false, success: false,
message: 'stream not found' message: 'stream not found'
}); });
if (msg.auth_user.id !== stream.userid) if (msg.auth_user.id !== vstream.userid)
return respond({ return respond({
success: false, success: false,
message: "stream doesn't belong to user" message: "stream doesn't belong to user"
}); });
stream = vstream.stream;
if (msg.stop) { if (msg.stop) {
await stream.stop(); await vstream.stop();
return respond({ return respond({ success: true });
success: true
});
} }
stream = stream.stream;
stream.name = msg.name; stream.name = msg.name;
stream.user.displayname = msg.auth_user.displayname; stream.user.displayname = msg.auth_user.displayname;
} }
@ -82,7 +80,8 @@ async function stream(msg, respond, socket) {
thread.streams.splice(thread.streams.findIndex(s => s.id === stream.id), 1); thread.streams.splice(thread.streams.findIndex(s => s.id === stream.id), 1);
delete vybe.streams[stream.id]; delete vybe.streams[stream.id];
await send(); await send();
} },
streams: new Set()
}; };
} }
await send(); await send();
@ -106,15 +105,11 @@ async function streamdata(msg, respond) {
message: "stream doesn't belong to user" message: "stream doesn't belong to user"
}); });
} }
for (let id in stream.listeners) if (stream.head)
stream.listeners[id].emit('streamdata', { stream.streams.forEach(passthrough => passthrough.write(msg.audio));
id: msg.id, else
audio: msg.audio, stream.head = msg.audio;
video: msg.video respond({ success: true });
});
respond({
success: true
});
} }
async function play_stream(msg, respond, socket) { async function play_stream(msg, respond, socket) {
@ -138,9 +133,7 @@ async function play_stream(msg, respond, socket) {
thread: stream.stream.thread, thread: stream.stream.thread,
count: Object.keys(stream.listeners).length count: Object.keys(stream.listeners).length
}); });
respond({ respond({ success: true });
success: true
});
} }
module.exports = { module.exports = {