Add host-approved party suggestions and live upvotes

This commit is contained in:
Jonathan Sykes
2026-10-03 14:52:10 +08:00
parent 75dbc468cc
commit d7721045fb
10 changed files with 101 additions and 3 deletions

View File

@@ -7343,6 +7343,7 @@ const Party = (() => {
const st = { const st = {
ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0, ws: null, role: null, code: '', secret: '', name: '', members: [], chat: [], state: null, offset: 0,
dj: { enabled: false, entries: [] }, djPending: false,
allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '', allowControl: false, quietUntil: 0, detached: false, retry: null, pushTimer: null, driftTimer: null, lastPush: '',
voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() }, voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() },
minimized: false, minimized: false,
@@ -7386,12 +7387,17 @@ const Party = (() => {
st.offset = (m.now || Date.now()) - Date.now(); st.offset = (m.now || Date.now()) - Date.now();
st.members = m.members || []; st.members = m.members || [];
st.chat = m.chat || []; st.chat = m.chat || [];
st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false;
st.allowControl = !!m.allowControl; st.allowControl = !!m.allowControl;
setStatus(isHost() ? 'You’re hosting' : 'Synced with the host'); setStatus(isHost() ? 'You’re hosting' : 'Synced with the host');
render(); render();
if (isHost()) pushState(true); if (isHost()) pushState(true);
else if (m.state) applyState(m.state); else if (m.state) applyState(m.state);
if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); } if (st.voice.on) { send({ type: 'voice', on: true }); connectVoicePeers(); }
} else if (m.type === 'dj') {
st.dj = { enabled: m.enabled, entries: m.entries || [] }; render();
} else if (m.type === 'dj-play' && isHost()) {
st.djPending = false; if (m.video) { Player._handoff = null; playVideoAt(m.video); } else { st.dj.entries = st.dj.entries.filter(e => !e.approved); advanceQueue(); }
} else if (m.type === 'state') { } else if (m.type === 'state') {
st.state = m.state; st.state = m.state;
if (st.role === 'guest') applyState(m.state); if (st.role === 'guest') applyState(m.state);
@@ -7681,6 +7687,8 @@ const Party = (() => {
<div class="pt-chat" id="ptChat"></div> <div class="pt-chat" id="ptChat"></div>
<form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form> <form class="pt-send"><input class="pt-msg" maxlength="500" placeholder="Message…" autocomplete="off" /><button class="np-btn" type="submit">Send</button></form>
<button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`; <button class="pt-leave" type="button" data-act="leave">${isHost() ? 'End the party' : 'Leave the party'}</button>`;
const dj = document.createElement('div'); panel.querySelector('.pt-body').append(dj);
PartyDJ.render(dj, { state: st.dj, host: isHost(), pid, send, search: async q => { const result = await API.search(q); return result.results || result.videos || []; } });
renderChat(); renderChat();
} }
function renderChat() { function renderChat() {
@@ -7762,6 +7770,7 @@ const Party = (() => {
} }
return { return {
nextDJ: () => { if (!isHost() || !st.dj.enabled || !st.dj.entries.some(e => e.approved)) return false; if (!st.djPending) { st.djPending = true; send({ type: 'dj', action: 'take' }); } return true; },
boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true), boot, guestActive, intercept, renderLyricsToggle, afterLoad: () => pushState(true),
state: () => ({ state: () => ({
role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on, role: st.role, code: st.code, detached: st.detached, members: st.members.length, voice: st.voice.on,
@@ -8013,6 +8022,7 @@ function renderUpNext() {
} }
// Advance to the next track. Wraps to the start when "repeat list" is on. // Advance to the next track. Wraps to the start when "repeat list" is on.
function advanceQueue() { function advanceQueue() {
if (Party.nextDJ()) return true;
if (queueIndex >= 0 && queueIndex < queue.length - 1) { if (queueIndex >= 0 && queueIndex < queue.length - 1) {
queueIndex++; queueIndex++;
} else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) { } else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) {
@@ -12431,6 +12441,7 @@ async function boot() {
bootSearchFromUrl(); bootSearchFromUrl();
if (WEB) bootShareFromUrl(); if (WEB) bootShareFromUrl();
Party.boot(); Party.boot();
PartyDJ.register(() => $('navPartyBtn').click());
Remote.boot(); Remote.boot();
Transition.renderButton(); Transition.renderButton();
PiP.render(); PiP.render();

View File

@@ -655,6 +655,7 @@
<script src="sw-update.js"></script> <script src="sw-update.js"></script>
<script src="settings-sections.js"></script> <script src="settings-sections.js"></script>
<script src="downloads.js"></script> <script src="downloads.js"></script>
<script src="party-dj.js"></script>
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

23
frontend/party-dj.js Normal file
View File

@@ -0,0 +1,23 @@
(function(root) {
'use strict';
const ranked = entries => [...entries].sort((a, b) => Number(b.approved) - Number(a.approved) || b.votes.length - a.votes.length);
function render(container, { state, host, pid, send, search }) {
const doc = container.ownerDocument;
if (host) { const label = doc.createElement('label'); label.className = 'pt-toggle'; const toggle = doc.createElement('input'); toggle.type = 'checkbox'; toggle.checked = state.enabled; toggle.onchange = () => send({ type: 'dj', action: 'enable', enabled: toggle.checked }); label.append(toggle, ' Party DJ mode'); container.append(label); }
if (!state.enabled) return;
const heading = doc.createElement('h3'); heading.textContent = 'Party Queue'; container.append(heading);
const form = doc.createElement('div'); form.className = 'party-dj-search';
const input = doc.createElement('input'); input.type = 'search'; input.placeholder = 'Suggest a song'; input.setAttribute('aria-label', 'Search for a party suggestion');
const button = doc.createElement('button'); button.type = 'button'; button.textContent = 'Search'; const results = doc.createElement('div');
let request = 0;
button.onclick = async () => { const query = input.value.trim(); if (!query) return; const token = ++request; button.disabled = true; results.textContent = 'Searching…'; try { const videos = await search(query); if (!container.isConnected || token !== request) return; results.replaceChildren(); for (const video of videos.slice(0, 8)) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = `Suggest: ${video.title || video.id}`; b.onclick = () => send({ type: 'dj', action: 'suggest', video }); results.append(b); } if (!videos.length) results.textContent = 'No matches.'; } catch { results.textContent = 'Search unavailable. Try again.'; } finally { button.disabled = false; } };
input.onkeydown = e => { if (e.key === 'Enter') { e.preventDefault(); button.click(); } }; form.append(input, button); container.append(form, results);
for (const entry of ranked(state.entries)) {
const row = doc.createElement('div'); row.className = 'party-dj-row'; const title = doc.createElement('b'); title.textContent = entry.video.title; row.append(title);
const status = doc.createElement('small'); status.textContent = entry.approved ? 'Upcoming' : 'Awaiting host approval'; row.append(status);
for (const action of ['vote', ...(host ? [...(entry.approved ? [] : ['approve']), 'remove'] : [])]) { const b = doc.createElement('button'); b.type = 'button'; b.textContent = action === 'vote' ? `👍 ${entry.votes.length}` : action === 'approve' ? 'Approve' : 'Remove'; b.setAttribute('aria-label', `${action} ${entry.video.title}`); if (action === 'vote') { b.disabled = entry.votes.includes(pid); b.setAttribute('aria-pressed', String(b.disabled)); } b.onclick = () => send({ type: 'dj', action, id: entry.video.id }); row.append(b); } container.append(row);
}
}
function register(open) { root.SettingsSections.register({ id: 'party-dj', title: 'Watch party', cluster: 'Live & worship', summary: () => 'Shared suggestions · host approval', render(container) { const p = container.ownerDocument.createElement('p'); p.textContent = 'Party DJ mode is off until the host enables it. Guests suggest songs and vote once per song; approved songs play in vote order.'; const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Open watch party'; b.onclick = open; container.append(p, b); } }); }
const api = { ranked, render, register }; if (typeof module !== 'undefined') module.exports = api; else root.PartyDJ = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,3 @@
const { test } = require('node:test'), assert = require('node:assert/strict');
const { ranked } = require('./party-dj');
test('approved songs rank first, vote ties retain submission order', () => { const entries = [{ video: { id: 'a' }, approved: false, votes: ['a','b'] }, { video: { id: 'b' }, approved: true, votes: [] }, { video: { id: 'c' }, approved: true, votes: ['a'] }, { video: { id: 'd' }, approved: true, votes: [] }]; assert.deepEqual(ranked(entries).map(e => e.video.id), ['c','b','d','a']); assert.equal(entries[0].video.id, 'a'); });

View File

@@ -30,3 +30,9 @@
.download-job progress { width: 100%; accent-color: var(--accent); } .download-job progress { width: 100%; accent-color: var(--accent); }
.download-job > div { display: flex; gap: 8px; } .download-job > div { display: flex; gap: 8px; }
.download-job button { min-height: 44px; padding: 8px 16px; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; } .download-job button { min-height: 44px; padding: 8px 16px; color: var(--text); background: var(--bg-2); border: 1px solid var(--line); border-radius: 8px; }
.party-dj-search { display: flex; gap: 8px; margin-block: 12px; }
.party-dj-search input { min-width: 0; flex: 1; }
.party-dj-row { padding-block: 12px; border-top: 1px solid var(--line); }
.party-dj-row b, .party-dj-row small { display: block; overflow-wrap: anywhere; }
.party-dj-row small { color: var(--text-2); margin-block: 6px; }
.party-dj-row button, .party-dj-search button, .party-dj-search + div button { min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; margin: 4px; }

View File

@@ -84,6 +84,7 @@ const SHELL = [
'/p2p-recv-worker.js', '/p2p-recv-worker.js',
'/settings-sections.js', '/settings-sections.js',
'/downloads.js', '/downloads.js',
'/party-dj.js',
'/settings-sections.css', '/settings-sections.css',
'/app.js', '/app.js',
'/manifest.webmanifest', '/manifest.webmanifest',

26
server/party-dj.js Normal file
View File

@@ -0,0 +1,26 @@
// Server-authoritative queue. Votes belong to the participant id across reconnects.
export function createDJQueue() { return { enabled: false, entries: [], sequence: 0 }; }
export function queueSnapshot(queue) {
return { enabled: queue.enabled, entries: [...queue.entries].sort((a, b) => Number(b.approved) - Number(a.approved) || b.votes.size - a.votes.size || a.order - b.order).map(({ votes, order, ...entry }) => ({ ...entry, votes: [...votes] })) };
}
export function changeDJQueue(queue, message, participant, host) {
if (message.action === 'enable') { if (!host) return false; queue.enabled = !!message.enabled; return true; }
if (!queue.enabled) return false;
const entry = queue.entries.find(e => e.video.id === message.id);
if (message.action === 'suggest') {
const v = message.video; if (!v || !/^[A-Za-z0-9_-]{11}$/.test(v.id) || queue.entries.length >= 100 || queue.entries.some(e => e.video.id === v.id)) return false;
const clean = value => String(value || '').replace(/[\u0000-\u001f<>]/g, '').slice(0, 200);
queue.entries.push({ video: { id: v.id, title: clean(v.title) || v.id, channel: clean(v.channel), thumbnail: `/api/thumb/${v.id}` }, from: participant, approved: host, votes: new Set(), order: queue.sequence++ }); return true;
}
if (!entry) return false;
if (message.action === 'vote') { if (entry.votes.has(participant)) return false; entry.votes.add(participant); return true; }
if (!host) return false;
if (message.action === 'approve') { entry.approved = true; return true; }
if (message.action === 'remove') { queue.entries = queue.entries.filter(e => e !== entry); return true; }
return false;
}
export function takeDJVideo(queue) {
if (!queue.enabled) return null;
const next = queueSnapshot(queue).entries.find(e => e.approved); if (!next) return null;
queue.entries = queue.entries.filter(e => e.video.id !== next.video.id); return next.video;
}

12
server/party-dj.test.js Normal file
View File

@@ -0,0 +1,12 @@
import { test, expect } from 'bun:test';
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
test('host controls mode and approval; participant votes survive reconnects', () => {
const q = createDJQueue(); const suggest = { action: 'suggest', video: { id: '0gfX0dFLaBc', title: '<Song>' } };
expect(changeDJQueue(q, suggest, 'guest', false)).toBe(false); expect(changeDJQueue(q, { action: 'enable', enabled: true }, 'guest', false)).toBe(false);
changeDJQueue(q, { action: 'enable', enabled: true }, 'host', true); expect(changeDJQueue(q, suggest, 'guest', false)).toBe(true);
expect(changeDJQueue(q, suggest, 'guest', false)).toBe(false); expect(takeDJVideo(q)).toBeNull();
const vote = { action: 'vote', id: suggest.video.id }; expect(changeDJQueue(q, vote, 'guest', false)).toBe(true); expect(changeDJQueue(q, vote, 'guest', false)).toBe(false);
expect(changeDJQueue(q, { action: 'approve', id: suggest.video.id }, 'guest', false)).toBe(false);
changeDJQueue(q, { action: 'approve', id: suggest.video.id }, 'host', true); expect(queueSnapshot(q).entries[0].votes).toEqual(['guest']); expect(takeDJVideo(q).title).toBe('Song'); expect(q.entries).toHaveLength(0);
});
test('ranked approved queue consumes highest voted song, with bounded metadata', () => { const q = createDJQueue(); q.enabled = true; for (const id of ['abcdefghijk', 'ABCDEFGHIJK']) changeDJQueue(q, { action: 'suggest', video: { id, title: 'x'.repeat(1000), thumbnail: 'bad' } }, 'host', true); changeDJQueue(q, { action: 'vote', id: 'ABCDEFGHIJK' }, 'guest', false); expect(takeDJVideo(q).id).toBe('ABCDEFGHIJK'); expect(queueSnapshot(q).entries[0].video.title.length).toBe(200); expect(changeDJQueue(q, { action: 'suggest', video: { id: 'bad' } }, 'guest', false)).toBe(false); });

View File

@@ -18,6 +18,7 @@
* link); the host resumes it with the secret it was given. * link); the host resumes it with the secret it was given.
* ========================================================================== */ * ========================================================================== */
import { createDJQueue, changeDJQueue, queueSnapshot, takeDJVideo } from './party-dj.js';
import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto'; import { randomBytes, randomInt, timingSafeEqual } from 'node:crypto';
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I
@@ -88,7 +89,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; } if (p && !same(p.secret, d.secret)) { ws.close(4003, 'not the host of this party'); return; }
if (!p) { if (!p) {
const code = newCode(); const code = newCode();
p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], allowControl: false, hostOnline: true, hostLeftAt: 0 }; p = { code, secret: randomBytes(18).toString('base64url'), hostPid: d.pid, members: new Map(), state: null, chat: [], dj: createDJQueue(), allowControl: false, hostOnline: true, hostLeftAt: 0 };
parties.set(code, p); parties.set(code, p);
} }
p.hostPid = d.pid; p.hostPid = d.pid;
@@ -103,7 +104,7 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 }); p.members.set(d.pid, { ws, pid: d.pid, name: d.name, voice: false, lastChat: 0 });
send(ws, { send(ws, {
type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid, type: 'hello', code: p.code, secret: d.role === 'host' ? p.secret : undefined, you: d.pid,
now: now(), members: members(p), state: p.state, chat: p.chat, allowControl: p.allowControl, now: now(), members: members(p), state: p.state, chat: p.chat, dj: queueSnapshot(p.dj), allowControl: p.allowControl,
}); });
broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws); broadcast(p, { type: 'members', members: members(p), joined: d.name }, ws);
} }
@@ -117,7 +118,10 @@ export function createPartyHub({ now = () => Date.now() } = {}) {
const me = p && p.members.get(d.pid); const me = p && p.members.get(d.pid);
if (!me || me.ws !== ws || !m || typeof m !== 'object') return; if (!me || me.ws !== ws || !m || typeof m !== 'object') return;
const isHost = d.pid === p.hostPid; const isHost = d.pid === p.hostPid;
if (m.type === 'state' && isHost) { if (m.type === 'dj') {
if (m.action === 'take' && isHost) { const video = takeDJVideo(p.dj); send(ws, { type: 'dj-play', video }); if (video) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) }); }
else if (changeDJQueue(p.dj, m, me.pid, isHost)) broadcast(p, { type: 'dj', ...queueSnapshot(p.dj) });
} else if (m.type === 'state' && isHost) {
p.state = { ...(m.state || {}), ts: now() }; p.state = { ...(m.state || {}), ts: now() };
broadcast(p, { type: 'state', state: p.state }, ws); broadcast(p, { type: 'state', state: p.state }, ws);
} else if (m.type === 'settings' && isHost) { } else if (m.type === 'settings' && isHost) {

View File

@@ -115,3 +115,14 @@ describe('watch party', () => {
expect(await guest.closed).toBe(4010); expect(await guest.closed).toBe(4010);
}); });
}); });
test('DJ suggestions and votes are broadcast, host consumes approved songs', async () => {
const h = client({ role: 'host', pid: 'djhost00001', name: 'Host' }); const hello = await h.next('hello');
const g = client({ role: 'guest', pid: 'djguest0001', name: 'Guest', code: hello.code }); await g.next('hello');
h.send({ type: 'dj', action: 'enable', enabled: true }); expect((await g.next('dj')).enabled).toBe(true); await h.next('dj');
g.send({ type: 'dj', action: 'suggest', video: { id: '0gfX0dFLaBc', title: 'Song' } }); expect((await h.next('dj')).entries[0].approved).toBe(false); await g.next('dj');
g.send({ type: 'dj', action: 'vote', id: '0gfX0dFLaBc' }); expect((await h.next('dj')).entries[0].votes).toEqual(['djguest0001']); await g.next('dj');
h.send({ type: 'dj', action: 'approve', id: '0gfX0dFLaBc' }); expect((await g.next('dj')).entries[0].approved).toBe(true); await h.next('dj');
h.send({ type: 'dj', action: 'take' }); expect((await h.next('dj-play')).video.id).toBe('0gfX0dFLaBc'); expect((await g.next('dj')).entries).toEqual([]);
h.ws.close(); g.ws.close();
});