openvibers/openvibe.chat Commits
Party rooms play OpenVibe Radio together (#494)
Changes
nnew file mode 100644@@@ -0,0 +1,46 @@++# Listening together in a party room++++A watch party or a shared computer on openvibe.party can play **OpenVibe Radio** beside what it watches. The room's++control lease (the remote) is the right to change it: whoever holds the remote starts a licensed track or the Radio++station, pauses, resumes, skips and stops it. Everyone else follows the same timeline; each person's volume is their++own browser setting.++++Only `https://openvibe.download`'s published Radio catalogue is playable. Nothing here accepts a free URL, and a++track is validated for its licence, its official audio URL and its published metadata every time it enters the room.++++## API (Chat)++++All routes are on `/api/chat/parties` and need the party's link, membership or the remote as noted.++++| Route | Who | What |++|---|---|---|++| `GET /:slug/music` | member or the link's `k` | `{ music, server_time_ms }`; `music` is null when the room is quiet |++| `POST /:slug/music/play` | the remote holder | `{ track_id? , station? }`; no `track_id` starts the Radio station, a `track_id` plays one track, `station: true` makes that track the station's start |++| `POST /:slug/music/pause` / `resume` | the remote holder | stop or continue the same timeline |++| `POST /:slug/music/next` | the remote holder | the station's next licensed track; a single track leaves the room quiet |++| `POST /:slug/music/ended` | the remote holder | `{ expected_id }`; the same as next, but idempotent for the track the listener just heard end |++| `POST /:slug/music/stop` | the remote holder | the room goes quiet |++++`music` is also in the party's own state (`GET /api/chat/parties/:slug`, and the page's `#party-data`).++++## The state++++```++{++ track: { id, title, artist, source, source_url, licence, licence_url, changes_note, ai_made, audio_url },++ station, started_by, started_by_name, started_at, started_at_ms,++ position_ms, at_ms, paused, up_next, seq++}++```++++`position_ms` is where the room was at `at_ms`; while `paused` is false it moves at rate 1. A late joiner computes++`position_ms + (server_now - at_ms)` and seeks there. `started_at` is when the current track began, `started_by` is++the OpenVibe subject that started it, and `seq` is the version counter (the same as `room_parties.music_version`).++++Changes are broadcast on the room socket as `{ type: 'music', room, state }`; a join also receives the current state.++Each browser keeps its volume in `localStorage` under `ov-party-volume` and never sends it to Chat.++++## Storage++++`migrations/0067_party_music.sql` adds `room_parties.music_state` (jsonb) and `music_version` (compare-and-set). The++module is `server/rooms/party-music.js`; the page code is in `public/web/party.js` (`paintMusic`, `applyMusic`).nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- Listening together in a party room (openvibe.party): any media or shared-computer party can play OpenVibe Radio++-- beside what it watches. The room's remote holder starts a licensed track or the Radio station; the state carries++-- the track, who started it, and the point its timeline was measured from, so a late joiner is at the right second.++--++-- music_state the current track (as OpenVibe Radio publishes it), the station flag, the queue of licensed track++-- ids the station will play next, the starter, and position_ms at at_ms (or paused at position_ms)++-- music_version a compare-and-set counter: two changes cannot overwrite each other, and the room hears one seq++--++-- A party that does not play music (agent-stream, radio, a desktop before anyone starts the Radio) leaves music_state++-- NULL. Expand only: two nullable/defaulted columns; an older release ignores them and its INSERT still fits.++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS music_state jsonb;++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS music_version integer NOT NULL DEFAULT 0;@@@ -216,3 +216,30 @@ html[data-ov-motion=reduced] .pt-float { animation: pt-fade 1.6s ease forwards;.pt-react-btn { width: 40px; height: 44px; font-size: 19px; }.pt-remote { flex: 1 1 100%; }}++++/* ── Listening together (openvibe.party): the room plays OpenVibe Radio beside the video or desktop ──++ Plain markup on purpose: layout, type and the player's face are the design pass's. */++.pt-music {++ order: 3; display: flex; flex-direction: column; gap: 10px; padding: 12px 14px;++ border: 1px solid var(--pt-line); border-radius: 14px; background: var(--pt-panel);++}++.pt-music[hidden] { display: none !important; }++.pt-music .pt-h { margin: 0; }++.pt-music-note { margin: 0; color: var(--pt-muted); font-size: 13.5px; line-height: 1.45; }++.pt-music-now { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; min-width: 0; }++.pt-music-copy { flex: 1 1 240px; min-width: 0; }++.pt-music-title { margin: 2px 0 0; font-size: 16px; line-height: 1.3; overflow-wrap: anywhere; }++.pt-music-by, .pt-music-proof { margin: 2px 0 0; color: var(--pt-muted); font-size: 13px; }++.pt-music-proof a { color: var(--pt-accent); }++.pt-music-audio { display: none; }++.pt-music-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }++.pt-music-volume { display: inline-flex; align-items: center; gap: 8px; color: var(--pt-muted); font-size: 13.5px; }++.pt-music-volume input { width: 140px; accent-color: var(--pt-accent); }++.pt-music-start { display: flex; gap: 8px; flex-wrap: wrap; }++.pt-music-start select {++ flex: 1 1 220px; min-width: 0; min-height: 40px; padding: 0 10px; border-radius: 9px;++ border: 1px solid var(--pt-line); background: var(--pt-bg); color: var(--pt-text); font: inherit;++}++@media (max-width: 900px) {++ .pt-music { margin: 0 12px; order: 3; }++}@@@ -23,7 +23,7 @@if (data && data.party) {var v = data.party;data = {-- slug: v.id, title: v.title, kind: v.kind, radio: v.radio || null, desktop: v.desktop || null, ended_at: v.ended_at || null, media: v.media, source_url: v.source_url, member: v.member,++ slug: v.id, title: v.title, kind: v.kind, radio: v.radio || null, music: v.music || null, desktop: v.desktop || null, ended_at: v.ended_at || null, media: v.media, source_url: v.source_url, member: v.member,server_time_ms: data.server_time_ms,host: { subject: v.host, name: v.host_name || null },members: (v.members || []).map(function (m) { return { subject: m.subject, name: m.name }; }),@@@ -35,7 +35,7 @@var slug = root.getAttribute('data-party') || data.slug;var region = function (name) { return root.querySelector('[data-party-' + name + ']'); };-- var stage = region('stage'), people = region('people'), chat = region('chat'), remote = region('remote'), invite = region('invite');++ var stage = region('stage'), people = region('people'), chat = region('chat'), remote = region('remote'), invite = region('invite'), musicBox = region('music');var REACTIONS = ['😂', '😮', '🔥', '❤️', '👏'];var DRIFT_SHOWN_MS = 1000;var self = data.self || null; // { subject, name } for a signed-in member@@@ -44,10 +44,11 @@(data.members || []).forEach(function (m) { if (m && m.subject) names[m.subject] = m.name || 'Someone'; });if (data.host && data.host.subject) names[data.host.subject] = data.host.name || names[data.host.subject] || 'The host';if (self && self.subject) names[self.subject] = self.name || names[self.subject] || 'You';-- var isRadio = data.kind === 'radio', isDesktop = data.kind === 'desktop', radio = data.radio || null, guestListening = false;++ var isRadio = data.kind === 'radio', isDesktop = data.kind === 'desktop', radio = data.radio || null, music = data.music || null, guestListening = false;var serverOffset = Number(data.server_time_ms) ? Number(data.server_time_ms) - Date.now() : 0;var bestClockRtt = Infinity;var catalogue = [];++ var musicAudio = null, musicNeedsTap = false, MUSIC_VOLUME_KEY = 'ov-party-volume', musicVolume = 0.8;var lease = data.lease || null, clock = data.clock || null, asked = null, everPlayed = !!(clock && (!clock.paused || clock.position_ms > 0));var away = {}; // subject -> when they left (their seat waits a minute)var ws = null, sync = null, delay = 1000, latest = 0, joined = false;@@@ -79,6 +80,25 @@// join, which comes straight back to this link (its key included). Nothing that would only fail is drawn: no remote,// no reactions, no composer, no count of who is watching, no socket (it answered "No such room").var guest = !token();++ // Each person's volume is this browser's, not the room's: remember it, and never send it.++ try {++ var savedVolume = localStorage.getItem(MUSIC_VOLUME_KEY);++ if (savedVolume !== null) {++ var savedNumber = Number(savedVolume);++ if (Number.isFinite(savedNumber) && savedNumber >= 0 && savedNumber <= 1) musicVolume = savedNumber;++ }++ } catch (e) { /* storage blocked */ }++ if (musicBox && !isRadio) {++ musicAudio = el('audio', 'pt-music-audio');++ musicAudio.preload = 'auto';++ musicAudio.setAttribute('playsinline', '');++ musicAudio.volume = musicVolume;++ musicAudio.addEventListener('ended', function () {++ if (music && music.track && !music.paused && iHold()) musicAction('ended', { expected_id: music.track.id });++ });++ musicAudio.addEventListener('loadedmetadata', applyMusic);++ musicAudio.addEventListener('canplay', applyMusic);++ }var joinHref = '/auth/login?next=' + encodeURIComponent(location.pathname + location.search);function joinLink(cls, text) { var a = el('a', cls, text); a.href = joinHref; return a; }if (guest) root.classList.add('is-guest');@@@ -177,6 +197,7 @@paintDesktopLayout();if (providerInfo) stage.appendChild(refusal);if (!guest) stage.appendChild(bar);++ if (musicBox) stage.appendChild(musicBox); // under the room's controls, not above its titleif (isRadio) {var radioBox = el('section', 'pt-radio');radioBox.setAttribute('aria-label', 'OpenVibe Radio queue');@@@ -338,6 +359,151 @@np.appendChild(text);}++ // ── listening together in the room ──────────────────────────────────────────++ // The room's remote starts OpenVibe Radio; Chat keeps the timeline and every page follows it. The volume slider is++ // this person's own (this browser's), and this page never sends it anywhere.++ function musicTargetMs() {++ if (!music || !music.track) return 0;++ var base = Math.max(0, Number(music.position_ms) || 0);++ if (!music.paused) base += Math.max(0, Date.now() + serverOffset - (Number(music.at_ms) || Date.now()));++ return base;++ }++ function timeLabel(iso) {++ var d = new Date(iso);++ return isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });++ }++ function applyMusic() {++ if (!musicAudio) return;++ if (!music || !music.track || !safeHref(music.track.audio_url)) {++ musicAudio.pause();++ musicAudio.removeAttribute('src');++ delete musicAudio.dataset.src;++ musicNeedsTap = false;++ return;++ }++ if (musicAudio.dataset.src !== music.track.audio_url) {++ musicAudio.dataset.src = music.track.audio_url;++ musicAudio.src = music.track.audio_url;++ }++ var target = musicTargetMs() / 1000;++ if (musicAudio.readyState >= 1 && Number.isFinite(musicAudio.duration) && musicAudio.duration > 0 && target > musicAudio.duration - 0.25)++ target = Math.max(0, musicAudio.duration - 0.25);++ if (musicAudio.readyState >= 1 && Math.abs(musicAudio.currentTime - target) > 1.5) {++ try { musicAudio.currentTime = target; } catch (e) { /* metadata is still loading */ }++ }++ if (music.paused) { musicAudio.pause(); musicNeedsTap = false; return; }++ var played = musicAudio.play();++ if (played && played.then) played.then(function () {++ if (musicNeedsTap) { musicNeedsTap = false; paintMusic(); }++ }, function () {++ // The browser wants a gesture before it plays sound: say so, do not pretend the room is audible.++ if (!musicNeedsTap) { musicNeedsTap = true; paintMusic(); }++ });++ else musicNeedsTap = false;++ }++ function musicAction(action, body) {++ var headers = { 'Content-Type': 'application/json' }, t = token();++ if (t) headers.Authorization = 'Bearer ' + t;++ fetch('/api/chat/parties/' + encodeURIComponent(slug) + '/music/' + action, {++ method: 'POST', headers: headers, credentials: 'same-origin', body: JSON.stringify(body || {}),++ }).then(function (r) {++ return r.json().then(function (v) { if (!r.ok) throw new Error(v.error || v.code || 'The room’s music did not change'); return v; });++ }).then(function (v) {++ music = v.music || null;++ applyMusic(); paintMusic();++ }).catch(function (e) { flash(e.message); });++ }++ function paintMusic() {++ if (!musicBox || isRadio) return;++ var control = !guest && !!(self && iHold());++ musicBox.textContent = '';++ if (!music || !music.track) {++ musicBox.hidden = !control;++ if (!control) return;++ musicBox.appendChild(el('h2', 'pt-h', 'Listen together'));++ musicBox.appendChild(el('p', 'pt-music-note', 'Play OpenVibe Radio in the room: its licensed catalogue, in step for everyone, with the volume your own.'));++ if (catalogue.length) {++ var form = el('form', 'pt-music-start');++ var pick = el('select');++ pick.setAttribute('aria-label', 'Choose a Radio track');++ var placeholder = el('option', null, 'Choose a Radio track'); placeholder.value = ''; pick.appendChild(placeholder);++ catalogue.forEach(function (t) { var option = el('option', null, t.title + ' · ' + t.artist); option.value = t.id; pick.appendChild(option); });++ var playTrack = el('button', 'pt-remote-btn', 'Play this track'); playTrack.type = 'submit';++ form.appendChild(pick); form.appendChild(playTrack);++ form.addEventListener('submit', function (event) {++ event.preventDefault();++ if (pick.value) musicAction('play', { track_id: pick.value });++ });++ musicBox.appendChild(form);++ var radioButton = el('button', 'pt-remote-btn is-quiet', 'Start OpenVibe Radio');++ radioButton.type = 'button';++ radioButton.addEventListener('click', function () { musicAction('play', { station: true }); });++ musicBox.appendChild(radioButton);++ } else {++ musicBox.appendChild(el('p', 'pt-music-note', 'OpenVibe Radio has no published tracks yet.'));++ }++ return;++ }++ musicBox.hidden = false;++ var nowBox = el('div', 'pt-music-now');++ var copy = el('div', 'pt-music-copy');++ copy.appendChild(el('p', 'pt-h', 'Playing in the room'));++ var title = el('p', 'pt-music-title');++ title.appendChild(el('strong', null, music.track.title));++ title.appendChild(document.createTextNode(' · ' + music.track.artist));++ copy.appendChild(title);++ var by = el('p', 'pt-music-by');++ var starter = music.started_by && self && music.started_by === self.subject ? 'you'++ : (music.started_by_name || (music.started_by ? nameOf(music.started_by) : 'someone'));++ by.appendChild(document.createTextNode((starter === 'you' ? 'Started by you' : 'Started by ' + starter) + (timeLabel(music.started_at) ? ' · since ' + timeLabel(music.started_at) : '')));++ copy.appendChild(by);++ var proof = el('p', 'pt-music-proof');++ proof.appendChild(outLink(licenceLabel(music.track.licence), music.track.licence_url));++ if (safeHref(music.track.source_url)) { proof.appendChild(document.createTextNode(' · ')); proof.appendChild(outLink('Original source', music.track.source_url)); }++ if (music.track.ai_made) proof.appendChild(document.createTextNode(' · AI-made music'));++ copy.appendChild(proof);++ nowBox.appendChild(copy);++ if (musicAudio) nowBox.appendChild(musicAudio);++ musicBox.appendChild(nowBox);++ var controls = el('div', 'pt-music-actions');++ var volume = el('label', 'pt-music-volume');++ volume.appendChild(document.createTextNode('Your volume'));++ var range = el('input');++ range.type = 'range'; range.min = '0'; range.max = '100'; range.step = '1';++ range.value = String(Math.round(musicVolume * 100));++ range.setAttribute('aria-label', 'Your volume');++ range.addEventListener('input', function () {++ musicVolume = Math.max(0, Math.min(1, Number(range.value) / 100));++ if (musicAudio) musicAudio.volume = musicVolume;++ try { localStorage.setItem(MUSIC_VOLUME_KEY, String(musicVolume)); } catch (e) { /* storage blocked */ }++ });++ volume.appendChild(range);++ controls.appendChild(volume);++ if (musicNeedsTap) {++ var tap = el('button', 'pt-remote-btn', 'Tap to listen');++ tap.type = 'button';++ tap.addEventListener('click', function () { musicNeedsTap = false; applyMusic(); paintMusic(); });++ controls.appendChild(tap);++ }++ if (control) {++ var toggle = el('button', 'pt-remote-btn is-quiet', music.paused ? 'Resume for everyone' : 'Pause for everyone');++ toggle.type = 'button';++ toggle.addEventListener('click', function () { musicAction(music.paused ? 'resume' : 'pause'); });++ controls.appendChild(toggle);++ if (music.station) {++ var next = el('button', 'pt-remote-btn is-quiet', 'Next track');++ next.type = 'button';++ next.addEventListener('click', function () { musicAction('next', { expected_id: music.track.id }); });++ controls.appendChild(next);++ }++ var stop = el('button', 'pt-remote-btn is-quiet', 'Stop the music');++ stop.type = 'button';++ stop.addEventListener('click', function () { musicAction('stop'); });++ controls.appendChild(stop);++ }++ musicBox.appendChild(controls);++ }++function paintRadio() {if (!isRadio || !radioBox) return;paintNowPlaying();@@@ -548,7 +714,12 @@if (guest) { paintVeil(); return; }if (!window.WebSocket) { paintVeil(); return; }ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');-- ws.onopen = function () { delay = 1000; send({ type: 'join_room', room: slug, token: token() }); paintVeil(); };++ ws.onopen = function () {++ delay = 1000;++ send({ type: 'join_room', room: slug, token: token() });++ send({ type: 'time', t0: Date.now(), id: 'music' });++ paintVeil();++ };ws.onmessage = function (ev) {var m;try { m = JSON.parse(ev.data); } catch (e) { return; }@@@ -558,7 +729,16 @@if (m.subject && !self) self = { subject: m.subject, name: names[m.subject] || 'You' };if (!isDesktop && !sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });catchUp();-- paintRadio();++ paintRadio(); paintMusic(); applyMusic();++ } else if (m.type === 'time' && Number.isFinite(Number(m.t_server)) && Number.isFinite(Number(m.t0))) {++ // NTP-style: the smallest round trip gives the best estimate of the server's clock, which is the++ // one the room's music timeline is written against.++ var rtt = Date.now() - Number(m.t0);++ if (rtt >= 0 && rtt < bestClockRtt) {++ bestClockRtt = rtt;++ serverOffset = Number(m.t_server) - (Number(m.t0) + Date.now()) / 2;++ applyMusic();++ }} else if (!isDesktop && m.type === 'sync' && m.state) {clock = m.state;if (!clock.paused || clock.position_ms > 0) everPlayed = true;@@@ -576,6 +756,10 @@before.forEach(function (s) { if ((lease.watchers || []).indexOf(s) < 0) away[s] = Date.now(); });(lease.watchers || []).forEach(function (s) { delete away[s]; });if (iHold() || holder().kind !== 'person' || (asked && holder().subject === asked)) asked = null;++ paintMusic();++ } else if (m.type === 'music') {++ music = m.state || null;++ applyMusic(); paintMusic();} else if (m.type === 'lease.request' && m.request && m.request.requester) {if (iHold()) asked = m.request.requester.subject;} else if (m.type === 'room_message' && m.message) {@@@ -609,6 +793,12 @@if (d > DRIFT_SHOWN_MS) providerClock(clock, false);}drift.hidden = !(clock && !clock.paused && d > DRIFT_SHOWN_MS);++ // The room's music is kept on its second too: seek only past 1.5 s, so ordinary playback never stutters.++ if (musicAudio && music && music.track && !music.paused && !musicAudio.paused && musicAudio.readyState >= 1) {++ var want = musicTargetMs() / 1000;++ if (Number.isFinite(musicAudio.duration) && musicAudio.duration > 0) want = Math.min(want, Math.max(0, musicAudio.duration - 0.25));++ if (Math.abs(musicAudio.currentTime - want) > 1.5) { try { musicAudio.currentTime = want; } catch (e) { /* metadata */ } }++ }// A seat waits a minute; after it, the person is simply not here.var now = Date.now(), changed = false;Object.keys(away).forEach(function (s) { if (now - away[s] > 60000) { delete away[s]; changed = true; } });@@@ -625,9 +815,35 @@if (t) headers.Authorization = 'Bearer ' + t;fetch('/api/chat/parties/' + encodeURIComponent(slug), { headers: headers, credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; })-- .then(function (v) { if (v && v.party) { data.desktop = v.party.desktop; data.ended_at = v.party.ended_at; paintDesktopLayout(); paintVeil(); } })++ .then(function (v) {++ if (!v || !v.party) return;++ data.desktop = v.party.desktop; data.ended_at = v.party.ended_at;++ if (v.party.music !== undefined) {++ var before = music && music.seq, after = v.party.music || null;++ music = after;++ if ((after && after.seq) !== before) { applyMusic(); paintMusic(); }++ }++ paintDesktopLayout(); paintVeil();++ }).catch(function () { /* the page keeps its last known state */ });}, 4000);++ if (guest && !isRadio && musicBox) {++ setInterval(function () {++ var key = null;++ try { key = new URLSearchParams(location.search).get('k') || localStorage.getItem('ov-party-key:' + slug); } catch (e) { /* storage blocked */ }++ var sentAt = Date.now();++ fetch('/api/chat/parties/' + encodeURIComponent(slug) + (key ? '?k=' + encodeURIComponent(key) : ''), { credentials: 'same-origin' })++ .then(function (r) { return r.ok ? r.json() : null; })++ .then(function (v) {++ if (!v || !v.party) return;++ var rtt = Date.now() - sentAt;++ if (Number(v.server_time_ms) && rtt < bestClockRtt) { bestClockRtt = rtt; serverOffset = Number(v.server_time_ms) - (sentAt + Date.now()) / 2; }++ var before = music && music.seq, after = v.party.music || null;++ music = after;++ if ((after && after.seq) !== before) { applyMusic(); paintMusic(); }++ }).catch(function () { /* retry next poll */ });++ }, 3000);++ }if (isRadio && guest) {setInterval(function () {var key = null;@@@ -646,8 +862,8 @@}).catch(function () { /* retry next poll */ });}, 3000);}-- if (isRadio && !guest) fetch('/api/chat/parties/radio/tracks').then(function (r) { return r.ok ? r.json() : null; })-- .then(function (v) { catalogue = v && v.tracks || []; paintRadio(); }).catch(function () { /* retry on refresh */ });++ if (!guest) fetch('/api/chat/parties/radio/tracks').then(function (r) { return r.ok ? r.json() : null; })++ .then(function (v) { catalogue = v && v.tracks || []; paintRadio(); paintMusic(); }).catch(function () { /* retry on refresh */ });// ── invite ───────────────────────────────────────────────────────────────────if (invite && !guest) {@@@ -669,6 +885,6 @@invite.appendChild(ib);}-- paintVeil(); paintRemote(); paintPeople(); paintRadio();++ paintVeil(); paintRemote(); paintPeople(); paintRadio(); paintMusic(); applyMusic();connect();})();@@@ -2915,6 +2915,15 @@ class ChatServer {} catch (err) {console.warn('[Sync] state:', err && err.message);}++ // A party room's music is the room's too: the joiner starts at the room's own second, not at zero.++ try {++ if (room.kind === 'party') {++ const state = await require('../rooms/party-music').stateForRoom(room);++ if (state !== undefined) this.sendTo(ws, { type: 'music', room: room.slug, state });++ }++ } catch (err) {++ console.warn('[Music] state:', err && err.message);++ }await this._broadcastLease(room);}@@@ -159,6 +159,11 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }}}if (kindOfParty(party) === 'radio') out.radio = await require('./radio').state(row, party, { user, key });++ // A watch party or a shared computer can play OpenVibe Radio beside what it watches: the room's own music++ // timeline (server/rooms/party-music.js), visible to every member and to a holder of the link's secret.++ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'desktop') {++ out.music = await require('./party-music').state(row, { now });++ }return out;}@@@ -257,7 +262,7 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l}async function bySlug(slug) {-- const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at, p.party_kind, p.media, p.source_url, p.radio_track, p.desktop_hosting, p.desktop_state, p.desktop_layout, p.desktop_node_id, p.desktop_share_id, p.desktop_share_url FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);++ const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at, p.party_kind, p.media, p.source_url, p.radio_track, p.music_state, p.music_version, p.desktop_hosting, p.desktop_state, p.desktop_layout, p.desktop_node_id, p.desktop_share_id, p.desktop_share_url FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);return row || null;}@@@ -268,6 +273,7 @@ function partyOf(row) {position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,join_secret_hash: row.join_secret_hash, ended_at: row.ended_at, created_at: row.party_created_at,party_kind: row.party_kind || 'agent_stream', media: row.media, source_url: row.source_url, radio_track: row.radio_track,++ music_state: row.music_state, music_version: row.music_version,desktop_hosting: row.desktop_hosting, desktop_state: row.desktop_state, desktop_layout: row.desktop_layout, desktop_node_id: row.desktop_node_id,desktop_share_id: row.desktop_share_id, desktop_share_url: row.desktop_share_url,} : null;@@@ -341,7 +347,7 @@ async function count(agentStreamId) {async function end(row, party, user) {if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host ends the party');-- await db.run("UPDATE room_parties SET ended_at = now(), playing = false, desktop_state = CASE WHEN party_kind = 'desktop' THEN 'stopped' ELSE desktop_state END WHERE room_id = ? AND ended_at IS NULL", [party.room_id]);++ await db.run("UPDATE room_parties SET ended_at = now(), playing = false, music_state = NULL, music_version = music_version + 1, desktop_state = CASE WHEN party_kind = 'desktop' THEN 'stopped' ELSE desktop_state END WHERE room_id = ? AND ended_at IS NULL", [party.room_id]);if (party.party_kind === 'desktop') await pool.releaseRoom(party.room_id, 'ended');const fresh = await bySlug(row.slug);return view(fresh, partyOf(fresh), { where: await pool.where(party.room_id) });nnew file mode 100644@@@ -0,0 +1,201 @@++'use strict';++/**++ * Listening together in a party room (openvibe.party): the room's music, beside the video or the shared desktop.++ *++ * The room's remote (common.control-lease@1) is the right to change it: whoever holds the control lease starts an++ * OpenVibe Radio track, or the Radio station (a continuous run of the licensed catalogue), and pauses, resumes, skips++ * or stops it. The room's state carries the track, who started it and the point the timeline was measured from:++ *++ * position_ms at at_ms, then paused or moving at rate 1. A late joiner computes where everyone should be from those++ * two numbers and the Chat server's clock, so the room hears the same thing within a second or two. Each person's++ * volume is their own: it is not part of this state and never leaves their browser.++ *++ * Only OpenVibe Radio's own catalogue is playable (server/rooms/radio.js): every track is validated for its licence,++ * its official audio URL and its published metadata before it can reach a room. Nothing here accepts a free URL.++ *++ * Durable state is room_parties.music_state (jsonb) with music_version as a compare-and-set counter; changes are++ * broadcast to the room socket as { type: 'music', room, state } and a join also receives it (chat-server.sendRoomState).++ * The live audio is the browser's: Chat keeps the timeline and makes sure everyone follows it.++ */++const db = require('../db/database');++const rooms = require('./rooms');++const parties = require('./parties');++const controlLease = require('./control-lease');++const radio = require('./radio');++++const KINDS = new Set(['media', 'desktop']);++const MAX_QUEUE = 200;++const fail = (status, code, message) => { throw new parties.PartyError(status, code, message); };++++function decode(value) {++ if (value == null) return null;++ if (typeof value !== 'string') return value;++ try { return JSON.parse(value); } catch { return null; }++}++++/** The room's remote holder, as the clock's holder is: a signed-in member with the control lease. */++async function holderOf(row, user) {++ if (!user || !user.subject_id) fail(401, 'music.sign_in', 'Sign in to change the room’s music');++ const access = await rooms.access(row, user);++ if (!access.read) fail(404, 'rooms.not_found', 'No such room');++ const lease = await controlLease.state({ id: row.id, slug: row.slug });++ const holder = lease && lease.holder;++ if (!holder || holder.kind !== 'person' || holder.subject !== user.subject_id) {++ fail(403, 'music.not_holder', 'Only the person with the remote can change the room’s music');++ }++}++++/** The room's stored timeline as it stands at `now`: position is base + moving time while it is not paused. */++function positionAt(stored, now) {++ const base = Math.max(0, Number(stored && stored.position_ms) || 0);++ if (!stored || stored.paused) return base;++ return base + Math.max(0, now - (Number(stored.at_ms) || now));++}++++/** The state a viewer, a socket or an SDK sees: the timeline measured at `at_ms`, never a stale stored number. */++async function wire(stored, { now = Date.now(), version = 0 } = {}) {++ if (!stored || !stored.track) return null;++ const startedBy = stored.started_by == null ? null : String(stored.started_by);++ let name = null;++ if (startedBy) {++ const who = await db.get('SELECT username, display_name FROM ctx_users WHERE subject_id = ? LIMIT 1', [startedBy]);++ name = who ? (who.display_name || who.username || null) : null;++ }++ return {++ track: stored.track,++ station: !!stored.station,++ started_by: startedBy,++ started_by_name: name,++ started_at: new Date(Number(stored.started_at) || now).toISOString(),++ started_at_ms: Number(stored.started_at) || now,++ position_ms: positionAt(stored, now),++ at_ms: now,++ paused: !!stored.paused,++ up_next: Array.isArray(stored.queue) ? stored.queue.length : 0,++ seq: Number(version) || 0,++ };++}++++/** A party's music as its detail view carries it: null when nothing plays, from the row bySlug already read. */++async function state(row, { now = Date.now() } = {}) {++ if (!row || !KINDS.has(row.party_kind)) return null;++ return await wire(decode(row.music_state), { now, version: row.music_version });++}++++/** A socket's join: the room's music if this is a kind that plays it, undefined for every other room. */++async function stateForRoom(room, { now = Date.now() } = {}) {++ if (!room || !room.id) return undefined;++ const row = await db.get('SELECT party_kind, ended_at, music_state, music_version FROM room_parties WHERE room_id = ?', [room.id]);++ if (!row || !KINDS.has(row.party_kind)) return undefined;++ if (row.ended_at) return null;++ return await wire(decode(row.music_state), { now, version: row.music_version });++}++++function fresh(track, { station = false, queue = [], startedBy = null, now = Date.now() } = {}) {++ return {++ track, station: !!station, queue: (queue || []).slice(0, MAX_QUEUE),++ started_by: startedBy, started_at: now, position_ms: 0, at_ms: now, paused: false,++ };++}++++/** The first track of a list that still validates, with the rest as the station's queue; null when none is left. */++async function firstPlayable(ids) {++ const left = (ids || []).slice();++ while (left.length) {++ const id = String(left.shift());++ try {++ return { track: await radio.track(id), queue: left.slice(0, MAX_QUEUE) };++ } catch (err) {++ // A track unpublished between the catalogue and now is skipped; a Radio outage is not.++ if (!(err instanceof parties.PartyError) || err.status >= 500) throw err;++ }++ }++ return null;++}++++/**++ * The next track of a station: the stored queue first (each one validated again), then a fresh catalogue pass so a++ * station that has played the whole catalogue once keeps going. A single track has no next.++ */++async function pickNext(stored) {++ if (!stored || !stored.station) return null;++ const found = await firstPlayable(stored.queue || []);++ if (found) return found;++ const catalogue = await radio.catalogue();++ const current = stored.track && stored.track.id;++ const ids = catalogue.map((t) => t.id).filter((id) => id !== current);++ return await firstPlayable(ids);++}++++/** One change, behind the version counter: the winner writes and returns, a loser re-reads and tries again. */++async function change(row, party, user, action, input = {}, { now = Date.now() } = {}) {++ if (!row || !party) fail(404, 'rooms.not_found', 'No such room');++ if (!KINDS.has(party.party_kind)) fail(409, 'music.party', 'Music plays in a watch party or a shared computer');++ if (party.ended_at) fail(410, 'party.ended', 'This party has ended');++ await holderOf(row, user);++ for (let tries = 0; tries < 5; tries++) {++ const current = await db.get('SELECT music_state, music_version FROM room_parties WHERE room_id = ?', [party.room_id]);++ if (!current) fail(404, 'rooms.not_found', 'No such room');++ const stored = decode(current.music_state);++ const version = Number(current.music_version) || 0;++ let next = stored;++ if (action === 'play') {++ const asked = input && input.track_id != null ? String(input.track_id) : '';++ const station = !asked || (input && input.station === true);++ if (asked) {++ const track = await radio.track(asked);++ const queue = station ? (await radio.catalogue()).map((t) => t.id).filter((id) => id !== track.id) : [];++ next = fresh(track, { station, queue, startedBy: user.subject_id, now });++ } else {++ const catalogue = await radio.catalogue();++ const first = await firstPlayable(catalogue.map((t) => t.id));++ if (!first) fail(503, 'music.unavailable', 'OpenVibe Radio has no published tracks to play yet');++ next = fresh(first.track, { station: true, queue: first.queue, startedBy: user.subject_id, now });++ }++ } else if (action === 'pause' || action === 'resume') {++ if (!stored || !stored.track) fail(409, 'music.none', 'Nothing is playing in the room');++ if (action === 'pause' ? stored.paused : !stored.paused) {++ // Already there: answer the same state rather than invent a new version.++ const same = await wire(stored, { now, version });++ return same;++ }++ next = { ...stored, at_ms: now, paused: action === 'pause' };++ if (action === 'pause') next.position_ms = positionAt(stored, now);++ } else if (action === 'stop') {++ next = null;++ } else if (action === 'next' || action === 'ended') {++ if (!stored || !stored.track) {++ if (action === 'next') fail(409, 'music.none', 'Nothing is playing in the room');++ next = null;++ } else if (input && input.expected_id && input.expected_id !== stored.track.id) {++ // A late or repeated end for a track the room already moved past: answer the current state.++ const same = await wire(stored, { now, version });++ return same;++ } else {++ const following = await pickNext(stored);++ next = following ? fresh(following.track, {++ station: true, queue: following.queue,++ // An auto-advance stays the station's starter; a person skipping names themselves.++ startedBy: action === 'ended' ? stored.started_by : user.subject_id, now,++ }) : null;++ }++ } else {++ fail(422, 'music.action', 'Unknown music action');++ }++ const updated = await db.get(`UPDATE room_parties++ SET music_state = ?, music_version = music_version + 1++ WHERE room_id = ? AND music_version = ? RETURNING music_version`,++ [next ? JSON.stringify(next) : null, party.room_id, version]);++ if (!updated) continue;++ const out = await wire(next, { now, version: Number(updated.music_version) });++ try {++ require('../chat/chat-server').broadcastToRoom(row.id, { type: 'music', room: row.slug, state: out });++ } catch (err) {++ console.warn('[Music] broadcast:', err && err.message);++ }++ return out;++ }++ fail(409, 'music.conflict', 'The room’s music changed; try again');++}++++module.exports = { KINDS, state, stateForRoom, change };@@@ -18,6 +18,7 @@ const { isOwner } = require('../auth/permissions');const { limits } = require('../net/actor-limits');const parties = require('../rooms/parties');const radio = require('../rooms/radio');++const music = require('../rooms/party-music');const desktop = require('../rooms/party-desktop');const partyPool = require('../rooms/party-pool');@@@ -25,6 +26,8 @@ const router = express.Router();// Starting a party: the same budget as making a room (owners keep at most MAX_ROOMS_PER_OWNER).const createLimit = limits('chat.party.create', { minute: 10, hour: 60 });++// Music changes are quick controls (play, pause, next): a generous budget, separate from starting a party.++const musicLimit = limits('chat.party.music', { minute: 60, hour: 300 });// The owner's pool panel is a write surface; the person's own pool budget is in party-pool.js (maxPerActor).const poolLimit = limits('chat.party.pool', { minute: 30, hour: 120 });@@@ -157,6 +160,28 @@ router.post('/:slug/desktop/layout', requireAuth, express.json({ limit: '1kb' })} catch (err) { send(res, err); }});++// ── Listening together in a watch party or shared computer ──────────────────────────────────────────────────────++// The room's remote holder plays OpenVibe Radio beside the video or desktop: a licensed track, or the Radio station.++// The state carries the track, who started it, and position_ms at at_ms; a joiner gets it from the party and the++// socket (sendRoomState). Volume is each person's own and never reaches this API.++router.get('/:slug/music', optionalAuth, async (req, res) => {++ try {++ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);++ if (!row || !party || !await parties.mayView(row, party, req.user, req.query.k)) return res.status(404).json({ code: 'rooms.not_found' });++ const out = await music.state(row);++ res.set('Cache-Control', 'private, no-store').json({ music: out, server_time_ms: Date.now() });++ } catch (err) { send(res, err); }++});++++router.post('/:slug/music/:action', requireAuth, musicLimit, async (req, res) => {++ try {++ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);++ if (!row || !party || !await parties.mayView(row, party, req.user, null)) return res.status(404).json({ code: 'rooms.not_found' });++ const out = await music.change(row, party, req.user, String(req.params.action || ''), req.body || {});++ res.set('Cache-Control', 'private, no-store').json({ music: out, server_time_ms: Date.now() });++ } catch (err) { send(res, err); }++});++router.get('/:slug/radio', optionalAuth, async (req, res) => {try {const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);@@@ -418,7 +418,7 @@ ${holdRows}mainAttrs: `data-party="${esc(out.id)}"`, party: true, wide: true,body: `<article class="oc-party${out.kind === 'radio' ? ' is-radio' : ''}"><header class="oc-party-head"><h1>${esc(out.title)}</h1><p class="oc-muted">Started by ${esc(out.host_name || 'someone')}</p>${out.kind === 'desktop' && out.desktop && out.desktop.where ? `<p class="oc-party-where" data-party-where>${esc(out.desktop.where.line)}</p>` : ''}</header>--<div class="oc-party-stage" data-party-stage aria-label="${out.kind === 'radio' ? 'The music stage' : 'The video'}"></div>++<div class="oc-party-stage" data-party-stage aria-label="${out.kind === 'radio' ? 'The music stage' : 'The video'}">${out.kind === 'radio' ? '' : '<div class="oc-party-music" data-party-music aria-label="Music"></div>'}</div><div class="oc-party-people" data-party-people aria-label="People here"></div><div class="oc-party-chat" data-party-chat aria-label="Chat"></div><div class="oc-party-remote" data-party-remote aria-label="The remote"></div>nnew file mode 100644@@@ -0,0 +1,195 @@++'use strict';++/**++ * Listening together in a party room (openvibe.party): the room's remote holder starts OpenVibe Radio in a shared++ * computer or a watch party, the room's state carries the track, who started it and the second the timeline was++ * measured from, a late joiner follows at that second, and the change reaches the room's socket.++ *++ * The Radio catalogue is a local stand-in, exactly as Radio publishes it; no audio is fetched here.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('party music');++const T1 = 'rad_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const T2 = 'rad_01JAB2C3D4E5F6G7H8J9K0MNRR';++const AUDIO1 = `https://openvibe.download/api/v1/radio/tracks/${T1}/audio`;++const AUDIO2 = `https://openvibe.download/api/v1/radio/tracks/${T2}/audio`;++const track = (id, title, artist, audio) => ({ id, title, artist, source: 'creator', source_url: null,++ licence: 'creator_grant', licence_url: 'https://openvibe.download/radio/licence', ai_made: false, audio_url: audio });++const TRACKS = [track(T1, 'Friday Song', 'Ann', AUDIO1), track(T2, 'Saturday Song', 'Bob', AUDIO2)];++let service, h, ann, bob, cat, party, key;++++const api = (method, path, u, body) => h.http(method, `/api/chat/parties${path}`, { token: u && u.token, body });++/** The party host page: its own Host header, so the Host-based party router answers. */++function raw(method, path, { cookie = null } = {}) {++ return new Promise((resolve, reject) => {++ const req = http.request({ host: '127.0.0.1', port: h.port, path, method,++ headers: { host: 'openvibe.party', ...(cookie ? { cookie } : {}) } }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text }));++ });++ req.on('error', reject);++ req.end();++ });++}++const dataOf = (html) => {++ const m = /<script type="application\/json" id="party-data">([\s\S]*?)<\/script>/.exec(html);++ assert.ok(m, 'the page carries its party data');++ return JSON.parse(m[1]);++};++++t('boot with the Radio catalogue', async () => {++ service = http.createServer((req, res) => {++ res.setHeader('content-type', 'application/json');++ if (req.url === '/api/v1/radio/tracks') return res.end(JSON.stringify({ tracks: TRACKS }));++ const one = /^\/api\/v1\/radio\/tracks\/([^/]+)$/.exec(req.url || '');++ if (one) {++ const found = TRACKS.find((x) => x.id === one[1]);++ if (found) return res.end(JSON.stringify({ track: found }));++ }++ res.writeHead(404); res.end('{}');++ });++ await new Promise((r) => service.listen(0, '127.0.0.1', r));++ process.env.CHAT_RADIO_URL = `http://127.0.0.1:${service.address().port}`;++ h = await boot({ env: { CHAT_PARTY_URL: 'https://openvibe.party' } });++ ann = h.addUser('ann', { subject: ids.newId('user') });++ bob = h.addUser('bob', { subject: ids.newId('user') });++ cat = h.addUser('cat', { subject: ids.newId('user') });++ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));++});++++t('a shared computer starts empty, and only the room’s remote holder can start its music', async () => {++ const made = await api('POST', '/', ann, { desktop_hosting: 'link', title: 'Music room' });++ assert.strictEqual(made.status, 201, made.text);++ party = made.body.party;++ key = made.body.join_secret;++ assert.strictEqual(party.kind, 'desktop');++++ const before = await api('GET', `/${party.id}`, ann);++ assert.strictEqual(before.status, 200, before.text);++ assert.strictEqual(before.body.party.music, null, 'nothing plays until someone starts it');++++ // Bob joins by the link; he is a member but does not hold the remote.++ const joined = await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: bob.token, body: { k: key } });++ assert.strictEqual(joined.status, 200, joined.text);++ const denied = await api('POST', `/${party.id}/music/play`, bob, { station: true });++ assert.strictEqual(denied.status, 403, denied.text);++ assert.strictEqual(denied.body.code, 'music.not_holder');++++ const started = await api('POST', `/${party.id}/music/play`, ann, { station: true, volume: 0.1 });++ assert.strictEqual(started.status, 200, started.text);++ const music = started.body.music;++ assert.strictEqual(music.track.id, T1, 'the station starts with the first published track');++ assert.strictEqual(music.station, true);++ assert.strictEqual(music.paused, false);++ assert.strictEqual(music.started_by, ann.subject_id);++ assert.strictEqual(music.started_by_name, 'Ann', 'who started it is in the room state');++ assert.ok(music.started_at && Number.isFinite(Date.parse(music.started_at)));++ assert.ok(music.position_ms >= 0 && music.position_ms < 1000, 'the clock starts at the beginning');++ assert.strictEqual(music.up_next, 1, 'the station queued the other licensed track');++ assert.strictEqual(music.volume, undefined, 'volume is each person’s own, never the room’s');++++ const seen = await api('GET', `/${party.id}`, bob);++ assert.strictEqual(seen.body.party.music.track.id, T1, 'every member sees what is playing');++ assert.strictEqual(seen.body.party.music.started_by_name, 'Ann');++ const guest = await api('GET', `/${party.id}?k=${encodeURIComponent(key)}`, null);++ assert.strictEqual(guest.status, 200);++ assert.strictEqual(guest.body.party.music.track.id, T1, 'the link’s holder sees it too');++});++++t('the room hears every change, and a late joiner starts at the room’s second', async () => {++ const host = await h.ws({ token: ann.token, origin: 'https://openvibe.party' });++ host.sendJson({ type: 'join_room', room: party.id });++ await host.next((m) => m.type === 'room_joined');++ const listener = await h.ws({ token: bob.token, origin: 'https://openvibe.party' });++ listener.sendJson({ type: 'join_room', room: party.id });++ await listener.next((m) => m.type === 'room_joined');++ // The join itself carries the room's music: nothing has to change for a late joiner to hear it.++ const atJoin = await listener.next((m) => m.type === 'music');++ assert.strictEqual(atJoin.state.track.id, T1);++++ const paused = await api('POST', `/${party.id}/music/pause`, ann, {});++ assert.strictEqual(paused.status, 200, paused.text);++ assert.strictEqual(paused.body.music.paused, true);++ const heard = await listener.next((m) => m.type === 'music');++ assert.strictEqual(heard.state.paused, true, 'the room socket hears the pause');++++ // Put the timeline five seconds back in time (a room that started before this joiner arrived), then join late.++ const music = require('../server/rooms/party-music');++ const parties = require('../server/rooms/parties');++ const row = await parties.bySlug(party.id);++ await music.change(row, parties.partyOf(row), ann, 'play', { track_id: T1 }, { now: Date.now() - 5000 });++ const late = await api('GET', `/${party.id}`, bob);++ const lateMusic = late.body.party.music;++ assert.ok(lateMusic.position_ms >= 5000 && lateMusic.position_ms < 8000, `late position ${lateMusic.position_ms}`);++ assert.ok(Math.abs(Date.parse(lateMusic.started_at) - (Date.now() - 5000)) < 2000, 'the state says from when');++++ // A new socket gets the same timeline on join.++ await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: cat.token, body: { k: key } });++ const lateSocket = await h.ws({ token: cat.token, origin: 'https://openvibe.party' });++ lateSocket.sendJson({ type: 'join_room', room: party.id });++ await lateSocket.next((m) => m.type === 'room_joined');++ const lateHeard = await lateSocket.next((m) => m.type === 'music');++ assert.strictEqual(lateHeard.state.track.id, T1);++ assert.ok(lateHeard.state.position_ms >= 5000, `socket late position ${lateHeard.state.position_ms}`);++ host.close(); listener.close(); lateSocket.close();++});++++t('only a published, licensed Radio track can enter the room; the station advances and stops', async () => {++ const bad = await api('POST', `/${party.id}/music/play`, ann, { track_id: 'https://example.org/song.mp3' });++ assert.strictEqual(bad.status, 422, bad.text);++ assert.strictEqual(bad.body.code, 'radio.track');++ const unpublished = await api('POST', `/${party.id}/music/play`, ann, { track_id: 'rad_01JAB2C3D4E5F6G7H8J9K0MZZZ' });++ assert.strictEqual(unpublished.status, 422);++ assert.strictEqual(unpublished.body.code, 'radio.track');++++ const started = await api('POST', `/${party.id}/music/play`, ann, { track_id: T1, station: true });++ assert.strictEqual(started.status, 200, started.text);++ assert.strictEqual(started.body.music.track.id, T1);++ const next = await api('POST', `/${party.id}/music/next`, ann, { expected_id: T1 });++ assert.strictEqual(next.status, 200, next.text);++ assert.strictEqual(next.body.music.track.id, T2, 'the station moves to the next licensed track');++ assert.ok(next.body.music.position_ms < 1000, 'the next track starts at zero');++ assert.strictEqual(next.body.music.started_by, ann.subject_id);++++ const ended = await api('POST', `/${party.id}/music/ended`, ann, { expected_id: 'rad_01JAB2C3D4E5F6G7H8J9K0MZZZ' });++ assert.strictEqual(ended.status, 200, ended.text);++ assert.strictEqual(ended.body.music.track.id, T2, 'a repeated end for another track changes nothing');++++ const stopped = await api('POST', `/${party.id}/music/stop`, ann, {});++ assert.strictEqual(stopped.status, 200, stopped.text);++ assert.strictEqual(stopped.body.music, null);++ assert.strictEqual((await api('GET', `/${party.id}`, ann)).body.party.music, null);++});++++t('a single track ends at the end, and the page carries the room’s music and its control', async () => {++ const started = await api('POST', `/${party.id}/music/play`, ann, { track_id: T2 });++ assert.strictEqual(started.status, 200, started.text);++ assert.strictEqual(started.body.music.station, false);++ const page = await raw('GET', `/p/${party.id}`, { cookie: `ov_token=${ann.token}` });++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));++ assert.ok(page.text.includes('data-party-music'), 'the page has the music control’s region');++ const pageData = dataOf(page.text);++ assert.strictEqual(pageData.party.music.track.id, T2);++ assert.strictEqual(pageData.party.music.started_by, ann.subject_id);++ assert.strictEqual(pageData.party.music.started_by_name, 'Ann');++ // The link's holder can hear the room without an account: the page still carries the now-playing state.++ const guestPage = await raw('GET', `/p/${party.id}?k=${encodeURIComponent(key)}`);++ assert.strictEqual(guestPage.status, 200, guestPage.text.slice(0, 300));++ assert.ok(guestPage.text.includes('data-party-music'));++ assert.strictEqual(dataOf(guestPage.text).party.music.track.id, T2);++++ const ended = await api('POST', `/${party.id}/music/ended`, ann, { expected_id: T2 });++ assert.strictEqual(ended.status, 200, ended.text);++ assert.strictEqual(ended.body.music, null, 'a single track leaves the room quiet when it ends');++});++++t('close', async () => {++ await h.close();++ await new Promise((r) => service.close(r));++ delete process.env.CHAT_RADIO_URL;++});++t.run();
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -0,0 +1,46 @@
+# Listening together in a party room
+
+A watch party or a shared computer on openvibe.party can play **OpenVibe Radio** beside what it watches. The room's
+control lease (the remote) is the right to change it: whoever holds the remote starts a licensed track or the Radio
+station, pauses, resumes, skips and stops it. Everyone else follows the same timeline; each person's volume is their
+own browser setting.
+
+Only `https://openvibe.download`'s published Radio catalogue is playable. Nothing here accepts a free URL, and a
+track is validated for its licence, its official audio URL and its published metadata every time it enters the room.
+
+## API (Chat)
+
+All routes are on `/api/chat/parties` and need the party's link, membership or the remote as noted.
+
+| Route | Who | What |
+|---|---|---|
+| `GET /:slug/music` | member or the link's `k` | `{ music, server_time_ms }`; `music` is null when the room is quiet |
+| `POST /:slug/music/play` | the remote holder | `{ track_id? , station? }`; no `track_id` starts the Radio station, a `track_id` plays one track, `station: true` makes that track the station's start |
+| `POST /:slug/music/pause` / `resume` | the remote holder | stop or continue the same timeline |
+| `POST /:slug/music/next` | the remote holder | the station's next licensed track; a single track leaves the room quiet |
+| `POST /:slug/music/ended` | the remote holder | `{ expected_id }`; the same as next, but idempotent for the track the listener just heard end |
+| `POST /:slug/music/stop` | the remote holder | the room goes quiet |
+
+`music` is also in the party's own state (`GET /api/chat/parties/:slug`, and the page's `#party-data`).
+
+## The state
+
+```
+{
+ track: { id, title, artist, source, source_url, licence, licence_url, changes_note, ai_made, audio_url },
+ station, started_by, started_by_name, started_at, started_at_ms,
+ position_ms, at_ms, paused, up_next, seq
+}
+```
+
+`position_ms` is where the room was at `at_ms`; while `paused` is false it moves at rate 1. A late joiner computes
+`position_ms + (server_now - at_ms)` and seeks there. `started_at` is when the current track began, `started_by` is
+the OpenVibe subject that started it, and `seq` is the version counter (the same as `room_parties.music_version`).
+
+Changes are broadcast on the room socket as `{ type: 'music', room, state }`; a join also receives the current state.
+Each browser keeps its volume in `localStorage` under `ov-party-volume` and never sends it to Chat.
+
+## Storage
+
+`migrations/0067_party_music.sql` adds `room_parties.music_state` (jsonb) and `music_version` (compare-and-set). The
+module is `server/rooms/party-music.js`; the page code is in `public/web/party.js` (`paintMusic`, `applyMusic`).
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- Listening together in a party room (openvibe.party): any media or shared-computer party can play OpenVibe Radio
+-- beside what it watches. The room's remote holder starts a licensed track or the Radio station; the state carries
+-- the track, who started it, and the point its timeline was measured from, so a late joiner is at the right second.
+--
+-- music_state the current track (as OpenVibe Radio publishes it), the station flag, the queue of licensed track
+-- ids the station will play next, the starter, and position_ms at at_ms (or paused at position_ms)
+-- music_version a compare-and-set counter: two changes cannot overwrite each other, and the room hears one seq
+--
+-- A party that does not play music (agent-stream, radio, a desktop before anyone starts the Radio) leaves music_state
+-- NULL. Expand only: two nullable/defaulted columns; an older release ignores them and its INSERT still fits.
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS music_state jsonb;
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS music_version integer NOT NULL DEFAULT 0;
@@ -216,3 +216,30 @@ html[data-ov-motion=reduced] .pt-float { animation: pt-fade 1.6s ease forwards;
@@ -216,3 +216,30 @@ html[data-ov-motion=reduced] .pt-float { animation: pt-fade 1.6s ease forwards;
.pt-react-btn { width: 40px; height: 44px; font-size: 19px; }
.pt-react-btn { width: 40px; height: 44px; font-size: 19px; }
.pt-remote { flex: 1 1 100%; }
.pt-remote { flex: 1 1 100%; }
}
}
+
+/* ── Listening together (openvibe.party): the room plays OpenVibe Radio beside the video or desktop ──
+ Plain markup on purpose: layout, type and the player's face are the design pass's. */
+.pt-music {
+ order: 3; display: flex; flex-direction: column; gap: 10px; padding: 12px 14px;
+ border: 1px solid var(--pt-line); border-radius: 14px; background: var(--pt-panel);
+}
+.pt-music[hidden] { display: none !important; }
+.pt-music .pt-h { margin: 0; }
+.pt-music-note { margin: 0; color: var(--pt-muted); font-size: 13.5px; line-height: 1.45; }
+.pt-music-now { display: flex; gap: 12px; align-items: flex-start; flex-wrap: wrap; min-width: 0; }
+.pt-music-copy { flex: 1 1 240px; min-width: 0; }
+.pt-music-title { margin: 2px 0 0; font-size: 16px; line-height: 1.3; overflow-wrap: anywhere; }
+.pt-music-by, .pt-music-proof { margin: 2px 0 0; color: var(--pt-muted); font-size: 13px; }
+.pt-music-proof a { color: var(--pt-accent); }
+.pt-music-audio { display: none; }
+.pt-music-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
+.pt-music-volume { display: inline-flex; align-items: center; gap: 8px; color: var(--pt-muted); font-size: 13.5px; }
+.pt-music-volume input { width: 140px; accent-color: var(--pt-accent); }
+.pt-music-start { display: flex; gap: 8px; flex-wrap: wrap; }
+.pt-music-start select {
+ flex: 1 1 220px; min-width: 0; min-height: 40px; padding: 0 10px; border-radius: 9px;
+ border: 1px solid var(--pt-line); background: var(--pt-bg); color: var(--pt-text); font: inherit;
+}
+@media (max-width: 900px) {
+ .pt-music { margin: 0 12px; order: 3; }
+}
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
if (data && data.party) {
if (data && data.party) {
var v = data.party;
var v = data.party;
data = {
data = {
- slug: v.id, title: v.title, kind: v.kind, radio: v.radio || null, desktop: v.desktop || null, ended_at: v.ended_at || null, media: v.media, source_url: v.source_url, member: v.member,
+ slug: v.id, title: v.title, kind: v.kind, radio: v.radio || null, music: v.music || null, desktop: v.desktop || null, ended_at: v.ended_at || null, media: v.media, source_url: v.source_url, member: v.member,
server_time_ms: data.server_time_ms,
server_time_ms: data.server_time_ms,
host: { subject: v.host, name: v.host_name || null },
host: { subject: v.host, name: v.host_name || null },
members: (v.members || []).map(function (m) { return { subject: m.subject, name: m.name }; }),
members: (v.members || []).map(function (m) { return { subject: m.subject, name: m.name }; }),
@@ -35,7 +35,7 @@
@@ -35,7 +35,7 @@
var slug = root.getAttribute('data-party') || data.slug;
var slug = root.getAttribute('data-party') || data.slug;
var region = function (name) { return root.querySelector('[data-party-' + name + ']'); };
var region = function (name) { return root.querySelector('[data-party-' + name + ']'); };
- var stage = region('stage'), people = region('people'), chat = region('chat'), remote = region('remote'), invite = region('invite');
+ var stage = region('stage'), people = region('people'), chat = region('chat'), remote = region('remote'), invite = region('invite'), musicBox = region('music');
var REACTIONS = ['😂', '😮', '🔥', '❤️', '👏'];
var REACTIONS = ['😂', '😮', '🔥', '❤️', '👏'];
var DRIFT_SHOWN_MS = 1000;
var DRIFT_SHOWN_MS = 1000;
var self = data.self || null; // { subject, name } for a signed-in member
var self = data.self || null; // { subject, name } for a signed-in member
@@ -44,10 +44,11 @@
@@ -44,10 +44,11 @@
(data.members || []).forEach(function (m) { if (m && m.subject) names[m.subject] = m.name || 'Someone'; });
(data.members || []).forEach(function (m) { if (m && m.subject) names[m.subject] = m.name || 'Someone'; });
if (data.host && data.host.subject) names[data.host.subject] = data.host.name || names[data.host.subject] || 'The host';
if (data.host && data.host.subject) names[data.host.subject] = data.host.name || names[data.host.subject] || 'The host';
if (self && self.subject) names[self.subject] = self.name || names[self.subject] || 'You';
if (self && self.subject) names[self.subject] = self.name || names[self.subject] || 'You';
- var isRadio = data.kind === 'radio', isDesktop = data.kind === 'desktop', radio = data.radio || null, guestListening = false;
+ var isRadio = data.kind === 'radio', isDesktop = data.kind === 'desktop', radio = data.radio || null, music = data.music || null, guestListening = false;
var serverOffset = Number(data.server_time_ms) ? Number(data.server_time_ms) - Date.now() : 0;
var serverOffset = Number(data.server_time_ms) ? Number(data.server_time_ms) - Date.now() : 0;
var bestClockRtt = Infinity;
var bestClockRtt = Infinity;
var catalogue = [];
var catalogue = [];
+ var musicAudio = null, musicNeedsTap = false, MUSIC_VOLUME_KEY = 'ov-party-volume', musicVolume = 0.8;
var lease = data.lease || null, clock = data.clock || null, asked = null, everPlayed = !!(clock && (!clock.paused || clock.position_ms > 0));
var lease = data.lease || null, clock = data.clock || null, asked = null, everPlayed = !!(clock && (!clock.paused || clock.position_ms > 0));
var away = {}; // subject -> when they left (their seat waits a minute)
var away = {}; // subject -> when they left (their seat waits a minute)
var ws = null, sync = null, delay = 1000, latest = 0, joined = false;
var ws = null, sync = null, delay = 1000, latest = 0, joined = false;
@@ -79,6 +80,25 @@
@@ -79,6 +80,25 @@
// join, which comes straight back to this link (its key included). Nothing that would only fail is drawn: no remote,
// join, which comes straight back to this link (its key included). Nothing that would only fail is drawn: no remote,
// no reactions, no composer, no count of who is watching, no socket (it answered "No such room").
// no reactions, no composer, no count of who is watching, no socket (it answered "No such room").
var guest = !token();
var guest = !token();
+ // Each person's volume is this browser's, not the room's: remember it, and never send it.
+ try {
+ var savedVolume = localStorage.getItem(MUSIC_VOLUME_KEY);
+ if (savedVolume !== null) {
+ var savedNumber = Number(savedVolume);
+ if (Number.isFinite(savedNumber) && savedNumber >= 0 && savedNumber <= 1) musicVolume = savedNumber;
+ }
+ } catch (e) { /* storage blocked */ }
+ if (musicBox && !isRadio) {
+ musicAudio = el('audio', 'pt-music-audio');
+ musicAudio.preload = 'auto';
+ musicAudio.setAttribute('playsinline', '');
+ musicAudio.volume = musicVolume;
+ musicAudio.addEventListener('ended', function () {
+ if (music && music.track && !music.paused && iHold()) musicAction('ended', { expected_id: music.track.id });
+ });
+ musicAudio.addEventListener('loadedmetadata', applyMusic);
+ musicAudio.addEventListener('canplay', applyMusic);
+ }
var joinHref = '/auth/login?next=' + encodeURIComponent(location.pathname + location.search);
var joinHref = '/auth/login?next=' + encodeURIComponent(location.pathname + location.search);
function joinLink(cls, text) { var a = el('a', cls, text); a.href = joinHref; return a; }
function joinLink(cls, text) { var a = el('a', cls, text); a.href = joinHref; return a; }
if (guest) root.classList.add('is-guest');
if (guest) root.classList.add('is-guest');
@@ -177,6 +197,7 @@
@@ -177,6 +197,7 @@
paintDesktopLayout();
paintDesktopLayout();
if (providerInfo) stage.appendChild(refusal);
if (providerInfo) stage.appendChild(refusal);
if (!guest) stage.appendChild(bar);
if (!guest) stage.appendChild(bar);
+ if (musicBox) stage.appendChild(musicBox); // under the room's controls, not above its title
if (isRadio) {
if (isRadio) {
var radioBox = el('section', 'pt-radio');
var radioBox = el('section', 'pt-radio');
radioBox.setAttribute('aria-label', 'OpenVibe Radio queue');
radioBox.setAttribute('aria-label', 'OpenVibe Radio queue');
@@ -338,6 +359,151 @@
@@ -338,6 +359,151 @@
np.appendChild(text);
np.appendChild(text);
}
}
+ // ── listening together in the room ──────────────────────────────────────────
+ // The room's remote starts OpenVibe Radio; Chat keeps the timeline and every page follows it. The volume slider is
+ // this person's own (this browser's), and this page never sends it anywhere.
+ function musicTargetMs() {
+ if (!music || !music.track) return 0;
+ var base = Math.max(0, Number(music.position_ms) || 0);
+ if (!music.paused) base += Math.max(0, Date.now() + serverOffset - (Number(music.at_ms) || Date.now()));
+ return base;
+ }
+ function timeLabel(iso) {
+ var d = new Date(iso);
+ return isNaN(d.getTime()) ? '' : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ function applyMusic() {
+ if (!musicAudio) return;
+ if (!music || !music.track || !safeHref(music.track.audio_url)) {
+ musicAudio.pause();
+ musicAudio.removeAttribute('src');
+ delete musicAudio.dataset.src;
+ musicNeedsTap = false;
+ return;
+ }
+ if (musicAudio.dataset.src !== music.track.audio_url) {
+ musicAudio.dataset.src = music.track.audio_url;
+ musicAudio.src = music.track.audio_url;
+ }
+ var target = musicTargetMs() / 1000;
+ if (musicAudio.readyState >= 1 && Number.isFinite(musicAudio.duration) && musicAudio.duration > 0 && target > musicAudio.duration - 0.25)
+ target = Math.max(0, musicAudio.duration - 0.25);
+ if (musicAudio.readyState >= 1 && Math.abs(musicAudio.currentTime - target) > 1.5) {
+ try { musicAudio.currentTime = target; } catch (e) { /* metadata is still loading */ }
+ }
+ if (music.paused) { musicAudio.pause(); musicNeedsTap = false; return; }
+ var played = musicAudio.play();
+ if (played && played.then) played.then(function () {
+ if (musicNeedsTap) { musicNeedsTap = false; paintMusic(); }
+ }, function () {
+ // The browser wants a gesture before it plays sound: say so, do not pretend the room is audible.
+ if (!musicNeedsTap) { musicNeedsTap = true; paintMusic(); }
+ });
+ else musicNeedsTap = false;
+ }
+ function musicAction(action, body) {
+ var headers = { 'Content-Type': 'application/json' }, t = token();
+ if (t) headers.Authorization = 'Bearer ' + t;
+ fetch('/api/chat/parties/' + encodeURIComponent(slug) + '/music/' + action, {
+ method: 'POST', headers: headers, credentials: 'same-origin', body: JSON.stringify(body || {}),
+ }).then(function (r) {
+ return r.json().then(function (v) { if (!r.ok) throw new Error(v.error || v.code || 'The room’s music did not change'); return v; });
+ }).then(function (v) {
+ music = v.music || null;
+ applyMusic(); paintMusic();
+ }).catch(function (e) { flash(e.message); });
+ }
+ function paintMusic() {
+ if (!musicBox || isRadio) return;
+ var control = !guest && !!(self && iHold());
+ musicBox.textContent = '';
+ if (!music || !music.track) {
+ musicBox.hidden = !control;
+ if (!control) return;
+ musicBox.appendChild(el('h2', 'pt-h', 'Listen together'));
+ musicBox.appendChild(el('p', 'pt-music-note', 'Play OpenVibe Radio in the room: its licensed catalogue, in step for everyone, with the volume your own.'));
+ if (catalogue.length) {
+ var form = el('form', 'pt-music-start');
+ var pick = el('select');
+ pick.setAttribute('aria-label', 'Choose a Radio track');
+ var placeholder = el('option', null, 'Choose a Radio track'); placeholder.value = ''; pick.appendChild(placeholder);
+ catalogue.forEach(function (t) { var option = el('option', null, t.title + ' · ' + t.artist); option.value = t.id; pick.appendChild(option); });
+ var playTrack = el('button', 'pt-remote-btn', 'Play this track'); playTrack.type = 'submit';
+ form.appendChild(pick); form.appendChild(playTrack);
+ form.addEventListener('submit', function (event) {
+ event.preventDefault();
+ if (pick.value) musicAction('play', { track_id: pick.value });
+ });
+ musicBox.appendChild(form);
+ var radioButton = el('button', 'pt-remote-btn is-quiet', 'Start OpenVibe Radio');
+ radioButton.type = 'button';
+ radioButton.addEventListener('click', function () { musicAction('play', { station: true }); });
+ musicBox.appendChild(radioButton);
+ } else {
+ musicBox.appendChild(el('p', 'pt-music-note', 'OpenVibe Radio has no published tracks yet.'));
+ }
+ return;
+ }
+ musicBox.hidden = false;
+ var nowBox = el('div', 'pt-music-now');
+ var copy = el('div', 'pt-music-copy');
+ copy.appendChild(el('p', 'pt-h', 'Playing in the room'));
+ var title = el('p', 'pt-music-title');
+ title.appendChild(el('strong', null, music.track.title));
+ title.appendChild(document.createTextNode(' · ' + music.track.artist));
+ copy.appendChild(title);
+ var by = el('p', 'pt-music-by');
+ var starter = music.started_by && self && music.started_by === self.subject ? 'you'
+ : (music.started_by_name || (music.started_by ? nameOf(music.started_by) : 'someone'));
+ by.appendChild(document.createTextNode((starter === 'you' ? 'Started by you' : 'Started by ' + starter) + (timeLabel(music.started_at) ? ' · since ' + timeLabel(music.started_at) : '')));
+ copy.appendChild(by);
+ var proof = el('p', 'pt-music-proof');
+ proof.appendChild(outLink(licenceLabel(music.track.licence), music.track.licence_url));
+ if (safeHref(music.track.source_url)) { proof.appendChild(document.createTextNode(' · ')); proof.appendChild(outLink('Original source', music.track.source_url)); }
+ if (music.track.ai_made) proof.appendChild(document.createTextNode(' · AI-made music'));
+ copy.appendChild(proof);
+ nowBox.appendChild(copy);
+ if (musicAudio) nowBox.appendChild(musicAudio);
+ musicBox.appendChild(nowBox);
+ var controls = el('div', 'pt-music-actions');
+ var volume = el('label', 'pt-music-volume');
+ volume.appendChild(document.createTextNode('Your volume'));
+ var range = el('input');
+ range.type = 'range'; range.min = '0'; range.max = '100'; range.step = '1';
+ range.value = String(Math.round(musicVolume * 100));
+ range.setAttribute('aria-label', 'Your volume');
+ range.addEventListener('input', function () {
+ musicVolume = Math.max(0, Math.min(1, Number(range.value) / 100));
+ if (musicAudio) musicAudio.volume = musicVolume;
+ try { localStorage.setItem(MUSIC_VOLUME_KEY, String(musicVolume)); } catch (e) { /* storage blocked */ }
+ });
+ volume.appendChild(range);
+ controls.appendChild(volume);
+ if (musicNeedsTap) {
+ var tap = el('button', 'pt-remote-btn', 'Tap to listen');
+ tap.type = 'button';
+ tap.addEventListener('click', function () { musicNeedsTap = false; applyMusic(); paintMusic(); });
+ controls.appendChild(tap);
+ }
+ if (control) {
+ var toggle = el('button', 'pt-remote-btn is-quiet', music.paused ? 'Resume for everyone' : 'Pause for everyone');
+ toggle.type = 'button';
+ toggle.addEventListener('click', function () { musicAction(music.paused ? 'resume' : 'pause'); });
+ controls.appendChild(toggle);
+ if (music.station) {
+ var next = el('button', 'pt-remote-btn is-quiet', 'Next track');
+ next.type = 'button';
+ next.addEventListener('click', function () { musicAction('next', { expected_id: music.track.id }); });
+ controls.appendChild(next);
+ }
+ var stop = el('button', 'pt-remote-btn is-quiet', 'Stop the music');
+ stop.type = 'button';
+ stop.addEventListener('click', function () { musicAction('stop'); });
+ controls.appendChild(stop);
+ }
+ musicBox.appendChild(controls);
+ }
+
function paintRadio() {
function paintRadio() {
if (!isRadio || !radioBox) return;
if (!isRadio || !radioBox) return;
paintNowPlaying();
paintNowPlaying();
@@ -548,7 +714,12 @@
@@ -548,7 +714,12 @@
if (guest) { paintVeil(); return; }
if (guest) { paintVeil(); return; }
if (!window.WebSocket) { paintVeil(); return; }
if (!window.WebSocket) { paintVeil(); return; }
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
ws = new WebSocket((location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ws/chat');
- ws.onopen = function () { delay = 1000; send({ type: 'join_room', room: slug, token: token() }); paintVeil(); };
+ ws.onopen = function () {
+ delay = 1000;
+ send({ type: 'join_room', room: slug, token: token() });
+ send({ type: 'time', t0: Date.now(), id: 'music' });
+ paintVeil();
+ };
ws.onmessage = function (ev) {
ws.onmessage = function (ev) {
var m;
var m;
try { m = JSON.parse(ev.data); } catch (e) { return; }
try { m = JSON.parse(ev.data); } catch (e) { return; }
@@ -558,7 +729,16 @@
@@ -558,7 +729,16 @@
if (m.subject && !self) self = { subject: m.subject, name: names[m.subject] || 'You' };
if (m.subject && !self) self = { subject: m.subject, name: names[m.subject] || 'You' };
if (!isDesktop && !sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });
if (!isDesktop && !sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });
catchUp();
catchUp();
- paintRadio();
+ paintRadio(); paintMusic(); applyMusic();
+ } else if (m.type === 'time' && Number.isFinite(Number(m.t_server)) && Number.isFinite(Number(m.t0))) {
+ // NTP-style: the smallest round trip gives the best estimate of the server's clock, which is the
+ // one the room's music timeline is written against.
+ var rtt = Date.now() - Number(m.t0);
+ if (rtt >= 0 && rtt < bestClockRtt) {
+ bestClockRtt = rtt;
+ serverOffset = Number(m.t_server) - (Number(m.t0) + Date.now()) / 2;
+ applyMusic();
+ }
} else if (!isDesktop && m.type === 'sync' && m.state) {
} else if (!isDesktop && m.type === 'sync' && m.state) {
clock = m.state;
clock = m.state;
if (!clock.paused || clock.position_ms > 0) everPlayed = true;
if (!clock.paused || clock.position_ms > 0) everPlayed = true;
@@ -576,6 +756,10 @@
@@ -576,6 +756,10 @@
before.forEach(function (s) { if ((lease.watchers || []).indexOf(s) < 0) away[s] = Date.now(); });
before.forEach(function (s) { if ((lease.watchers || []).indexOf(s) < 0) away[s] = Date.now(); });
(lease.watchers || []).forEach(function (s) { delete away[s]; });
(lease.watchers || []).forEach(function (s) { delete away[s]; });
if (iHold() || holder().kind !== 'person' || (asked && holder().subject === asked)) asked = null;
if (iHold() || holder().kind !== 'person' || (asked && holder().subject === asked)) asked = null;
+ paintMusic();
+ } else if (m.type === 'music') {
+ music = m.state || null;
+ applyMusic(); paintMusic();
} else if (m.type === 'lease.request' && m.request && m.request.requester) {
} else if (m.type === 'lease.request' && m.request && m.request.requester) {
if (iHold()) asked = m.request.requester.subject;
if (iHold()) asked = m.request.requester.subject;
} else if (m.type === 'room_message' && m.message) {
} else if (m.type === 'room_message' && m.message) {
@@ -609,6 +793,12 @@
@@ -609,6 +793,12 @@
if (d > DRIFT_SHOWN_MS) providerClock(clock, false);
if (d > DRIFT_SHOWN_MS) providerClock(clock, false);
}
}
drift.hidden = !(clock && !clock.paused && d > DRIFT_SHOWN_MS);
drift.hidden = !(clock && !clock.paused && d > DRIFT_SHOWN_MS);
+ // The room's music is kept on its second too: seek only past 1.5 s, so ordinary playback never stutters.
+ if (musicAudio && music && music.track && !music.paused && !musicAudio.paused && musicAudio.readyState >= 1) {
+ var want = musicTargetMs() / 1000;
+ if (Number.isFinite(musicAudio.duration) && musicAudio.duration > 0) want = Math.min(want, Math.max(0, musicAudio.duration - 0.25));
+ if (Math.abs(musicAudio.currentTime - want) > 1.5) { try { musicAudio.currentTime = want; } catch (e) { /* metadata */ } }
+ }
// A seat waits a minute; after it, the person is simply not here.
// A seat waits a minute; after it, the person is simply not here.
var now = Date.now(), changed = false;
var now = Date.now(), changed = false;
Object.keys(away).forEach(function (s) { if (now - away[s] > 60000) { delete away[s]; changed = true; } });
Object.keys(away).forEach(function (s) { if (now - away[s] > 60000) { delete away[s]; changed = true; } });
@@ -625,9 +815,35 @@
@@ -625,9 +815,35 @@
if (t) headers.Authorization = 'Bearer ' + t;
if (t) headers.Authorization = 'Bearer ' + t;
fetch('/api/chat/parties/' + encodeURIComponent(slug), { headers: headers, credentials: 'same-origin' })
fetch('/api/chat/parties/' + encodeURIComponent(slug), { headers: headers, credentials: 'same-origin' })
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (r) { return r.ok ? r.json() : null; })
- .then(function (v) { if (v && v.party) { data.desktop = v.party.desktop; data.ended_at = v.party.ended_at; paintDesktopLayout(); paintVeil(); } })
+ .then(function (v) {
+ if (!v || !v.party) return;
+ data.desktop = v.party.desktop; data.ended_at = v.party.ended_at;
+ if (v.party.music !== undefined) {
+ var before = music && music.seq, after = v.party.music || null;
+ music = after;
+ if ((after && after.seq) !== before) { applyMusic(); paintMusic(); }
+ }
+ paintDesktopLayout(); paintVeil();
+ })
.catch(function () { /* the page keeps its last known state */ });
.catch(function () { /* the page keeps its last known state */ });
}, 4000);
}, 4000);
+ if (guest && !isRadio && musicBox) {
+ setInterval(function () {
+ var key = null;
+ try { key = new URLSearchParams(location.search).get('k') || localStorage.getItem('ov-party-key:' + slug); } catch (e) { /* storage blocked */ }
+ var sentAt = Date.now();
+ fetch('/api/chat/parties/' + encodeURIComponent(slug) + (key ? '?k=' + encodeURIComponent(key) : ''), { credentials: 'same-origin' })
+ .then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (v) {
+ if (!v || !v.party) return;
+ var rtt = Date.now() - sentAt;
+ if (Number(v.server_time_ms) && rtt < bestClockRtt) { bestClockRtt = rtt; serverOffset = Number(v.server_time_ms) - (sentAt + Date.now()) / 2; }
+ var before = music && music.seq, after = v.party.music || null;
+ music = after;
+ if ((after && after.seq) !== before) { applyMusic(); paintMusic(); }
+ }).catch(function () { /* retry next poll */ });
+ }, 3000);
+ }
if (isRadio && guest) {
if (isRadio && guest) {
setInterval(function () {
setInterval(function () {
var key = null;
var key = null;
@@ -646,8 +862,8 @@
@@ -646,8 +862,8 @@
}).catch(function () { /* retry next poll */ });
}).catch(function () { /* retry next poll */ });
}, 3000);
}, 3000);
}
}
- if (isRadio && !guest) fetch('/api/chat/parties/radio/tracks').then(function (r) { return r.ok ? r.json() : null; })
- .then(function (v) { catalogue = v && v.tracks || []; paintRadio(); }).catch(function () { /* retry on refresh */ });
+ if (!guest) fetch('/api/chat/parties/radio/tracks').then(function (r) { return r.ok ? r.json() : null; })
+ .then(function (v) { catalogue = v && v.tracks || []; paintRadio(); paintMusic(); }).catch(function () { /* retry on refresh */ });
// ── invite ───────────────────────────────────────────────────────────────────
// ── invite ───────────────────────────────────────────────────────────────────
if (invite && !guest) {
if (invite && !guest) {
@@ -669,6 +885,6 @@
@@ -669,6 +885,6 @@
invite.appendChild(ib);
invite.appendChild(ib);
}
}
- paintVeil(); paintRemote(); paintPeople(); paintRadio();
+ paintVeil(); paintRemote(); paintPeople(); paintRadio(); paintMusic(); applyMusic();
connect();
connect();
})();
})();
@@ -2915,6 +2915,15 @@ class ChatServer {
@@ -2915,6 +2915,15 @@ class ChatServer {
} catch (err) {
} catch (err) {
console.warn('[Sync] state:', err && err.message);
console.warn('[Sync] state:', err && err.message);
}
}
+ // A party room's music is the room's too: the joiner starts at the room's own second, not at zero.
+ try {
+ if (room.kind === 'party') {
+ const state = await require('../rooms/party-music').stateForRoom(room);
+ if (state !== undefined) this.sendTo(ws, { type: 'music', room: room.slug, state });
+ }
+ } catch (err) {
+ console.warn('[Music] state:', err && err.message);
+ }
await this._broadcastLease(room);
await this._broadcastLease(room);
}
}
@@ -159,6 +159,11 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
@@ -159,6 +159,11 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
}
}
}
}
if (kindOfParty(party) === 'radio') out.radio = await require('./radio').state(row, party, { user, key });
if (kindOfParty(party) === 'radio') out.radio = await require('./radio').state(row, party, { user, key });
+ // A watch party or a shared computer can play OpenVibe Radio beside what it watches: the room's own music
+ // timeline (server/rooms/party-music.js), visible to every member and to a holder of the link's secret.
+ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'desktop') {
+ out.music = await require('./party-music').state(row, { now });
+ }
return out;
return out;
}
}
@@ -257,7 +262,7 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l
@@ -257,7 +262,7 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l
}
}
async function bySlug(slug) {
async function bySlug(slug) {
- const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at, p.party_kind, p.media, p.source_url, p.radio_track, p.desktop_hosting, p.desktop_state, p.desktop_layout, p.desktop_node_id, p.desktop_share_id, p.desktop_share_url FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
+ const row = await db.get('SELECT r.*, p.room_id AS party_room_id, p.agent_stream_id, p.vod_id, p.host_user_id, p.host_subject, p.mode, p.position_s, p.playing, p.position_at, p.listed, p.join_secret_hash, p.ended_at, p.created_at AS party_created_at, p.party_kind, p.media, p.source_url, p.radio_track, p.music_state, p.music_version, p.desktop_hosting, p.desktop_state, p.desktop_layout, p.desktop_node_id, p.desktop_share_id, p.desktop_share_url FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);
return row || null;
return row || null;
}
}
@@ -268,6 +273,7 @@ function partyOf(row) {
@@ -268,6 +273,7 @@ function partyOf(row) {
position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,
position_s: row.position_s, playing: row.playing, position_at: row.position_at, listed: row.listed,
join_secret_hash: row.join_secret_hash, ended_at: row.ended_at, created_at: row.party_created_at,
join_secret_hash: row.join_secret_hash, ended_at: row.ended_at, created_at: row.party_created_at,
party_kind: row.party_kind || 'agent_stream', media: row.media, source_url: row.source_url, radio_track: row.radio_track,
party_kind: row.party_kind || 'agent_stream', media: row.media, source_url: row.source_url, radio_track: row.radio_track,
+ music_state: row.music_state, music_version: row.music_version,
desktop_hosting: row.desktop_hosting, desktop_state: row.desktop_state, desktop_layout: row.desktop_layout, desktop_node_id: row.desktop_node_id,
desktop_hosting: row.desktop_hosting, desktop_state: row.desktop_state, desktop_layout: row.desktop_layout, desktop_node_id: row.desktop_node_id,
desktop_share_id: row.desktop_share_id, desktop_share_url: row.desktop_share_url,
desktop_share_id: row.desktop_share_id, desktop_share_url: row.desktop_share_url,
} : null;
} : null;
@@ -341,7 +347,7 @@ async function count(agentStreamId) {
@@ -341,7 +347,7 @@ async function count(agentStreamId) {
async function end(row, party, user) {
async function end(row, party, user) {
if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host ends the party');
if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host ends the party');
- await db.run("UPDATE room_parties SET ended_at = now(), playing = false, desktop_state = CASE WHEN party_kind = 'desktop' THEN 'stopped' ELSE desktop_state END WHERE room_id = ? AND ended_at IS NULL", [party.room_id]);
+ await db.run("UPDATE room_parties SET ended_at = now(), playing = false, music_state = NULL, music_version = music_version + 1, desktop_state = CASE WHEN party_kind = 'desktop' THEN 'stopped' ELSE desktop_state END WHERE room_id = ? AND ended_at IS NULL", [party.room_id]);
if (party.party_kind === 'desktop') await pool.releaseRoom(party.room_id, 'ended');
if (party.party_kind === 'desktop') await pool.releaseRoom(party.room_id, 'ended');
const fresh = await bySlug(row.slug);
const fresh = await bySlug(row.slug);
return view(fresh, partyOf(fresh), { where: await pool.where(party.room_id) });
return view(fresh, partyOf(fresh), { where: await pool.where(party.room_id) });
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -0,0 +1,201 @@
+'use strict';
+/**
+ * Listening together in a party room (openvibe.party): the room's music, beside the video or the shared desktop.
+ *
+ * The room's remote (common.control-lease@1) is the right to change it: whoever holds the control lease starts an
+ * OpenVibe Radio track, or the Radio station (a continuous run of the licensed catalogue), and pauses, resumes, skips
+ * or stops it. The room's state carries the track, who started it and the point the timeline was measured from:
+ *
+ * position_ms at at_ms, then paused or moving at rate 1. A late joiner computes where everyone should be from those
+ * two numbers and the Chat server's clock, so the room hears the same thing within a second or two. Each person's
+ * volume is their own: it is not part of this state and never leaves their browser.
+ *
+ * Only OpenVibe Radio's own catalogue is playable (server/rooms/radio.js): every track is validated for its licence,
+ * its official audio URL and its published metadata before it can reach a room. Nothing here accepts a free URL.
+ *
+ * Durable state is room_parties.music_state (jsonb) with music_version as a compare-and-set counter; changes are
+ * broadcast to the room socket as { type: 'music', room, state } and a join also receives it (chat-server.sendRoomState).
+ * The live audio is the browser's: Chat keeps the timeline and makes sure everyone follows it.
+ */
+const db = require('../db/database');
+const rooms = require('./rooms');
+const parties = require('./parties');
+const controlLease = require('./control-lease');
+const radio = require('./radio');
+
+const KINDS = new Set(['media', 'desktop']);
+const MAX_QUEUE = 200;
+const fail = (status, code, message) => { throw new parties.PartyError(status, code, message); };
+
+function decode(value) {
+ if (value == null) return null;
+ if (typeof value !== 'string') return value;
+ try { return JSON.parse(value); } catch { return null; }
+}
+
+/** The room's remote holder, as the clock's holder is: a signed-in member with the control lease. */
+async function holderOf(row, user) {
+ if (!user || !user.subject_id) fail(401, 'music.sign_in', 'Sign in to change the room’s music');
+ const access = await rooms.access(row, user);
+ if (!access.read) fail(404, 'rooms.not_found', 'No such room');
+ const lease = await controlLease.state({ id: row.id, slug: row.slug });
+ const holder = lease && lease.holder;
+ if (!holder || holder.kind !== 'person' || holder.subject !== user.subject_id) {
+ fail(403, 'music.not_holder', 'Only the person with the remote can change the room’s music');
+ }
+}
+
+/** The room's stored timeline as it stands at `now`: position is base + moving time while it is not paused. */
+function positionAt(stored, now) {
+ const base = Math.max(0, Number(stored && stored.position_ms) || 0);
+ if (!stored || stored.paused) return base;
+ return base + Math.max(0, now - (Number(stored.at_ms) || now));
+}
+
+/** The state a viewer, a socket or an SDK sees: the timeline measured at `at_ms`, never a stale stored number. */
+async function wire(stored, { now = Date.now(), version = 0 } = {}) {
+ if (!stored || !stored.track) return null;
+ const startedBy = stored.started_by == null ? null : String(stored.started_by);
+ let name = null;
+ if (startedBy) {
+ const who = await db.get('SELECT username, display_name FROM ctx_users WHERE subject_id = ? LIMIT 1', [startedBy]);
+ name = who ? (who.display_name || who.username || null) : null;
+ }
+ return {
+ track: stored.track,
+ station: !!stored.station,
+ started_by: startedBy,
+ started_by_name: name,
+ started_at: new Date(Number(stored.started_at) || now).toISOString(),
+ started_at_ms: Number(stored.started_at) || now,
+ position_ms: positionAt(stored, now),
+ at_ms: now,
+ paused: !!stored.paused,
+ up_next: Array.isArray(stored.queue) ? stored.queue.length : 0,
+ seq: Number(version) || 0,
+ };
+}
+
+/** A party's music as its detail view carries it: null when nothing plays, from the row bySlug already read. */
+async function state(row, { now = Date.now() } = {}) {
+ if (!row || !KINDS.has(row.party_kind)) return null;
+ return await wire(decode(row.music_state), { now, version: row.music_version });
+}
+
+/** A socket's join: the room's music if this is a kind that plays it, undefined for every other room. */
+async function stateForRoom(room, { now = Date.now() } = {}) {
+ if (!room || !room.id) return undefined;
+ const row = await db.get('SELECT party_kind, ended_at, music_state, music_version FROM room_parties WHERE room_id = ?', [room.id]);
+ if (!row || !KINDS.has(row.party_kind)) return undefined;
+ if (row.ended_at) return null;
+ return await wire(decode(row.music_state), { now, version: row.music_version });
+}
+
+function fresh(track, { station = false, queue = [], startedBy = null, now = Date.now() } = {}) {
+ return {
+ track, station: !!station, queue: (queue || []).slice(0, MAX_QUEUE),
+ started_by: startedBy, started_at: now, position_ms: 0, at_ms: now, paused: false,
+ };
+}
+
+/** The first track of a list that still validates, with the rest as the station's queue; null when none is left. */
+async function firstPlayable(ids) {
+ const left = (ids || []).slice();
+ while (left.length) {
+ const id = String(left.shift());
+ try {
+ return { track: await radio.track(id), queue: left.slice(0, MAX_QUEUE) };
+ } catch (err) {
+ // A track unpublished between the catalogue and now is skipped; a Radio outage is not.
+ if (!(err instanceof parties.PartyError) || err.status >= 500) throw err;
+ }
+ }
+ return null;
+}
+
+/**
+ * The next track of a station: the stored queue first (each one validated again), then a fresh catalogue pass so a
+ * station that has played the whole catalogue once keeps going. A single track has no next.
+ */
+async function pickNext(stored) {
+ if (!stored || !stored.station) return null;
+ const found = await firstPlayable(stored.queue || []);
+ if (found) return found;
+ const catalogue = await radio.catalogue();
+ const current = stored.track && stored.track.id;
+ const ids = catalogue.map((t) => t.id).filter((id) => id !== current);
+ return await firstPlayable(ids);
+}
+
+/** One change, behind the version counter: the winner writes and returns, a loser re-reads and tries again. */
+async function change(row, party, user, action, input = {}, { now = Date.now() } = {}) {
+ if (!row || !party) fail(404, 'rooms.not_found', 'No such room');
+ if (!KINDS.has(party.party_kind)) fail(409, 'music.party', 'Music plays in a watch party or a shared computer');
+ if (party.ended_at) fail(410, 'party.ended', 'This party has ended');
+ await holderOf(row, user);
+ for (let tries = 0; tries < 5; tries++) {
+ const current = await db.get('SELECT music_state, music_version FROM room_parties WHERE room_id = ?', [party.room_id]);
+ if (!current) fail(404, 'rooms.not_found', 'No such room');
+ const stored = decode(current.music_state);
+ const version = Number(current.music_version) || 0;
+ let next = stored;
+ if (action === 'play') {
+ const asked = input && input.track_id != null ? String(input.track_id) : '';
+ const station = !asked || (input && input.station === true);
+ if (asked) {
+ const track = await radio.track(asked);
+ const queue = station ? (await radio.catalogue()).map((t) => t.id).filter((id) => id !== track.id) : [];
+ next = fresh(track, { station, queue, startedBy: user.subject_id, now });
+ } else {
+ const catalogue = await radio.catalogue();
+ const first = await firstPlayable(catalogue.map((t) => t.id));
+ if (!first) fail(503, 'music.unavailable', 'OpenVibe Radio has no published tracks to play yet');
+ next = fresh(first.track, { station: true, queue: first.queue, startedBy: user.subject_id, now });
+ }
+ } else if (action === 'pause' || action === 'resume') {
+ if (!stored || !stored.track) fail(409, 'music.none', 'Nothing is playing in the room');
+ if (action === 'pause' ? stored.paused : !stored.paused) {
+ // Already there: answer the same state rather than invent a new version.
+ const same = await wire(stored, { now, version });
+ return same;
+ }
+ next = { ...stored, at_ms: now, paused: action === 'pause' };
+ if (action === 'pause') next.position_ms = positionAt(stored, now);
+ } else if (action === 'stop') {
+ next = null;
+ } else if (action === 'next' || action === 'ended') {
+ if (!stored || !stored.track) {
+ if (action === 'next') fail(409, 'music.none', 'Nothing is playing in the room');
+ next = null;
+ } else if (input && input.expected_id && input.expected_id !== stored.track.id) {
+ // A late or repeated end for a track the room already moved past: answer the current state.
+ const same = await wire(stored, { now, version });
+ return same;
+ } else {
+ const following = await pickNext(stored);
+ next = following ? fresh(following.track, {
+ station: true, queue: following.queue,
+ // An auto-advance stays the station's starter; a person skipping names themselves.
+ startedBy: action === 'ended' ? stored.started_by : user.subject_id, now,
+ }) : null;
+ }
+ } else {
+ fail(422, 'music.action', 'Unknown music action');
+ }
+ const updated = await db.get(`UPDATE room_parties
+ SET music_state = ?, music_version = music_version + 1
+ WHERE room_id = ? AND music_version = ? RETURNING music_version`,
+ [next ? JSON.stringify(next) : null, party.room_id, version]);
+ if (!updated) continue;
+ const out = await wire(next, { now, version: Number(updated.music_version) });
+ try {
+ require('../chat/chat-server').broadcastToRoom(row.id, { type: 'music', room: row.slug, state: out });
+ } catch (err) {
+ console.warn('[Music] broadcast:', err && err.message);
+ }
+ return out;
+ }
+ fail(409, 'music.conflict', 'The room’s music changed; try again');
+}
+
+module.exports = { KINDS, state, stateForRoom, change };
@@ -18,6 +18,7 @@ const { isOwner } = require('../auth/permissions');
@@ -18,6 +18,7 @@ const { isOwner } = require('../auth/permissions');
const { limits } = require('../net/actor-limits');
const { limits } = require('../net/actor-limits');
const parties = require('../rooms/parties');
const parties = require('../rooms/parties');
const radio = require('../rooms/radio');
const radio = require('../rooms/radio');
+const music = require('../rooms/party-music');
const desktop = require('../rooms/party-desktop');
const desktop = require('../rooms/party-desktop');
const partyPool = require('../rooms/party-pool');
const partyPool = require('../rooms/party-pool');
@@ -25,6 +26,8 @@ const router = express.Router();
@@ -25,6 +26,8 @@ const router = express.Router();
// Starting a party: the same budget as making a room (owners keep at most MAX_ROOMS_PER_OWNER).
// Starting a party: the same budget as making a room (owners keep at most MAX_ROOMS_PER_OWNER).
const createLimit = limits('chat.party.create', { minute: 10, hour: 60 });
const createLimit = limits('chat.party.create', { minute: 10, hour: 60 });
+// Music changes are quick controls (play, pause, next): a generous budget, separate from starting a party.
+const musicLimit = limits('chat.party.music', { minute: 60, hour: 300 });
// The owner's pool panel is a write surface; the person's own pool budget is in party-pool.js (maxPerActor).
// The owner's pool panel is a write surface; the person's own pool budget is in party-pool.js (maxPerActor).
const poolLimit = limits('chat.party.pool', { minute: 30, hour: 120 });
const poolLimit = limits('chat.party.pool', { minute: 30, hour: 120 });
@@ -157,6 +160,28 @@ router.post('/:slug/desktop/layout', requireAuth, express.json({ limit: '1kb' })
@@ -157,6 +160,28 @@ router.post('/:slug/desktop/layout', requireAuth, express.json({ limit: '1kb' })
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
+// ── Listening together in a watch party or shared computer ──────────────────────────────────────────────────────
+// The room's remote holder plays OpenVibe Radio beside the video or desktop: a licensed track, or the Radio station.
+// The state carries the track, who started it, and position_ms at at_ms; a joiner gets it from the party and the
+// socket (sendRoomState). Volume is each person's own and never reaches this API.
+router.get('/:slug/music', optionalAuth, async (req, res) => {
+ try {
+ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
+ if (!row || !party || !await parties.mayView(row, party, req.user, req.query.k)) return res.status(404).json({ code: 'rooms.not_found' });
+ const out = await music.state(row);
+ res.set('Cache-Control', 'private, no-store').json({ music: out, server_time_ms: Date.now() });
+ } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/music/:action', requireAuth, musicLimit, async (req, res) => {
+ try {
+ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
+ if (!row || !party || !await parties.mayView(row, party, req.user, null)) return res.status(404).json({ code: 'rooms.not_found' });
+ const out = await music.change(row, party, req.user, String(req.params.action || ''), req.body || {});
+ res.set('Cache-Control', 'private, no-store').json({ music: out, server_time_ms: Date.now() });
+ } catch (err) { send(res, err); }
+});
+
router.get('/:slug/radio', optionalAuth, async (req, res) => {
router.get('/:slug/radio', optionalAuth, async (req, res) => {
try {
try {
const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
@@ -418,7 +418,7 @@ ${holdRows}
@@ -418,7 +418,7 @@ ${holdRows}
mainAttrs: `data-party="${esc(out.id)}"`, party: true, wide: true,
mainAttrs: `data-party="${esc(out.id)}"`, party: true, wide: true,
body: `<article class="oc-party${out.kind === 'radio' ? ' is-radio' : ''}">
body: `<article class="oc-party${out.kind === 'radio' ? ' is-radio' : ''}">
<header class="oc-party-head"><h1>${esc(out.title)}</h1><p class="oc-muted">Started by ${esc(out.host_name || 'someone')}</p>${out.kind === 'desktop' && out.desktop && out.desktop.where ? `<p class="oc-party-where" data-party-where>${esc(out.desktop.where.line)}</p>` : ''}</header>
<header class="oc-party-head"><h1>${esc(out.title)}</h1><p class="oc-muted">Started by ${esc(out.host_name || 'someone')}</p>${out.kind === 'desktop' && out.desktop && out.desktop.where ? `<p class="oc-party-where" data-party-where>${esc(out.desktop.where.line)}</p>` : ''}</header>
-<div class="oc-party-stage" data-party-stage aria-label="${out.kind === 'radio' ? 'The music stage' : 'The video'}"></div>
+<div class="oc-party-stage" data-party-stage aria-label="${out.kind === 'radio' ? 'The music stage' : 'The video'}">${out.kind === 'radio' ? '' : '<div class="oc-party-music" data-party-music aria-label="Music"></div>'}</div>
<div class="oc-party-people" data-party-people aria-label="People here"></div>
<div class="oc-party-people" data-party-people aria-label="People here"></div>
<div class="oc-party-chat" data-party-chat aria-label="Chat"></div>
<div class="oc-party-chat" data-party-chat aria-label="Chat"></div>
<div class="oc-party-remote" data-party-remote aria-label="The remote"></div>
<div class="oc-party-remote" data-party-remote aria-label="The remote"></div>
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
+'use strict';
+/**
+ * Listening together in a party room (openvibe.party): the room's remote holder starts OpenVibe Radio in a shared
+ * computer or a watch party, the room's state carries the track, who started it and the second the timeline was
+ * measured from, a late joiner follows at that second, and the change reaches the room's socket.
+ *
+ * The Radio catalogue is a local stand-in, exactly as Radio publishes it; no audio is fetched here.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('party music');
+const T1 = 'rad_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const T2 = 'rad_01JAB2C3D4E5F6G7H8J9K0MNRR';
+const AUDIO1 = `https://openvibe.download/api/v1/radio/tracks/${T1}/audio`;
+const AUDIO2 = `https://openvibe.download/api/v1/radio/tracks/${T2}/audio`;
+const track = (id, title, artist, audio) => ({ id, title, artist, source: 'creator', source_url: null,
+ licence: 'creator_grant', licence_url: 'https://openvibe.download/radio/licence', ai_made: false, audio_url: audio });
+const TRACKS = [track(T1, 'Friday Song', 'Ann', AUDIO1), track(T2, 'Saturday Song', 'Bob', AUDIO2)];
+let service, h, ann, bob, cat, party, key;
+
+const api = (method, path, u, body) => h.http(method, `/api/chat/parties${path}`, { token: u && u.token, body });
+/** The party host page: its own Host header, so the Host-based party router answers. */
+function raw(method, path, { cookie = null } = {}) {
+ return new Promise((resolve, reject) => {
+ const req = http.request({ host: '127.0.0.1', port: h.port, path, method,
+ headers: { host: 'openvibe.party', ...(cookie ? { cookie } : {}) } }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, text }));
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+const dataOf = (html) => {
+ const m = /<script type="application\/json" id="party-data">([\s\S]*?)<\/script>/.exec(html);
+ assert.ok(m, 'the page carries its party data');
+ return JSON.parse(m[1]);
+};
+
+t('boot with the Radio catalogue', async () => {
+ service = http.createServer((req, res) => {
+ res.setHeader('content-type', 'application/json');
+ if (req.url === '/api/v1/radio/tracks') return res.end(JSON.stringify({ tracks: TRACKS }));
+ const one = /^\/api\/v1\/radio\/tracks\/([^/]+)$/.exec(req.url || '');
+ if (one) {
+ const found = TRACKS.find((x) => x.id === one[1]);
+ if (found) return res.end(JSON.stringify({ track: found }));
+ }
+ res.writeHead(404); res.end('{}');
+ });
+ await new Promise((r) => service.listen(0, '127.0.0.1', r));
+ process.env.CHAT_RADIO_URL = `http://127.0.0.1:${service.address().port}`;
+ h = await boot({ env: { CHAT_PARTY_URL: 'https://openvibe.party' } });
+ ann = h.addUser('ann', { subject: ids.newId('user') });
+ bob = h.addUser('bob', { subject: ids.newId('user') });
+ cat = h.addUser('cat', { subject: ids.newId('user') });
+ for (const u of [ann, bob, cat]) await h.ctx.upsertUser(h.live.users.get(u.id));
+});
+
+t('a shared computer starts empty, and only the room’s remote holder can start its music', async () => {
+ const made = await api('POST', '/', ann, { desktop_hosting: 'link', title: 'Music room' });
+ assert.strictEqual(made.status, 201, made.text);
+ party = made.body.party;
+ key = made.body.join_secret;
+ assert.strictEqual(party.kind, 'desktop');
+
+ const before = await api('GET', `/${party.id}`, ann);
+ assert.strictEqual(before.status, 200, before.text);
+ assert.strictEqual(before.body.party.music, null, 'nothing plays until someone starts it');
+
+ // Bob joins by the link; he is a member but does not hold the remote.
+ const joined = await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: bob.token, body: { k: key } });
+ assert.strictEqual(joined.status, 200, joined.text);
+ const denied = await api('POST', `/${party.id}/music/play`, bob, { station: true });
+ assert.strictEqual(denied.status, 403, denied.text);
+ assert.strictEqual(denied.body.code, 'music.not_holder');
+
+ const started = await api('POST', `/${party.id}/music/play`, ann, { station: true, volume: 0.1 });
+ assert.strictEqual(started.status, 200, started.text);
+ const music = started.body.music;
+ assert.strictEqual(music.track.id, T1, 'the station starts with the first published track');
+ assert.strictEqual(music.station, true);
+ assert.strictEqual(music.paused, false);
+ assert.strictEqual(music.started_by, ann.subject_id);
+ assert.strictEqual(music.started_by_name, 'Ann', 'who started it is in the room state');
+ assert.ok(music.started_at && Number.isFinite(Date.parse(music.started_at)));
+ assert.ok(music.position_ms >= 0 && music.position_ms < 1000, 'the clock starts at the beginning');
+ assert.strictEqual(music.up_next, 1, 'the station queued the other licensed track');
+ assert.strictEqual(music.volume, undefined, 'volume is each person’s own, never the room’s');
+
+ const seen = await api('GET', `/${party.id}`, bob);
+ assert.strictEqual(seen.body.party.music.track.id, T1, 'every member sees what is playing');
+ assert.strictEqual(seen.body.party.music.started_by_name, 'Ann');
+ const guest = await api('GET', `/${party.id}?k=${encodeURIComponent(key)}`, null);
+ assert.strictEqual(guest.status, 200);
+ assert.strictEqual(guest.body.party.music.track.id, T1, 'the link’s holder sees it too');
+});
+
+t('the room hears every change, and a late joiner starts at the room’s second', async () => {
+ const host = await h.ws({ token: ann.token, origin: 'https://openvibe.party' });
+ host.sendJson({ type: 'join_room', room: party.id });
+ await host.next((m) => m.type === 'room_joined');
+ const listener = await h.ws({ token: bob.token, origin: 'https://openvibe.party' });
+ listener.sendJson({ type: 'join_room', room: party.id });
+ await listener.next((m) => m.type === 'room_joined');
+ // The join itself carries the room's music: nothing has to change for a late joiner to hear it.
+ const atJoin = await listener.next((m) => m.type === 'music');
+ assert.strictEqual(atJoin.state.track.id, T1);
+
+ const paused = await api('POST', `/${party.id}/music/pause`, ann, {});
+ assert.strictEqual(paused.status, 200, paused.text);
+ assert.strictEqual(paused.body.music.paused, true);
+ const heard = await listener.next((m) => m.type === 'music');
+ assert.strictEqual(heard.state.paused, true, 'the room socket hears the pause');
+
+ // Put the timeline five seconds back in time (a room that started before this joiner arrived), then join late.
+ const music = require('../server/rooms/party-music');
+ const parties = require('../server/rooms/parties');
+ const row = await parties.bySlug(party.id);
+ await music.change(row, parties.partyOf(row), ann, 'play', { track_id: T1 }, { now: Date.now() - 5000 });
+ const late = await api('GET', `/${party.id}`, bob);
+ const lateMusic = late.body.party.music;
+ assert.ok(lateMusic.position_ms >= 5000 && lateMusic.position_ms < 8000, `late position ${lateMusic.position_ms}`);
+ assert.ok(Math.abs(Date.parse(lateMusic.started_at) - (Date.now() - 5000)) < 2000, 'the state says from when');
+
+ // A new socket gets the same timeline on join.
+ await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: cat.token, body: { k: key } });
+ const lateSocket = await h.ws({ token: cat.token, origin: 'https://openvibe.party' });
+ lateSocket.sendJson({ type: 'join_room', room: party.id });
+ await lateSocket.next((m) => m.type === 'room_joined');
+ const lateHeard = await lateSocket.next((m) => m.type === 'music');
+ assert.strictEqual(lateHeard.state.track.id, T1);
+ assert.ok(lateHeard.state.position_ms >= 5000, `socket late position ${lateHeard.state.position_ms}`);
+ host.close(); listener.close(); lateSocket.close();
+});
+
+t('only a published, licensed Radio track can enter the room; the station advances and stops', async () => {
+ const bad = await api('POST', `/${party.id}/music/play`, ann, { track_id: 'https://example.org/song.mp3' });
+ assert.strictEqual(bad.status, 422, bad.text);
+ assert.strictEqual(bad.body.code, 'radio.track');
+ const unpublished = await api('POST', `/${party.id}/music/play`, ann, { track_id: 'rad_01JAB2C3D4E5F6G7H8J9K0MZZZ' });
+ assert.strictEqual(unpublished.status, 422);
+ assert.strictEqual(unpublished.body.code, 'radio.track');
+
+ const started = await api('POST', `/${party.id}/music/play`, ann, { track_id: T1, station: true });
+ assert.strictEqual(started.status, 200, started.text);
+ assert.strictEqual(started.body.music.track.id, T1);
+ const next = await api('POST', `/${party.id}/music/next`, ann, { expected_id: T1 });
+ assert.strictEqual(next.status, 200, next.text);
+ assert.strictEqual(next.body.music.track.id, T2, 'the station moves to the next licensed track');
+ assert.ok(next.body.music.position_ms < 1000, 'the next track starts at zero');
+ assert.strictEqual(next.body.music.started_by, ann.subject_id);
+
+ const ended = await api('POST', `/${party.id}/music/ended`, ann, { expected_id: 'rad_01JAB2C3D4E5F6G7H8J9K0MZZZ' });
+ assert.strictEqual(ended.status, 200, ended.text);
+ assert.strictEqual(ended.body.music.track.id, T2, 'a repeated end for another track changes nothing');
+
+ const stopped = await api('POST', `/${party.id}/music/stop`, ann, {});
+ assert.strictEqual(stopped.status, 200, stopped.text);
+ assert.strictEqual(stopped.body.music, null);
+ assert.strictEqual((await api('GET', `/${party.id}`, ann)).body.party.music, null);
+});
+
+t('a single track ends at the end, and the page carries the room’s music and its control', async () => {
+ const started = await api('POST', `/${party.id}/music/play`, ann, { track_id: T2 });
+ assert.strictEqual(started.status, 200, started.text);
+ assert.strictEqual(started.body.music.station, false);
+ const page = await raw('GET', `/p/${party.id}`, { cookie: `ov_token=${ann.token}` });
+ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
+ assert.ok(page.text.includes('data-party-music'), 'the page has the music control’s region');
+ const pageData = dataOf(page.text);
+ assert.strictEqual(pageData.party.music.track.id, T2);
+ assert.strictEqual(pageData.party.music.started_by, ann.subject_id);
+ assert.strictEqual(pageData.party.music.started_by_name, 'Ann');
+ // The link's holder can hear the room without an account: the page still carries the now-playing state.
+ const guestPage = await raw('GET', `/p/${party.id}?k=${encodeURIComponent(key)}`);
+ assert.strictEqual(guestPage.status, 200, guestPage.text.slice(0, 300));
+ assert.ok(guestPage.text.includes('data-party-music'));
+ assert.strictEqual(dataOf(guestPage.text).party.music.track.id, T2);
+
+ const ended = await api('POST', `/${party.id}/music/ended`, ann, { expected_id: T2 });
+ assert.strictEqual(ended.status, 200, ended.text);
+ assert.strictEqual(ended.body.music, null, 'a single track leaves the room quiet when it ends');
+});
+
+t('close', async () => {
+ await h.close();
+ await new Promise((r) => service.close(r));
+ delete process.env.CHAT_RADIO_URL;
+});
+t.run();