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

View File

@ -1,18 +1,6 @@
import { render, html } from '/uhtml.js';
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;
function loadMessages(callback) {
@ -24,7 +12,16 @@ function loadMessages(callback) {
load more messages
</button>
<div id='messages'></div>
<form id='msginput' onsubmit=${sendMessage}>
<form id='msginput' onsubmit=${function(event) {
event.preventDefault();
if (!msg.value)
return;
window.currentInstance.emit('send_message', {
message: msg.value,
thread: window.currentThread.id
});
msg.value = '';
}}>
<input id='msg' placeholder='write a message...' />
<button type='submit' id='sendmsg'>send</button>
</form>
@ -40,6 +37,20 @@ function loadMessages(callback) {
else
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', {
before: earliestMessage,
thread: window.currentThread.id
@ -73,10 +84,7 @@ function loadMessages(callback) {
user.permissions = message.user.permissions;
}
}
let div = html.node`<div class='message'>: ${message.content}</div>`;
div.prepend(user ? window.makeUser(user, user.id.split?.('@')[1] || instance.url)
: html.node`<span>${message.user.id}</span>`);
messages.prepend(div);
addMessage(message, user);
}
}
if (msg.more)
@ -92,10 +100,7 @@ function loadMessages(callback) {
return;
const messages = document.getElementById('messages');
let scroll = messages.scrollTop + 10 >= messages.scrollHeight - messages.clientHeight;
let div = html.node`<div class='message'>: ${message.content}</div>`;
div.prepend(window.makeUser(message.user,
message.user.id.split?.('@')[1] || instance.url));
messages.append(div);
addMessage(message, message.user);
if (scroll)
messages.scroll(0, messages.scrollHeight - messages.clientHeight);
if (!earliestMessage)

View File

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

View File

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

View File

@ -419,7 +419,9 @@ async function loadThreads(instancediv, select) {
}>members</button>
</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>
<hr class='separator' color='#505050'>

View File

@ -4,6 +4,7 @@ const http = require('http');
const { Server } = require('socket.io');
const ioclient = require('socket.io-client');
const compression = require('compression');
const { PassThrough } = require('stream');
const events = {};
for (let file of fs.readdirSync('./src/events')) {
@ -89,6 +90,20 @@ const io = new Server(server, {
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) => {
for (let event in events) {
socket.on(event, (msg, callback) => {

View File

@ -46,7 +46,8 @@ async function send_message(msg, respond) {
permissions: userperms
},
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(`
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
left join user on post.user = user.id
where ${msg.before ? 'post.id < ? and' : ''}
thread = ?
order by post.created desc
order by post.created asc
limit 101`,
msg.before ? [msg.before, msg.thread] : [msg.thread]
)).rows;
@ -103,7 +104,8 @@ async function get_history(msg, respond) {
displayname: message.displayname,
permissions: perms[message.userid]
},
content: message.content
content: message.content,
created: new Date(message.created + 'Z').getTime()
})),
more: messages.length > 100
});

View File

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