openvibers/openvibe.chat Commits

Party rooms play OpenVibe Radio together (#494)

OP OpenVibers committed 4 days ago · b0d9d515f9 · 10 files, +750 −12

Changes

ddiff --git a/docs/party-music.md b/docs/party-music.md
nnew file mode 100644
iindex 0000000..087a56c
---- /dev/null
++++ b/docs/party-music.md
@@@ -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`).
ddiff --git a/migrations/0067_party_music.sql b/migrations/0067_party_music.sql
nnew file mode 100644
iindex 0000000..4a43795
---- /dev/null
++++ b/migrations/0067_party_music.sql
@@@ -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;
ddiff --git a/public/web/party.css b/public/web/party.css
iindex d889c67..894c68f 100644
---- a/public/web/party.css
++++ b/public/web/party.css
@@@ -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; }
++}
ddiff --git a/public/web/party.js b/public/web/party.js
iindex 69d1b5d..b5b2506 100644
---- a/public/web/party.js
++++ b/public/web/party.js
@@@ -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 title
if (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();
})();
ddiff --git a/server/chat/chat-server.js b/server/chat/chat-server.js
iindex 96d05c4..c110c94 100644
---- a/server/chat/chat-server.js
++++ b/server/chat/chat-server.js
@@@ -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);
}
ddiff --git a/server/rooms/parties.js b/server/rooms/parties.js
iindex 89a0bc9..96c4813 100644
---- a/server/rooms/parties.js
++++ b/server/rooms/parties.js
@@@ -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) });
ddiff --git a/server/rooms/party-music.js b/server/rooms/party-music.js
nnew file mode 100644
iindex 0000000..eb9948f
---- /dev/null
++++ b/server/rooms/party-music.js
@@@ -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 };
ddiff --git a/server/rooms/party-routes.js b/server/rooms/party-routes.js
iindex 07d8a23..22d96cd 100644
---- a/server/rooms/party-routes.js
++++ b/server/rooms/party-routes.js
@@@ -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);
ddiff --git a/server/web/party-pages.js b/server/web/party-pages.js
iindex 8a6e436..b212d32 100644
---- a/server/web/party-pages.js
++++ b/server/web/party-pages.js
@@@ -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>
ddiff --git a/test/party-music.test.js b/test/party-music.test.js
nnew file mode 100644
iindex 0000000..84ebe00
---- /dev/null
++++ b/test/party-music.test.js
@@@ -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();