diff --git a/frontend/app.js b/frontend/app.js index 966ade2..7d748b2 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -7343,6 +7343,7 @@ const Party = (() => { const st = { 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: '', voice: { on: false, stream: null, muted: false, pcs: new Map(), audios: new Map() }, minimized: false, @@ -7386,12 +7387,17 @@ const Party = (() => { st.offset = (m.now || Date.now()) - Date.now(); st.members = m.members || []; st.chat = m.chat || []; + st.dj = m.dj || { enabled: false, entries: [] }; st.djPending = false; st.allowControl = !!m.allowControl; setStatus(isHost() ? 'You’re hosting' : 'Synced with the host'); render(); if (isHost()) pushState(true); else if (m.state) applyState(m.state); 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') { st.state = m.state; if (st.role === 'guest') applyState(m.state); @@ -7681,6 +7687,8 @@ const Party = (() => {
`; + 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(); } function renderChat() { @@ -7762,6 +7770,7 @@ const Party = (() => { } 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), state: () => ({ 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. function advanceQueue() { + if (Party.nextDJ()) return true; if (queueIndex >= 0 && queueIndex < queue.length - 1) { queueIndex++; } else if (!playFullMode && data.settings.repeatMode === 'all' && queue.length) { @@ -12431,6 +12441,7 @@ async function boot() { bootSearchFromUrl(); if (WEB) bootShareFromUrl(); Party.boot(); + PartyDJ.register(() => $('navPartyBtn').click()); Remote.boot(); Transition.renderButton(); PiP.render(); diff --git a/frontend/index.html b/frontend/index.html index 360b0fe..980bbf3 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -655,6 +655,7 @@ + diff --git a/frontend/party-dj.js b/frontend/party-dj.js new file mode 100644 index 0000000..b93390c --- /dev/null +++ b/frontend/party-dj.js @@ -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); diff --git a/frontend/party-dj.test.js b/frontend/party-dj.test.js new file mode 100644 index 0000000..d471e54 --- /dev/null +++ b/frontend/party-dj.test.js @@ -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'); }); diff --git a/frontend/settings-sections.css b/frontend/settings-sections.css index a70f675..85b677c 100644 --- a/frontend/settings-sections.css +++ b/frontend/settings-sections.css @@ -30,3 +30,9 @@ .download-job progress { width: 100%; accent-color: var(--accent); } .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; } +.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; } diff --git a/frontend/sw.js b/frontend/sw.js index 705ca22..2cecaf4 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -84,6 +84,7 @@ const SHELL = [ '/p2p-recv-worker.js', '/settings-sections.js', '/downloads.js', + '/party-dj.js', '/settings-sections.css', '/app.js', '/manifest.webmanifest', diff --git a/server/party-dj.js b/server/party-dj.js new file mode 100644 index 0000000..57e9152 --- /dev/null +++ b/server/party-dj.js @@ -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; +} diff --git a/server/party-dj.test.js b/server/party-dj.test.js new file mode 100644 index 0000000..31e10e7 --- /dev/null +++ b/server/party-dj.test.js @@ -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: '' } }; + 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); }); diff --git a/server/party.js b/server/party.js index 3b11020..7108edd 100644 --- a/server/party.js +++ b/server/party.js @@ -18,6 +18,7 @@ * 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'; 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) { 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); } 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 }); send(ws, { 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); } @@ -117,7 +118,10 @@ export function createPartyHub({ now = () => Date.now() } = {}) { const me = p && p.members.get(d.pid); if (!me || me.ws !== ws || !m || typeof m !== 'object') return; 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() }; broadcast(p, { type: 'state', state: p.state }, ws); } else if (m.type === 'settings' && isHost) { diff --git a/server/party.test.js b/server/party.test.js index 6e24ba8..5a9dd23 100644 --- a/server/party.test.js +++ b/server/party.test.js @@ -115,3 +115,14 @@ describe('watch party', () => { 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(); +});