openvibers/openvibe.chat Commits
Add OpenVibe Radio listening parties (#413)
Changes
nnew file mode 100644@@@ -0,0 +1,13 @@++-- phase: expand++-- Radio parties share the media clock and hold an approved Radio track plus a durable queue.++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_track jsonb;++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_queue jsonb NOT NULL DEFAULT '[]'::jsonb;++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_allow_all boolean NOT NULL DEFAULT false;++ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_version integer NOT NULL DEFAULT 0;++ALTER TABLE room_parties DROP CONSTRAINT IF EXISTS room_parties_kind_allowed;++ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_allowed CHECK (party_kind IN ('agent_stream', 'media', 'radio'));++ALTER TABLE room_parties DROP CONSTRAINT IF EXISTS room_parties_kind_source;++ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_source CHECK (++ (party_kind = 'agent_stream' AND agent_stream_id IS NOT NULL AND media IS NULL)++ OR (party_kind IN ('media', 'radio') AND agent_stream_id IS NULL AND media IS NOT NULL)++);@@@ -53,3 +53,12 @@.pty-mine-list a:hover, .pty-mine-list a:focus-visible { border-color: var(--oc-accent); }.pty-mine-list strong { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.pty-mine-list span { font-size: 13px; color: var(--oc-muted); }++.pty-radio { margin-top: 40px; padding: 22px; border-radius: 16px; background: var(--oc-panel); border: 1px solid var(--oc-line); }++.pty-radio h2 { margin: 0 0 8px; font-size: 1.3rem; }++.pty-radio p { color: var(--oc-muted); line-height: 1.5; }++.pty-radio-list { list-style: none; padding: 0; display: grid; gap: 10px; }++.pty-radio-list li { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px; border: 1px solid var(--oc-line); border-radius: 12px; min-width: 0; }++.pty-radio-list li div { display: grid; gap: 3px; min-width: 0; }++.pty-radio-list li span, .pty-radio-list li small { color: var(--oc-muted); overflow-wrap: anywhere; }++.pty-radio-list li a.oc-button { flex: none; min-height: 44px; display: inline-flex; align-items: center; padding: 0 16px; }++@media (max-width: 600px) { .pty-radio { padding: 16px; } .pty-radio-list li { align-items: stretch; flex-direction: column; } .pty-radio-list li a.oc-button { align-self: flex-start; } }@@@ -46,6 +46,49 @@ main[data-party] [hidden] { display: none !important; }box-shadow: 0 18px 50px rgba(0, 0, 0, .35);}.pt-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }++.pt-frame.is-radio {++ aspect-ratio: auto; width: 100%; display: grid; gap: 14px; padding: 18px;++ background: var(--pt-panel); border: 1px solid var(--pt-line); box-shadow: none;++}++.pt-frame.is-radio audio { display: block; width: 100%; height: 44px; }++.pt-frame.is-radio .pt-veil, .pt-frame.is-radio.is-guest .pt-veil {++ position: static; inset: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; padding: 0;++ background: none; color: var(--pt-muted); font-size: 14px; font-weight: 650; text-align: left; pointer-events: auto;++}++.pt-frame.is-radio.is-guest .pt-veil { flex-direction: row; justify-content: space-between; }++.pt-frame.is-radio .pt-veil:empty, .pt-frame.is-radio .pt-veil[hidden],++.pt-frame.is-radio.is-empty audio, .pt-frame.is-radio.is-empty .pt-veil { display: none; }++.pt-frame.is-radio .pt-veil-line { max-width: none; }++.pt-frame.is-radio .pt-drift { left: auto; right: 12px; }++.pt-np { display: flex; gap: 16px; align-items: center; min-width: 0; }++.pt-np-cover {++ flex: none; width: 104px; height: 104px; border-radius: 14px; display: grid; place-items: center;++ font-size: 44px; font-weight: 800; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .25);++ background: linear-gradient(135deg, hsl(var(--np-h, 260) 68% 56%), hsl(calc(var(--np-h, 260) + 48) 64% 34%));++}++.pt-np-text { min-width: 0; }++.pt-np-text .pt-h { margin-bottom: 4px; }++.pt-np-title { display: block; font-size: clamp(20px, 2.4vw, 30px); line-height: 1.15; overflow-wrap: anywhere; }++.pt-np-artist { margin: 4px 0 0; font-size: 15px; }++.pt-np-proof { margin: 6px 0 0; font-size: 13px; color: var(--pt-muted); }++.pt-np-proof a { color: var(--pt-accent); }++.pt-radio { padding: 18px; border-radius: 16px; border: 1px solid var(--pt-line); background: var(--pt-panel); }++.pt-radio .pt-h:not(:first-child) { margin-top: 18px; }++.pt-radio-title { font-size: clamp(20px, 2vw, 28px); }++.pt-radio-meta { margin: 6px 0; font-size: 14px; color: var(--pt-muted); }++.pt-radio a { color: var(--pt-accent); }++.pt-radio-queue { margin: 0; padding-left: 22px; font-size: 14px; line-height: 1.7; }++.pt-radio-track { font-weight: 700; }++.pt-radio-by, .pt-radio-lic { color: var(--pt-muted); font-size: 13px; }++.pt-radio-empty { margin: 0; font-size: 14px; color: var(--pt-muted); }++.pt-radio-add { margin-top: 14px; }++.pt-ghost { min-height: 40px; padding: 0 16px; border: 1px solid var(--pt-line); border-radius: 999px; background: none; color: var(--pt-text); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }++.pt-ghost:hover { border-color: var(--pt-accent); }++.pt-radio-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 16px; }++.pt-radio-add { display: flex; gap: 8px; flex: 1 1 280px; }++.pt-radio-add select { min-width: 0; flex: 1; min-height: 44px; border-radius: 9px; padding: 0 10px; background: var(--pt-bg); color: var(--pt-text); border: 1px solid var(--pt-line); }++.pt-radio-permission { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }++.pt-radio-permission input { width: 18px; height: 18px; vertical-align: middle; }/* Honest states over the picture: waiting for the host, joining, reconnecting. */.pt-veil {position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center;@@@ -137,6 +180,12 @@ main[data-party].is-guest [data-party-chat] { min-height: 0; flex: 0 0 auto; ali[data-party-stage] { gap: 8px; }.pt-head { order: 3; padding: 0 max(14px, env(safe-area-inset-left, 0px)); }.pt-frame { order: 1; width: 100%; border-radius: 0; box-shadow: none; position: sticky; top: var(--pt-nav); z-index: 2; }++ .pt-frame.is-radio { padding: 12px 14px; gap: 10px; border-radius: 0; border-left: 0; border-right: 0; }++ .pt-np { gap: 12px; }++ .pt-np-cover { width: 64px; height: 64px; font-size: 28px; border-radius: 12px; }++ .pt-np-title { font-size: 19px; }++ .pt-np-artist { font-size: 14px; }++ .pt-radio { order: 4; margin: 0 12px; }.pt-bar { order: 2; padding: 0 max(12px, env(safe-area-inset-left, 0px)); }.pt-flash { order: 4; margin: 0 14px; }.pt-title { font-size: 1.05rem; }@@@ -23,7 +23,8 @@if (data && data.party) {var v = data.party;data = {-- slug: v.id, title: v.title, media: v.media, source_url: v.source_url, member: v.member,++ slug: v.id, title: v.title, kind: v.kind, radio: v.radio || 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 }; }),// The shell's join_url never carries the key (the secret is not echoed into a page): only a URL that has one@@@ -43,6 +44,10 @@(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', radio = data.radio || null, guestListening = false;++ var serverOffset = Number(data.server_time_ms) ? Number(data.server_time_ms) - Date.now() : 0;++ var bestClockRtt = Infinity;++ var catalogue = [];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,12 +84,13 @@if (guest) root.classList.add('is-guest');// ── the stage ────────────────────────────────────────────────────────────────-- var video = el('video');-- video.setAttribute('data-ov-player', '');++ var video = el(isRadio ? 'audio' : 'video');++ if (!isRadio) video.setAttribute('data-ov-player', '');video.setAttribute('playsinline', '');video.preload = 'auto';-- video.controls = !guest; // a guest's player would not follow the party: the invitation stands over it++ video.controls = !guest || isRadio;var src = typeof (clock && clock.media) === 'string' ? clock.media : (typeof data.media === 'string' ? data.media : null);++ if (isRadio && radio && !radio.current) src = null;if (src) video.src = src;var frame = el('div', 'pt-frame');frame.appendChild(video);@@@ -95,6 +101,11 @@var floats = el('div', 'pt-floats'); // reactions rising over the picturefloats.setAttribute('aria-hidden', 'true');frame.appendChild(veil); frame.appendChild(drift); frame.appendChild(floats);++ if (isRadio) frame.classList.add('is-radio');++ // A listening party has no picture: the stage is the now-playing card (cover, title, artist, the licence), then++ // the player, then the status line.++ var np = null;++ if (isRadio) { np = el('div', 'pt-np'); frame.insertBefore(np, video); }// The shell's own header (the title and who started it) leads; without one, the page writes its own.var shellHead = root.querySelector('.oc-party-head');var head = shellHead || el('div', 'pt-head');@@@ -113,6 +124,11 @@stage.appendChild(head);stage.appendChild(frame);if (!guest) stage.appendChild(bar);++ if (isRadio) {++ var radioBox = el('section', 'pt-radio');++ radioBox.setAttribute('aria-label', 'OpenVibe Radio queue');++ stage.appendChild(radioBox);++ }if (remote && !guest) bar.appendChild(remote);var react = el('div', 'pt-react');react.setAttribute('aria-label', 'React');@@@ -135,6 +151,20 @@function holder() { return lease && lease.holder ? lease.holder : { kind: 'nobody', subject: null }; }function iHold() { var h = holder(); return !!(self && h.kind === 'person' && h.subject === self.subject); }function paintVeil() {++ if (isRadio && guest) {++ frame.classList.add('is-guest');++ veil.textContent = '';++ if (!guestListening) {++ veil.appendChild(el('span', 'pt-veil-line', 'Listen at the party’s position, no account needed'));++ var start = el('button', 'pt-join', 'Start listening');++ start.type = 'button';++ start.addEventListener('click', function () { guestListening = true; applyGuestClock(); paintVeil(); });++ veil.appendChild(start);++ }++ veil.hidden = guestListening;++ frame.classList.toggle('is-waiting', !guestListening);++ return;++ }if (guest) {veil.textContent = '';var host = data.host && data.host.name ? data.host.name : null;@@@ -153,6 +183,104 @@frame.classList.toggle('is-waiting', !!text);}++ function applyGuestClock() {++ if (!isRadio || !guest || !guestListening || !clock) return;++ var serverNow = Date.now() + serverOffset;++ var target = Math.max(0, Number(clock.position_ms || 0) + (!clock.paused ? Math.max(0, serverNow - Number(clock.at_ms || serverNow)) * Number(clock.rate || 1) : 0)) / 1000;++ if (Math.abs(video.currentTime - target) > 1) { try { video.currentTime = target; } catch (e) { /* metadata loading */ } }++ if (clock.paused) video.pause();++ else video.play().catch(function () { /* browser gesture required */ });++ }++ function licenceLabel(value) {++ return { creator_grant: 'Streamed with the artist’s permission', public_domain: 'Public domain', 'CC0-1.0': 'CC0 1.0',++ 'CC-BY-4.0': 'CC BY 4.0', 'CC-BY-SA-4.0': 'CC BY-SA 4.0' }[value] || value;++ }++ function safeHref(u) { return typeof u === 'string' && /^https:\/\//i.test(u) ? u : null; }++ function outLink(text, href) {++ var url = safeHref(href), a = el(url ? 'a' : 'span', null, text);++ if (url) { a.href = url; a.target = '_blank'; a.rel = 'noopener'; }++ return a;++ }++ function mayChangeQueue() { return !guest && self && (self.subject === owner || (radio && radio.allow_everyone)); }++ function paintNowPlaying() {++ if (!np) return;++ np.textContent = '';++ var current = radio && radio.current;++ frame.classList.toggle('is-empty', !current); // no track: no dead player, no second "nothing to play" line++ var cover = el('div', 'pt-np-cover', current ? (String(current.title || '').trim().charAt(0) || '♪').toUpperCase() : '♪');++ cover.setAttribute('aria-hidden', 'true');++ var seed = String(current ? current.id || current.title : 'radio'), hue = 0;++ for (var i = 0; i < seed.length; i++) hue = (hue * 31 + seed.charCodeAt(i)) % 360;++ cover.style.setProperty('--np-h', String(hue));++ np.appendChild(cover);++ var text = el('div', 'pt-np-text');++ text.appendChild(el('p', 'pt-h', current ? 'Now playing' : 'OpenVibe Radio'));++ text.appendChild(el('strong', 'pt-np-title', current ? current.title : 'Nothing is playing yet'));++ if (current) {++ text.appendChild(el('p', 'pt-np-artist', current.artist + (current.ai_made ? ' · AI-made music' : '')));++ var proof = el('p', 'pt-np-proof');++ proof.appendChild(outLink(licenceLabel(current.licence), current.licence_url));++ if (safeHref(current.source_url)) { proof.appendChild(document.createTextNode(' · ')); proof.appendChild(outLink('Original source', current.source_url)); }++ if (current.changes_note) proof.appendChild(document.createTextNode(' · Changes: ' + current.changes_note));++ text.appendChild(proof);++ } else {++ text.appendChild(el('p', 'pt-np-artist', mayChangeQueue() ? 'Pick a track below to start the music.' : 'The music starts when the host picks a track.'));++ }++ np.appendChild(text);++ }++++ function paintRadio() {++ if (!isRadio || !radioBox) return;++ paintNowPlaying();++ radioBox.textContent = '';++ var queue = (radio && radio.queue) || [];++ radioBox.appendChild(el('h2', 'pt-h', queue.length ? 'Up next · ' + queue.length : 'Up next'));++ if (queue.length) {++ var list = el('ol', 'pt-radio-queue');++ queue.forEach(function (t) {++ var item = el('li', null);++ item.appendChild(el('span', 'pt-radio-track', t.title));++ item.appendChild(el('span', 'pt-radio-by', ' · ' + t.artist + (t.ai_made ? ' · AI-made music' : '') + ' · '));++ var lic = outLink(t.licence === 'creator_grant' ? 'Artist’s permission' : licenceLabel(t.licence), t.licence_url); lic.className = 'pt-radio-lic'; item.appendChild(lic);++ list.appendChild(item);++ });++ radioBox.appendChild(list);++ } else {++ radioBox.appendChild(el('p', 'pt-radio-empty', mayChangeQueue() ? 'Nothing queued yet. Pick a track below.' : 'Nothing queued yet.'));++ }++ if (mayChangeQueue()) {++ var add = el('form', 'pt-radio-add');++ var id = el('select'); id.name = 'track_id'; id.required = true; id.setAttribute('aria-label', 'Choose a Radio track');++ var placeholder = el('option', null, catalogue.length ? 'Choose a Radio track' : 'No Radio tracks yet'); placeholder.value = ''; id.appendChild(placeholder);++ catalogue.forEach(function (t) { var option = el('option', null, t.title + ' · ' + t.artist); option.value = t.id; id.appendChild(option); });++ var submit = el('button', 'pt-remote-btn', 'Add to queue'); submit.type = 'submit'; add.appendChild(id); add.appendChild(submit);++ add.addEventListener('submit', function (event) { event.preventDefault(); radioAction('queue', { track_id: id.value }); id.value = ''; });++ radioBox.appendChild(add);++ var row = el('div', 'pt-radio-actions');++ if (radio && radio.current) {++ var skip = el('button', 'pt-ghost', 'Skip this track'); skip.type = 'button';++ skip.addEventListener('click', function () { radioAction('skip', { expected_id: radio && radio.current && radio.current.id }); });++ row.appendChild(skip);++ }++ if (self && self.subject === owner) {++ var allow = el('label', 'pt-radio-permission');++ var box = el('input'); box.type = 'checkbox'; box.checked = !!(radio && radio.allow_everyone);++ box.addEventListener('change', function () { radioAction('allow', { allow_everyone: box.checked }); });++ allow.appendChild(box); allow.appendChild(document.createTextNode(' Let everyone add and skip tracks'));++ row.appendChild(allow);++ }++ if (row.children.length) radioBox.appendChild(row);++ }++ }++ function radioAction(action, body) {++ var headers = { 'Content-Type': 'application/json' }, t = token();++ if (t) headers.Authorization = 'Bearer ' + t;++ fetch('/api/chat/parties/' + encodeURIComponent(slug) + '/radio/' + 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 || 'Queue change failed'); return v; }); })++ .then(function (v) { radio = v.radio; paintRadio(); })++ .catch(function (e) { flash(e.message); });++ }++// ── the remote ───────────────────────────────────────────────────────────────function pill(text, moves) {remote.textContent = '';@@@ -212,7 +340,7 @@if (!people) return;people.textContent = '';if (guest) {-- people.appendChild(el('h2', 'pt-h', 'Watching'));++ people.appendChild(el('h2', 'pt-h', isRadio ? 'Listening' : 'Watching'));people.appendChild(el('p', 'pt-guest-note', 'Sign in to see who is here.'));return;}@@@ -222,7 +350,8 @@// Here first, then the ones whose seat waits, then the members who are not here.var rank = function (s) { return watching.indexOf(s) >= 0 ? 0 : away[s] ? 1 : 2; };all.sort(function (a, b) { return rank(a) - rank(b) || nameOf(a).localeCompare(nameOf(b)); });-- var h = people.appendChild(el('h2', 'pt-h', watching.length === 1 ? '1 watching' : watching.length + ' watching'));++ var verb = isRadio ? ' listening' : ' watching';++ var h = people.appendChild(el('h2', 'pt-h', watching.length + verb));h.id = 'pt-people-h';var list = el('ul', 'pt-faces');list.setAttribute('aria-labelledby', 'pt-people-h');@@@ -320,10 +449,16 @@if (m.subject && !self) self = { subject: m.subject, name: names[m.subject] || 'You' };if (!sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });catchUp();++ paintRadio();} else if (m.type === 'sync' && m.state) {clock = m.state;if (!clock.paused || clock.position_ms > 0) everPlayed = true;-- if (!src && typeof clock.media === 'string') { src = clock.media; video.src = src; }++ if ((!isRadio || radio && radio.current) && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }++ if (guest) applyGuestClock();++ } else if (m.type === 'radio' && m.state) {++ radio = m.state;++ if (!radio.current) { video.pause(); video.removeAttribute('src'); src = null; }++ paintRadio();} else if (m.type === 'lease' && m.state) {var before = lease && lease.watchers ? lease.watchers : [];lease = m.state;@@@ -364,6 +499,31 @@if (changed) { paintPeople(); paintRemote(); }}, 500);video.addEventListener('play', function () { everPlayed = true; paintVeil(); });++ video.addEventListener('ended', function () {++ if (isRadio && !guest && self && radio && radio.current && (self.subject === owner || radio.allow_everyone))++ radioAction('skip', { expected_id: radio.current.id });++ });++ video.addEventListener('loadedmetadata', applyGuestClock);++ if (isRadio && guest) {++ 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; }++ clock = v.party.clock; radio = v.party.radio;++ if (radio && !radio.current) { video.pause(); video.removeAttribute('src'); src = null; }++ else if (clock && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }++ paintRadio(); applyGuestClock();++ }).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 */ });// ── invite ───────────────────────────────────────────────────────────────────if (invite && !guest) {@@@ -385,6 +545,6 @@invite.appendChild(ib);}-- paintVeil(); paintRemote(); paintPeople();++ paintVeil(); paintRemote(); paintPeople(); paintRadio();connect();})();@@@ -645,6 +645,13 @@ class ChatServer {const access = await rooms.access(room, client.user);const subject = client.user.subject_id;if (!access.read || !subject) { this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'sync.sign_in', message: 'Sign in to change the room\'s clock' }); break; }++ const radioParty = await db.get("SELECT party_kind, radio_track FROM room_parties WHERE room_id = ? AND party_kind = 'radio'", [room.id]);++ if (radioParty) {++ const track = typeof radioParty.radio_track === 'string' ? JSON.parse(radioParty.radio_track) : radioParty.radio_track;++ if (!track || (msg.media !== undefined && msg.media !== track.audio_url)) {++ this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'radio.licence', message: 'Choose a licensed Radio track from the queue' }); break;++ }++ }let holder;try { holder = (await controlLease.state(room, this.roomWatchers(room.id))).holder; } catch (err) { this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'sync.lease', message: 'The room\'s remote could not be read' }); break; }if (holder.kind !== 'person' || holder.subject !== subject) {@@@ -74,10 +74,10 @@ function sourceUrlOf(value) {function mediaFromRow(value) {if (value == null) return null;if (typeof value !== 'string') return value;-- try { return JSON.parse(value); } catch { return null; }++ try { return JSON.parse(value); } catch { return value.startsWith('https://') ? value : null; }}--const kindOfParty = (party) => (party && party.party_kind === 'media' ? 'media' : 'agent_stream');++const kindOfParty = (party) => (party && ['media', 'radio'].includes(party.party_kind) ? party.party_kind : 'agent_stream');/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */function syncOf(party, now = Date.now()) {@@@ -94,7 +94,7 @@ function syncOf(party, now = Date.now()) {function joinUrl(row, kind, rawSecret = null) {const slug = encodeURIComponent(row.slug);-- const base = kind === 'media' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;++ const base = kind === 'media' || kind === 'radio' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;}@@@ -105,7 +105,8 @@ function view(row, party, { rawSecret = null, now = Date.now() } = {}) {kind,agent_stream_id: party.agent_stream_id || null,vod_id: party.vod_id == null ? null : String(party.vod_id),-- ...(kind === 'media' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),++ ...(kind === 'media' || kind === 'radio' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),++ ...(kind === 'radio' ? { radio_track: mediaFromRow(party.radio_track) } : {}),host: party.host_subject,title: row.name,...(kind === 'agent_stream' ? { sync: syncOf(party, now) } : {}),@@@ -133,7 +134,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }host_name: host ? (host.display_name || host.username) : null,member,};-- if (kindOfParty(party) === 'media') {++ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'radio') {const room = { id: row.id, slug: row.slug };out.clock = await syncClock.get(room);if (member) {@@@ -143,6 +144,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }.map((m) => ({ subject: m.subject_id || null, name: m.display_name || m.username, role: m.role }));}}++ if (kindOfParty(party) === 'radio') out.radio = await require('./radio').state(row, party, { user, key });return out;}@@@ -151,6 +153,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }* Returns the party and the one time its raw join secret exists.*/async function create(user, input = {}) {++ if (input && input.radio_track_id) return await require('./radio').start(user, input);if (input && input.media !== undefined && input.media !== null && input.media !== '') return await createMedia(user, input);return await createAgentStream(user, input);}@@@ -183,7 +186,7 @@ async function createAgentStream(user, { agent_stream_id: agentStreamId, vod_id:* the remote granted to the person who started it. The join link is openvibe.party/p/<slug>?k=…, and the secret is* in this answer only.*/--async function createMedia(user, { media, source_url: sourceUrl = null, title, listed = false } = {}) {++async function createMedia(user, { media, source_url: sourceUrl = null, title, listed = false, partyKind = 'media', radioTrack = null } = {}) {if (!user) fail(401, 'party.sign_in', 'Sign in to start a watch party');if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');const chosen = mediaOf(media);@@@ -195,9 +198,9 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, lconst room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });const id = room.id;await db.run(-- `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash, party_kind, media, source_url)-- VALUES (?, NULL, NULL, ?, ?, 'vod', false, ?, ?, 'media', ?, ?)`,-- [id, user.id, user.subject_id || '', !!listed, sha256(raw), JSON.stringify(chosen), source]++ `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash, party_kind, media, source_url, radio_track)++ VALUES (?, NULL, NULL, ?, ?, 'vod', false, ?, ?, ?, ?, ?, ?)`,++ [id, user.id, user.subject_id || '', !!listed, sha256(raw), partyKind, JSON.stringify(chosen), source, radioTrack ? JSON.stringify(radioTrack) : null]);const shared = { id, slug: room.slug };await syncClock.set(shared, user.subject_id, { media: chosen, position_ms: 0, rate: 1, paused: true });@@@ -208,7 +211,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 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 FROM rooms r JOIN room_parties p ON p.room_id = r.id WHERE r.slug = ?', [String(slug || '').toLowerCase()]);return row || null;}@@@ -218,7 +221,7 @@ function partyOf(row) {host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,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,++ party_kind: row.party_kind || 'agent_stream', media: row.media, source_url: row.source_url, radio_track: row.radio_track,} : null;}@@@ -238,7 +241,7 @@ async function mine(user, { limit = 6 } = {}) {const since = new Date(Date.now() - 14 * 86400_000).toISOString();const rows = await db.all(`SELECT r.slug, r.name, p.host_user_id, p.created_atFROM room_members m JOIN rooms r ON r.id = m.room_id JOIN room_parties p ON p.room_id = r.id-- WHERE m.user_id = ? AND p.party_kind = 'media' AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?++ WHERE m.user_id = ? AND p.party_kind IN ('media', 'radio') AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?ORDER BY p.created_at DESC LIMIT ?`, [user.id, since, Math.max(1, Math.min(12, Number(limit) || 6))]);return (rows || []).map((x) => ({id: x.slug, title: x.name, hosting: Number(x.host_user_id) === Number(user.id),@@@ -267,7 +270,7 @@ async function joinWithLink(row, party, user, key) {/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */async function setSync(row, party, user, { position_s: position, playing } = {}) {if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');-- if (kindOfParty(party) === 'media') fail(409, 'party.clock', 'A media party follows the room\'s shared clock; the remote moves it');++ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'radio') fail(409, 'party.clock', 'A media party follows the room\'s shared clock; the remote moves it');if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');let pos = party.position_s;if (position !== undefined) {@@@ -16,6 +16,7 @@ const cache = require('openvibe-shared/cache-policy');const { requireAuth, optionalAuth } = require('../auth/auth');const { limits } = require('../net/actor-limits');const parties = require('../rooms/parties');++const radio = require('../rooms/radio');const router = express.Router();@@@ -35,13 +36,35 @@ router.post('/', requireAuth, createLimit, async (req, res) => {} catch (err) { send(res, err); }});++router.get('/radio/tracks', async (_req, res) => {++ res.set('Cache-Control', 'public, max-age=30').json({ tracks: await radio.catalogue() });++});++++router.get('/:slug/radio', optionalAuth, async (req, res) => {++ try {++ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);++ const out = await radio.state(row, party, { user: req.user || null, key: req.query.k });++ if (!out) return res.status(404).json({ code: 'rooms.not_found' });++ res.set('Cache-Control', 'private, no-store').json({ radio: out });++ } catch (err) { send(res, err); }++});++++router.post('/:slug/radio/:action', requireAuth, createLimit, 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 radio.change(row, party, req.user, req.params.action, req.body || {});++ res.set('Cache-Control', 'private, no-store').json({ radio: out });++ } catch (err) { send(res, err); }++});++router.get('/:slug', optionalAuth, async (req, res) => {try {const row = await parties.bySlug(req.params.slug);const party = parties.partyOf(row);const out = row && party ? await parties.detail(row, party, { user: req.user || null, key: req.query.k }) : null;if (!out) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });-- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out });++ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out, server_time_ms: Date.now() });} catch (err) { send(res, err); }});nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++const db = require('../db/database');++const rooms = require('./rooms');++const parties = require('./parties');++const clock = require('./sync-clock');++++const TRACK_ID = /^rad_[0-9A-HJKMNP-TV-Z]{26}$/;++const LICENCES = new Set(['creator_grant', 'CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0', 'public_domain']);++const base = () => String(process.env.CHAT_RADIO_URL || (process.env.NODE_ENV === 'test' ? '' : 'https://openvibe.download')).replace(/\/+$/, '');++const publicBase = 'https://openvibe.download';++const fail = (status, code, message) => { throw new parties.PartyError(status, code, message); };++const decode = (v) => { if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return null; } };++const trackView = (t) => t && ({ id: t.id, title: t.title, artist: t.artist, source: t.source, source_url: t.source_url,++ licence: t.licence, licence_url: t.licence_url, changes_note: t.changes_note || null, ai_made: !!t.ai_made, audio_url: t.audio_url });++++async function catalogue() {++ if (!base()) return [];++ try {++ const response = await fetch(`${base()}/api/v1/radio/tracks`, { redirect: 'error', signal: AbortSignal.timeout(2500) });++ if (!response.ok) return [];++ const body = await response.json();++ return (Array.isArray(body.tracks) ? body.tracks : []).filter((t) => TRACK_ID.test(t.id)++ && t.audio_url === `${publicBase}/api/v1/radio/tracks/${encodeURIComponent(t.id)}/audio`++ && t.title && t.artist && LICENCES.has(t.licence) && /^https:\/\//.test(t.licence_url)).slice(0, 100).map(trackView);++ } catch { return []; }++}++++async function track(id) {++ if (!TRACK_ID.test(String(id || ''))) fail(422, 'radio.track', 'Choose an OpenVibe Radio track');++ if (!base()) fail(503, 'radio.unavailable', 'OpenVibe Radio is not configured');++ let response;++ try { response = await fetch(`${base()}/api/v1/radio/tracks/${encodeURIComponent(id)}`, { redirect: 'error', signal: AbortSignal.timeout(2500) }); }++ catch { fail(503, 'radio.unavailable', 'OpenVibe Radio did not answer'); }++ if (!response.ok) fail(422, 'radio.track', 'That track is not published on OpenVibe Radio');++ const value = (await response.json().catch(() => null))?.track;++ const expected = `${publicBase}/api/v1/radio/tracks/${encodeURIComponent(id)}/audio`;++ if (!value || value.id !== id || value.audio_url !== expected || !value.title || !value.artist || !LICENCES.has(value.licence) || !/^https:\/\//.test(value.licence_url) || !['creator', 'creative_commons', 'public_domain', 'ai'].includes(value.source))++ fail(502, 'radio.catalogue', 'OpenVibe Radio returned incomplete track rights');++ return trackView(value);++}++++async function start(user, input) {++ const chosen = await track(input.radio_track_id);++ return parties.createMedia(user, { media: chosen.audio_url, source_url: `${publicBase}/radio/tracks/${chosen.id}`,++ title: input.title || `${chosen.title} listening party`, partyKind: 'radio', radioTrack: chosen });++}++++async function state(row, party, { user = null, key = null } = {}) {++ if (!row || !party || party.party_kind !== 'radio') return null;++ if (!await parties.mayView(row, party, user, key)) return null;++ const fresh = await db.get('SELECT radio_track, radio_queue, radio_allow_all, radio_version FROM room_parties WHERE room_id = ?', [row.id]);++ return { current: decode(fresh.radio_track), queue: decode(fresh.radio_queue) || [], allow_everyone: !!fresh.radio_allow_all, version: Number(fresh.radio_version) || 0 };++}++++async function change(row, party, user, action, input = {}) {++ const access = user ? await rooms.access(row, user) : null;++ if (!access || !access.read) fail(404, 'rooms.not_found', 'No such room');++ if (!party || party.party_kind !== 'radio' || party.ended_at) fail(409, 'radio.party', 'This is not an active listening party');++ const isHost = user.subject_id === party.host_subject;++ const shared = { id: row.id, slug: row.slug };++ for (let tries = 0; tries < 5; tries++) {++ const old = await state(row, party, { user });++ if (!isHost && !old.allow_everyone) fail(403, 'radio.rights', 'Only the host may change the queue');++ const queue = old.queue.slice();++ let current = old.current;++ if (action === 'queue') {++ if (queue.length >= 100) fail(409, 'radio.queue_full', 'The queue is full');++ queue.push(await track(input.track_id));++ } else if (action === 'skip') {++ if (input.expected_id && input.expected_id !== (current && current.id)) return old;++ current = null;++ while (queue.length && !current) {++ const next = queue.shift();++ try { current = await track(next.id); } catch (err) { if (!(err instanceof parties.PartyError) || err.status >= 500) throw err; }++ }++ } else if (action === 'allow') {++ if (!isHost) fail(403, 'radio.rights', 'Only the host changes queue rights');++ } else fail(422, 'radio.action', 'Unknown queue action');++ const allow = action === 'allow' ? input.allow_everyone === true : old.allow_everyone;++ const updated = await db.get(`UPDATE room_parties SET radio_track = ?, radio_queue = ?, radio_allow_all = ?, radio_version = radio_version + 1++ WHERE room_id = ? AND radio_version = ? RETURNING radio_version`, [current ? JSON.stringify(current) : null, JSON.stringify(queue), allow, row.id, old.version]);++ if (!updated) continue;++ if (action === 'skip') {++ const sync = await clock.set(shared, user.subject_id, current++ ? { media: current.audio_url, position_ms: 0, paused: false, rate: 1 }++ : { position_ms: 0, paused: true });++ require('../chat/chat-server').broadcastToRoom(row.id, { type: 'sync', room: row.slug, state: sync });++ }++ const result = { current, queue, allow_everyone: allow, version: Number(updated.radio_version) };++ require('../chat/chat-server').broadcastToRoom(row.id, { type: 'radio', room: row.slug, state: result });++ return result;++ }++ fail(409, 'radio.conflict', 'The queue changed; try again');++}++++module.exports = { track, catalogue, start, state, change };@@@ -26,6 +26,9 @@ const { renderPage, contentSecurityPolicy, esc } = require('./layout');const { createSessionRoutes } = require('./session');const { viewerOf } = require('./pages');const parties = require('../rooms/parties');++const radio = require('../rooms/radio');++const radioSource = (v) => ({ creator: 'Creator upload', creative_commons: 'Creative Commons', public_domain: 'Public domain', ai: 'AI-made music' })[v] || v;++const radioLicence = (v) => v === 'creator_grant' ? 'Creator’s permission to stream' : v === 'public_domain' ? 'Public domain mark' : v;const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');const SITE = Object.freeze({ name: 'OpenVibe.Party', service: 'party', links: [{ key: 'start', label: 'Start a party', href: '/' }] });@@@ -138,7 +141,7 @@ function createPartyRoutes({ config }) {const actor = await viewerOf(req);const signedIn = actor.kind === 'user';const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';-- const picks = await latestWatchable();++ const [picks, tracks] = await Promise.all([latestWatchable(), radio.catalogue()]);const mine = signedIn ? await parties.mine(actor.user, { limit: 6 }).catch(() => []) : [];const since = (iso) => {const days = Math.floor((Date.now() - Date.parse(iso)) / 86400_000);@@@ -176,6 +179,9 @@ ${error}${start}</header>${yours}++<section class="pty-radio" aria-labelledby="pty-radio-h"><h2 id="pty-radio-h">OpenVibe Radio</h2><p>Listen together using music its creator has shared for streaming. Every track shows its source and licence.</p>++${tracks.length ? `<ul class="pty-radio-list">${tracks.slice(0, 12).map((t) => `<li><div><strong>${esc(t.title)}</strong><span>${esc(t.artist)}${t.ai_made ? ' · AI-made music' : ''}</span><small>Source: ${esc(radioSource(t.source))} · <a href="${esc(t.licence_url)}">${esc(radioLicence(t.licence))}</a>${t.source_url ? ` · <a href="${esc(t.source_url)}">Original source</a>` : ''}</small></div><a class="oc-button" href="/new?radio=${encodeURIComponent(t.id)}">Listen together</a></li>`).join('')}</ul>` : '<p>No Radio tracks are published yet. Creators can mark their own audio uploads as music on OpenVibe.Download.</p>'}++<p>Spotify, Apple Music and YouTube Music listening is coming after those services approve it.</p></section>${grid}<section class="pty-how" aria-labelledby="pty-how-h"><h2 id="pty-how-h">How it works</h2>@@@ -193,6 +199,12 @@ ${grid}router.get('/new', async (req, res) => {const actor = await viewerOf(req);if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);++ if (req.query.radio) {++ let chosen;++ try { chosen = await radio.track(String(req.query.radio)); } catch { return res.redirect(303, '/?error=That%20Radio%20track%20is%20not%20available'); }++ return page(req, res, 200, { actor, title: 'Start a listening party', path: '/new', robots: 'noindex, nofollow', footer: 'compact', partyHome: true,++ body: `<section class="oc-party-confirm pty-confirm"><h1>Start a listening party?</h1><p class="oc-lede"><strong>${esc(chosen.title)}</strong> by ${esc(chosen.artist)}</p><p>Source: ${esc(radioSource(chosen.source))} · <a href="${esc(chosen.licence_url)}">${esc(radioLicence(chosen.licence))}</a>${chosen.ai_made ? ' · AI-made music' : ''}</p><p>Everyone hears the track at the party’s position. You control the queue and can allow everyone to add and skip.</p><form method="post" action="/new"><input type="hidden" name="radio_track_id" value="${esc(chosen.id)}"><button class="oc-button" type="submit">Start listening</button></form></section>` });++ }const asked = String(req.query.media || '').trim();if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);const want = await playable({ media: asked, title: String(req.query.title || '').replace(/\s+/g, ' ').trim().slice(0, 200), source: req.query.source_url ? String(req.query.source_url).slice(0, 2048) : '' });@@@ -227,6 +239,14 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''const origin = String(req.get('origin') || '');const fetchSite = String(req.get('sec-fetch-site') || '');if ((origin && origin !== base) || (fetchSite && fetchSite !== 'same-origin')) return res.status(403).type('text/plain').send('Start a party from openvibe.party itself.');++ if (req.body && req.body.radio_track_id) {++ try {++ const out = await radio.start(actor.user, { radio_track_id: String(req.body.radio_track_id) });++ return res.redirect(303, `/p/${encodeURIComponent(out.party.id)}?k=${encodeURIComponent(out.join_secret)}`);++ } catch (err) {++ return res.redirect(303, `/?error=${encodeURIComponent(err instanceof parties.PartyError ? err.message : 'That listening party could not be started')}`);++ }++ }const asked = String((req.body && req.body.media) || '').trim();if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);// A watch page posted here directly (not through the confirmation) is made playable the same way.@@@ -269,15 +289,15 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''actor, title: out.title, path: `/p/${out.id}`, robots: 'noindex, nofollow', footer: 'compact',description: 'Watch together on OpenVibe.',mainAttrs: `data-party="${esc(out.id)}"`, party: true, wide: true,-- body: `<article class="oc-party">++ 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></header>--<div class="oc-party-stage" data-party-stage aria-label="The video"></div>++<div class="oc-party-stage" data-party-stage aria-label="${out.kind === 'radio' ? 'The music stage' : 'The video'}"></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><div class="oc-party-invite" data-party-invite aria-label="Invite"></div></article>--<script type="application/json" id="party-data">${escJson({ party: out })}</script>`,++<script type="application/json" id="party-data">${escJson({ party: out, server_time_ms: Date.now() })}</script>`,});} catch (err) { next(err); }});nnew file mode 100644@@@ -0,0 +1,93 @@++'use strict';++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, suite } = require('./helpers');++++const t = suite('party radio');++const ID = 'rad_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const AUDIO = `https://openvibe.download/api/v1/radio/tracks/${ID}/audio`;++const TRACK = { id: ID, title: 'Friday Song', artist: 'Ann', source: 'creator', source_url: null,++ licence: 'creator_grant', licence_url: 'https://openvibe.download/radio/licence', ai_made: false, audio_url: AUDIO };++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 });++++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: [TRACK] }));++ if (req.url === `/api/v1/radio/tracks/${ID}`) return res.end(JSON.stringify({ track: TRACK }));++ 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 published track starts a listening party with rights on the stage', async () => {++ const made = await api('POST', '/', ann, { radio_track_id: ID });++ assert.strictEqual(made.status, 201, made.text);++ party = made.body.party;++ key = made.body.join_secret;++ assert.strictEqual(party.kind, 'radio');++ assert.strictEqual(party.media, AUDIO);++ const detail = await api('GET', `/${party.id}`, ann);++ assert.strictEqual(detail.body.party.radio.current.licence, 'creator_grant');++ assert.strictEqual(detail.body.party.clock.position_ms, 0);++ assert.strictEqual(detail.body.party.clock.paused, true);++ const guest = await api('GET', `/${party.id}?k=${made.body.join_secret}`, null);++ assert.strictEqual(guest.status, 200);++ assert.strictEqual(guest.body.party.radio.current.source, 'creator');++});++++t('the host controls queue rights and a skip starts the next track at zero', async () => {++ const join = await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: bob.token, body: { k: key } });++ assert.strictEqual(join.status, 200, join.text);++ const forbidden = await api('POST', `/${party.id}/radio/queue`, bob, { track_id: ID });++ assert.strictEqual(forbidden.status, 403);++ assert.strictEqual(forbidden.body.code, 'radio.rights');++ assert.strictEqual((await api('POST', `/${party.id}/radio/queue`, ann, { track_id: 'https://example.org/song.mp3' })).body.code, 'radio.track');++ const queued = await api('POST', `/${party.id}/radio/queue`, ann, { track_id: ID });++ assert.strictEqual(queued.status, 200, queued.text);++ assert.strictEqual(queued.body.radio.queue.length, 1);++ assert.strictEqual((await api('POST', `/${party.id}/radio/allow`, bob, { allow_everyone: true })).status, 403);++ const allowed = await api('POST', `/${party.id}/radio/allow`, ann, { allow_everyone: true });++ assert.strictEqual(allowed.body.radio.allow_everyone, true);++ assert.strictEqual((await api('POST', `/${party.id}/radio/queue`, bob, { track_id: ID })).status, 200);++ const skipped = await api('POST', `/${party.id}/radio/skip`, bob, {});++ assert.strictEqual(skipped.status, 200, skipped.text);++ assert.strictEqual(skipped.body.radio.current.id, ID);++ const now = await api('GET', `/${party.id}`, ann);++ assert.strictEqual(now.body.party.clock.media, AUDIO);++ assert.strictEqual(now.body.party.clock.position_ms, 0);++ assert.strictEqual(now.body.party.clock.paused, false);++});++++t('a late joiner receives the shared clock at the right second', async () => {++ const clock = require('../server/rooms/sync-clock');++ const row = await require('../server/rooms/parties').bySlug(party.id);++ const room = { id: row.id, slug: row.slug };++ await clock.set(room, ann.subject_id, { media: AUDIO, position_ms: 12000, paused: false, rate: 1 }, { now: Date.now() - 5000 });++ const joined = await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: cat.token, body: { k: key } });++ assert.strictEqual(joined.status, 200, joined.text);++ const ws = await h.ws({ token: cat.token, origin: 'https://openvibe.party' });++ ws.sendJson({ type: 'join_room', room: party.id });++ await ws.next((m) => m.type === 'room_joined');++ const sync = await ws.next((m) => m.type === 'sync');++ const position = sync.state.position_ms + (Date.now() - sync.state.at_ms) * sync.state.rate;++ assert.ok(position >= 17000 && position < 18500, `late joiner position ${position}`);++ 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');++ host.sendJson({ type: 'sync', media: 'https://openvibe.media/unlicensed.mp3', position_ms: 0 });++ assert.strictEqual((await host.next((m) => m.type === 'sync_error')).code, 'radio.licence');++ host.close();++ ws.close();++});++++t('close', async () => { await h.close(); await new Promise((r) => service.close(r)); delete process.env.CHAT_RADIO_URL; });++t.run();@@@ -124,6 +124,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",'server/inbox/actions.js': "an inbox owner's action addresses: https only, assertPublicUrl when saved and safeLookup on every call, no redirects followed, 8 s",'server/web/party-pages.js': 'OpenVibe.Video (fixed: https://openvibe.video, or a test\'s CHAT_PARTY_VIDEO_URL), fixed /api/v1/video/watchable paths; a pasted link is only a query parameter, never fetched; redirects refused, 2.5 s, cached a minute',++ 'server/rooms/radio.js': 'OpenVibe.Download Radio catalogue (fixed https origin, or a test\'s CHAT_RADIO_URL); track ids match rad_ ULIDs, redirects refused, 2.5 s','server/chat/link-cards.js': 'OpenVibe.Video and OpenVibe.Actor (fixed https origins, or a test\'s CHAT_LINK_VIDEO_URL / CHAT_LINK_ACTOR_URL), fixed paths (a watchable lookup, /voices/<id>.json, /skills/<slug>.json); a pasted link is a query parameter or a path segment only, never fetched; redirects refused, 2.5 s, cached a minute','server/web/room-embed-pages.js': 'not the server: the sign-in window\'s own script posts to /embed/bind on this same site',};
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -0,0 +1,13 @@
+-- phase: expand
+-- Radio parties share the media clock and hold an approved Radio track plus a durable queue.
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_track jsonb;
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_queue jsonb NOT NULL DEFAULT '[]'::jsonb;
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_allow_all boolean NOT NULL DEFAULT false;
+ALTER TABLE room_parties ADD COLUMN IF NOT EXISTS radio_version integer NOT NULL DEFAULT 0;
+ALTER TABLE room_parties DROP CONSTRAINT IF EXISTS room_parties_kind_allowed;
+ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_allowed CHECK (party_kind IN ('agent_stream', 'media', 'radio'));
+ALTER TABLE room_parties DROP CONSTRAINT IF EXISTS room_parties_kind_source;
+ALTER TABLE room_parties ADD CONSTRAINT room_parties_kind_source CHECK (
+ (party_kind = 'agent_stream' AND agent_stream_id IS NOT NULL AND media IS NULL)
+ OR (party_kind IN ('media', 'radio') AND agent_stream_id IS NULL AND media IS NOT NULL)
+);
@@ -53,3 +53,12 @@
@@ -53,3 +53,12 @@
.pty-mine-list a:hover, .pty-mine-list a:focus-visible { border-color: var(--oc-accent); }
.pty-mine-list a:hover, .pty-mine-list a:focus-visible { border-color: var(--oc-accent); }
.pty-mine-list strong { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pty-mine-list strong { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pty-mine-list span { font-size: 13px; color: var(--oc-muted); }
.pty-mine-list span { font-size: 13px; color: var(--oc-muted); }
+.pty-radio { margin-top: 40px; padding: 22px; border-radius: 16px; background: var(--oc-panel); border: 1px solid var(--oc-line); }
+.pty-radio h2 { margin: 0 0 8px; font-size: 1.3rem; }
+.pty-radio p { color: var(--oc-muted); line-height: 1.5; }
+.pty-radio-list { list-style: none; padding: 0; display: grid; gap: 10px; }
+.pty-radio-list li { display: flex; justify-content: space-between; align-items: center; gap: 16px; padding: 12px; border: 1px solid var(--oc-line); border-radius: 12px; min-width: 0; }
+.pty-radio-list li div { display: grid; gap: 3px; min-width: 0; }
+.pty-radio-list li span, .pty-radio-list li small { color: var(--oc-muted); overflow-wrap: anywhere; }
+.pty-radio-list li a.oc-button { flex: none; min-height: 44px; display: inline-flex; align-items: center; padding: 0 16px; }
+@media (max-width: 600px) { .pty-radio { padding: 16px; } .pty-radio-list li { align-items: stretch; flex-direction: column; } .pty-radio-list li a.oc-button { align-self: flex-start; } }
@@ -46,6 +46,49 @@ main[data-party] [hidden] { display: none !important; }
@@ -46,6 +46,49 @@ main[data-party] [hidden] { display: none !important; }
box-shadow: 0 18px 50px rgba(0, 0, 0, .35);
box-shadow: 0 18px 50px rgba(0, 0, 0, .35);
}
}
.pt-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
.pt-frame video { display: block; width: 100%; height: 100%; object-fit: contain; background: #000; }
+.pt-frame.is-radio {
+ aspect-ratio: auto; width: 100%; display: grid; gap: 14px; padding: 18px;
+ background: var(--pt-panel); border: 1px solid var(--pt-line); box-shadow: none;
+}
+.pt-frame.is-radio audio { display: block; width: 100%; height: 44px; }
+.pt-frame.is-radio .pt-veil, .pt-frame.is-radio.is-guest .pt-veil {
+ position: static; inset: auto; display: flex; flex-wrap: wrap; align-items: center; gap: 10px 14px; padding: 0;
+ background: none; color: var(--pt-muted); font-size: 14px; font-weight: 650; text-align: left; pointer-events: auto;
+}
+.pt-frame.is-radio.is-guest .pt-veil { flex-direction: row; justify-content: space-between; }
+.pt-frame.is-radio .pt-veil:empty, .pt-frame.is-radio .pt-veil[hidden],
+.pt-frame.is-radio.is-empty audio, .pt-frame.is-radio.is-empty .pt-veil { display: none; }
+.pt-frame.is-radio .pt-veil-line { max-width: none; }
+.pt-frame.is-radio .pt-drift { left: auto; right: 12px; }
+.pt-np { display: flex; gap: 16px; align-items: center; min-width: 0; }
+.pt-np-cover {
+ flex: none; width: 104px; height: 104px; border-radius: 14px; display: grid; place-items: center;
+ font-size: 44px; font-weight: 800; color: #fff; text-shadow: 0 1px 2px rgba(0, 0, 0, .25);
+ background: linear-gradient(135deg, hsl(var(--np-h, 260) 68% 56%), hsl(calc(var(--np-h, 260) + 48) 64% 34%));
+}
+.pt-np-text { min-width: 0; }
+.pt-np-text .pt-h { margin-bottom: 4px; }
+.pt-np-title { display: block; font-size: clamp(20px, 2.4vw, 30px); line-height: 1.15; overflow-wrap: anywhere; }
+.pt-np-artist { margin: 4px 0 0; font-size: 15px; }
+.pt-np-proof { margin: 6px 0 0; font-size: 13px; color: var(--pt-muted); }
+.pt-np-proof a { color: var(--pt-accent); }
+.pt-radio { padding: 18px; border-radius: 16px; border: 1px solid var(--pt-line); background: var(--pt-panel); }
+.pt-radio .pt-h:not(:first-child) { margin-top: 18px; }
+.pt-radio-title { font-size: clamp(20px, 2vw, 28px); }
+.pt-radio-meta { margin: 6px 0; font-size: 14px; color: var(--pt-muted); }
+.pt-radio a { color: var(--pt-accent); }
+.pt-radio-queue { margin: 0; padding-left: 22px; font-size: 14px; line-height: 1.7; }
+.pt-radio-track { font-weight: 700; }
+.pt-radio-by, .pt-radio-lic { color: var(--pt-muted); font-size: 13px; }
+.pt-radio-empty { margin: 0; font-size: 14px; color: var(--pt-muted); }
+.pt-radio-add { margin-top: 14px; }
+.pt-ghost { min-height: 40px; padding: 0 16px; border: 1px solid var(--pt-line); border-radius: 999px; background: none; color: var(--pt-text); font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
+.pt-ghost:hover { border-color: var(--pt-accent); }
+.pt-radio-actions { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-top: 16px; }
+.pt-radio-add { display: flex; gap: 8px; flex: 1 1 280px; }
+.pt-radio-add select { min-width: 0; flex: 1; min-height: 44px; border-radius: 9px; padding: 0 10px; background: var(--pt-bg); color: var(--pt-text); border: 1px solid var(--pt-line); }
+.pt-radio-permission { display: inline-flex; align-items: center; gap: 8px; font-size: 14px; }
+.pt-radio-permission input { width: 18px; height: 18px; vertical-align: middle; }
/* Honest states over the picture: waiting for the host, joining, reconnecting. */
/* Honest states over the picture: waiting for the host, joining, reconnecting. */
.pt-veil {
.pt-veil {
position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center;
position: absolute; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center;
@@ -137,6 +180,12 @@ main[data-party].is-guest [data-party-chat] { min-height: 0; flex: 0 0 auto; ali
@@ -137,6 +180,12 @@ main[data-party].is-guest [data-party-chat] { min-height: 0; flex: 0 0 auto; ali
[data-party-stage] { gap: 8px; }
[data-party-stage] { gap: 8px; }
.pt-head { order: 3; padding: 0 max(14px, env(safe-area-inset-left, 0px)); }
.pt-head { order: 3; padding: 0 max(14px, env(safe-area-inset-left, 0px)); }
.pt-frame { order: 1; width: 100%; border-radius: 0; box-shadow: none; position: sticky; top: var(--pt-nav); z-index: 2; }
.pt-frame { order: 1; width: 100%; border-radius: 0; box-shadow: none; position: sticky; top: var(--pt-nav); z-index: 2; }
+ .pt-frame.is-radio { padding: 12px 14px; gap: 10px; border-radius: 0; border-left: 0; border-right: 0; }
+ .pt-np { gap: 12px; }
+ .pt-np-cover { width: 64px; height: 64px; font-size: 28px; border-radius: 12px; }
+ .pt-np-title { font-size: 19px; }
+ .pt-np-artist { font-size: 14px; }
+ .pt-radio { order: 4; margin: 0 12px; }
.pt-bar { order: 2; padding: 0 max(12px, env(safe-area-inset-left, 0px)); }
.pt-bar { order: 2; padding: 0 max(12px, env(safe-area-inset-left, 0px)); }
.pt-flash { order: 4; margin: 0 14px; }
.pt-flash { order: 4; margin: 0 14px; }
.pt-title { font-size: 1.05rem; }
.pt-title { font-size: 1.05rem; }
@@ -23,7 +23,8 @@
@@ -23,7 +23,8 @@
if (data && data.party) {
if (data && data.party) {
var v = data.party;
var v = data.party;
data = {
data = {
- slug: v.id, title: v.title, media: v.media, source_url: v.source_url, member: v.member,
+ slug: v.id, title: v.title, kind: v.kind, radio: v.radio || 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 },
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 }; }),
// The shell's join_url never carries the key (the secret is not echoed into a page): only a URL that has one
// The shell's join_url never carries the key (the secret is not echoed into a page): only a URL that has one
@@ -43,6 +44,10 @@
@@ -43,6 +44,10 @@
(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', radio = data.radio || null, guestListening = false;
+ var serverOffset = Number(data.server_time_ms) ? Number(data.server_time_ms) - Date.now() : 0;
+ var bestClockRtt = Infinity;
+ var catalogue = [];
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,12 +84,13 @@
@@ -79,12 +84,13 @@
if (guest) root.classList.add('is-guest');
if (guest) root.classList.add('is-guest');
// ── the stage ────────────────────────────────────────────────────────────────
// ── the stage ────────────────────────────────────────────────────────────────
- var video = el('video');
- video.setAttribute('data-ov-player', '');
+ var video = el(isRadio ? 'audio' : 'video');
+ if (!isRadio) video.setAttribute('data-ov-player', '');
video.setAttribute('playsinline', '');
video.setAttribute('playsinline', '');
video.preload = 'auto';
video.preload = 'auto';
- video.controls = !guest; // a guest's player would not follow the party: the invitation stands over it
+ video.controls = !guest || isRadio;
var src = typeof (clock && clock.media) === 'string' ? clock.media : (typeof data.media === 'string' ? data.media : null);
var src = typeof (clock && clock.media) === 'string' ? clock.media : (typeof data.media === 'string' ? data.media : null);
+ if (isRadio && radio && !radio.current) src = null;
if (src) video.src = src;
if (src) video.src = src;
var frame = el('div', 'pt-frame');
var frame = el('div', 'pt-frame');
frame.appendChild(video);
frame.appendChild(video);
@@ -95,6 +101,11 @@
@@ -95,6 +101,11 @@
var floats = el('div', 'pt-floats'); // reactions rising over the picture
var floats = el('div', 'pt-floats'); // reactions rising over the picture
floats.setAttribute('aria-hidden', 'true');
floats.setAttribute('aria-hidden', 'true');
frame.appendChild(veil); frame.appendChild(drift); frame.appendChild(floats);
frame.appendChild(veil); frame.appendChild(drift); frame.appendChild(floats);
+ if (isRadio) frame.classList.add('is-radio');
+ // A listening party has no picture: the stage is the now-playing card (cover, title, artist, the licence), then
+ // the player, then the status line.
+ var np = null;
+ if (isRadio) { np = el('div', 'pt-np'); frame.insertBefore(np, video); }
// The shell's own header (the title and who started it) leads; without one, the page writes its own.
// The shell's own header (the title and who started it) leads; without one, the page writes its own.
var shellHead = root.querySelector('.oc-party-head');
var shellHead = root.querySelector('.oc-party-head');
var head = shellHead || el('div', 'pt-head');
var head = shellHead || el('div', 'pt-head');
@@ -113,6 +124,11 @@
@@ -113,6 +124,11 @@
stage.appendChild(head);
stage.appendChild(head);
stage.appendChild(frame);
stage.appendChild(frame);
if (!guest) stage.appendChild(bar);
if (!guest) stage.appendChild(bar);
+ if (isRadio) {
+ var radioBox = el('section', 'pt-radio');
+ radioBox.setAttribute('aria-label', 'OpenVibe Radio queue');
+ stage.appendChild(radioBox);
+ }
if (remote && !guest) bar.appendChild(remote);
if (remote && !guest) bar.appendChild(remote);
var react = el('div', 'pt-react');
var react = el('div', 'pt-react');
react.setAttribute('aria-label', 'React');
react.setAttribute('aria-label', 'React');
@@ -135,6 +151,20 @@
@@ -135,6 +151,20 @@
function holder() { return lease && lease.holder ? lease.holder : { kind: 'nobody', subject: null }; }
function holder() { return lease && lease.holder ? lease.holder : { kind: 'nobody', subject: null }; }
function iHold() { var h = holder(); return !!(self && h.kind === 'person' && h.subject === self.subject); }
function iHold() { var h = holder(); return !!(self && h.kind === 'person' && h.subject === self.subject); }
function paintVeil() {
function paintVeil() {
+ if (isRadio && guest) {
+ frame.classList.add('is-guest');
+ veil.textContent = '';
+ if (!guestListening) {
+ veil.appendChild(el('span', 'pt-veil-line', 'Listen at the party’s position, no account needed'));
+ var start = el('button', 'pt-join', 'Start listening');
+ start.type = 'button';
+ start.addEventListener('click', function () { guestListening = true; applyGuestClock(); paintVeil(); });
+ veil.appendChild(start);
+ }
+ veil.hidden = guestListening;
+ frame.classList.toggle('is-waiting', !guestListening);
+ return;
+ }
if (guest) {
if (guest) {
veil.textContent = '';
veil.textContent = '';
var host = data.host && data.host.name ? data.host.name : null;
var host = data.host && data.host.name ? data.host.name : null;
@@ -153,6 +183,104 @@
@@ -153,6 +183,104 @@
frame.classList.toggle('is-waiting', !!text);
frame.classList.toggle('is-waiting', !!text);
}
}
+ function applyGuestClock() {
+ if (!isRadio || !guest || !guestListening || !clock) return;
+ var serverNow = Date.now() + serverOffset;
+ var target = Math.max(0, Number(clock.position_ms || 0) + (!clock.paused ? Math.max(0, serverNow - Number(clock.at_ms || serverNow)) * Number(clock.rate || 1) : 0)) / 1000;
+ if (Math.abs(video.currentTime - target) > 1) { try { video.currentTime = target; } catch (e) { /* metadata loading */ } }
+ if (clock.paused) video.pause();
+ else video.play().catch(function () { /* browser gesture required */ });
+ }
+ function licenceLabel(value) {
+ return { creator_grant: 'Streamed with the artist’s permission', public_domain: 'Public domain', 'CC0-1.0': 'CC0 1.0',
+ 'CC-BY-4.0': 'CC BY 4.0', 'CC-BY-SA-4.0': 'CC BY-SA 4.0' }[value] || value;
+ }
+ function safeHref(u) { return typeof u === 'string' && /^https:\/\//i.test(u) ? u : null; }
+ function outLink(text, href) {
+ var url = safeHref(href), a = el(url ? 'a' : 'span', null, text);
+ if (url) { a.href = url; a.target = '_blank'; a.rel = 'noopener'; }
+ return a;
+ }
+ function mayChangeQueue() { return !guest && self && (self.subject === owner || (radio && radio.allow_everyone)); }
+ function paintNowPlaying() {
+ if (!np) return;
+ np.textContent = '';
+ var current = radio && radio.current;
+ frame.classList.toggle('is-empty', !current); // no track: no dead player, no second "nothing to play" line
+ var cover = el('div', 'pt-np-cover', current ? (String(current.title || '').trim().charAt(0) || '♪').toUpperCase() : '♪');
+ cover.setAttribute('aria-hidden', 'true');
+ var seed = String(current ? current.id || current.title : 'radio'), hue = 0;
+ for (var i = 0; i < seed.length; i++) hue = (hue * 31 + seed.charCodeAt(i)) % 360;
+ cover.style.setProperty('--np-h', String(hue));
+ np.appendChild(cover);
+ var text = el('div', 'pt-np-text');
+ text.appendChild(el('p', 'pt-h', current ? 'Now playing' : 'OpenVibe Radio'));
+ text.appendChild(el('strong', 'pt-np-title', current ? current.title : 'Nothing is playing yet'));
+ if (current) {
+ text.appendChild(el('p', 'pt-np-artist', current.artist + (current.ai_made ? ' · AI-made music' : '')));
+ var proof = el('p', 'pt-np-proof');
+ proof.appendChild(outLink(licenceLabel(current.licence), current.licence_url));
+ if (safeHref(current.source_url)) { proof.appendChild(document.createTextNode(' · ')); proof.appendChild(outLink('Original source', current.source_url)); }
+ if (current.changes_note) proof.appendChild(document.createTextNode(' · Changes: ' + current.changes_note));
+ text.appendChild(proof);
+ } else {
+ text.appendChild(el('p', 'pt-np-artist', mayChangeQueue() ? 'Pick a track below to start the music.' : 'The music starts when the host picks a track.'));
+ }
+ np.appendChild(text);
+ }
+
+ function paintRadio() {
+ if (!isRadio || !radioBox) return;
+ paintNowPlaying();
+ radioBox.textContent = '';
+ var queue = (radio && radio.queue) || [];
+ radioBox.appendChild(el('h2', 'pt-h', queue.length ? 'Up next · ' + queue.length : 'Up next'));
+ if (queue.length) {
+ var list = el('ol', 'pt-radio-queue');
+ queue.forEach(function (t) {
+ var item = el('li', null);
+ item.appendChild(el('span', 'pt-radio-track', t.title));
+ item.appendChild(el('span', 'pt-radio-by', ' · ' + t.artist + (t.ai_made ? ' · AI-made music' : '') + ' · '));
+ var lic = outLink(t.licence === 'creator_grant' ? 'Artist’s permission' : licenceLabel(t.licence), t.licence_url); lic.className = 'pt-radio-lic'; item.appendChild(lic);
+ list.appendChild(item);
+ });
+ radioBox.appendChild(list);
+ } else {
+ radioBox.appendChild(el('p', 'pt-radio-empty', mayChangeQueue() ? 'Nothing queued yet. Pick a track below.' : 'Nothing queued yet.'));
+ }
+ if (mayChangeQueue()) {
+ var add = el('form', 'pt-radio-add');
+ var id = el('select'); id.name = 'track_id'; id.required = true; id.setAttribute('aria-label', 'Choose a Radio track');
+ var placeholder = el('option', null, catalogue.length ? 'Choose a Radio track' : 'No Radio tracks yet'); placeholder.value = ''; id.appendChild(placeholder);
+ catalogue.forEach(function (t) { var option = el('option', null, t.title + ' · ' + t.artist); option.value = t.id; id.appendChild(option); });
+ var submit = el('button', 'pt-remote-btn', 'Add to queue'); submit.type = 'submit'; add.appendChild(id); add.appendChild(submit);
+ add.addEventListener('submit', function (event) { event.preventDefault(); radioAction('queue', { track_id: id.value }); id.value = ''; });
+ radioBox.appendChild(add);
+ var row = el('div', 'pt-radio-actions');
+ if (radio && radio.current) {
+ var skip = el('button', 'pt-ghost', 'Skip this track'); skip.type = 'button';
+ skip.addEventListener('click', function () { radioAction('skip', { expected_id: radio && radio.current && radio.current.id }); });
+ row.appendChild(skip);
+ }
+ if (self && self.subject === owner) {
+ var allow = el('label', 'pt-radio-permission');
+ var box = el('input'); box.type = 'checkbox'; box.checked = !!(radio && radio.allow_everyone);
+ box.addEventListener('change', function () { radioAction('allow', { allow_everyone: box.checked }); });
+ allow.appendChild(box); allow.appendChild(document.createTextNode(' Let everyone add and skip tracks'));
+ row.appendChild(allow);
+ }
+ if (row.children.length) radioBox.appendChild(row);
+ }
+ }
+ function radioAction(action, body) {
+ var headers = { 'Content-Type': 'application/json' }, t = token();
+ if (t) headers.Authorization = 'Bearer ' + t;
+ fetch('/api/chat/parties/' + encodeURIComponent(slug) + '/radio/' + 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 || 'Queue change failed'); return v; }); })
+ .then(function (v) { radio = v.radio; paintRadio(); })
+ .catch(function (e) { flash(e.message); });
+ }
+
// ── the remote ───────────────────────────────────────────────────────────────
// ── the remote ───────────────────────────────────────────────────────────────
function pill(text, moves) {
function pill(text, moves) {
remote.textContent = '';
remote.textContent = '';
@@ -212,7 +340,7 @@
@@ -212,7 +340,7 @@
if (!people) return;
if (!people) return;
people.textContent = '';
people.textContent = '';
if (guest) {
if (guest) {
- people.appendChild(el('h2', 'pt-h', 'Watching'));
+ people.appendChild(el('h2', 'pt-h', isRadio ? 'Listening' : 'Watching'));
people.appendChild(el('p', 'pt-guest-note', 'Sign in to see who is here.'));
people.appendChild(el('p', 'pt-guest-note', 'Sign in to see who is here.'));
return;
return;
}
}
@@ -222,7 +350,8 @@
@@ -222,7 +350,8 @@
// Here first, then the ones whose seat waits, then the members who are not here.
// Here first, then the ones whose seat waits, then the members who are not here.
var rank = function (s) { return watching.indexOf(s) >= 0 ? 0 : away[s] ? 1 : 2; };
var rank = function (s) { return watching.indexOf(s) >= 0 ? 0 : away[s] ? 1 : 2; };
all.sort(function (a, b) { return rank(a) - rank(b) || nameOf(a).localeCompare(nameOf(b)); });
all.sort(function (a, b) { return rank(a) - rank(b) || nameOf(a).localeCompare(nameOf(b)); });
- var h = people.appendChild(el('h2', 'pt-h', watching.length === 1 ? '1 watching' : watching.length + ' watching'));
+ var verb = isRadio ? ' listening' : ' watching';
+ var h = people.appendChild(el('h2', 'pt-h', watching.length + verb));
h.id = 'pt-people-h';
h.id = 'pt-people-h';
var list = el('ul', 'pt-faces');
var list = el('ul', 'pt-faces');
list.setAttribute('aria-labelledby', 'pt-people-h');
list.setAttribute('aria-labelledby', 'pt-people-h');
@@ -320,10 +449,16 @@
@@ -320,10 +449,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 (!sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });
if (!sync && window.OVSync && video) sync = window.OVSync.attach(video, { room: slug, socket: ws, self: self && self.subject });
catchUp();
catchUp();
+ paintRadio();
} else if (m.type === 'sync' && m.state) {
} else if (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;
- if (!src && typeof clock.media === 'string') { src = clock.media; video.src = src; }
+ if ((!isRadio || radio && radio.current) && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
+ if (guest) applyGuestClock();
+ } else if (m.type === 'radio' && m.state) {
+ radio = m.state;
+ if (!radio.current) { video.pause(); video.removeAttribute('src'); src = null; }
+ paintRadio();
} else if (m.type === 'lease' && m.state) {
} else if (m.type === 'lease' && m.state) {
var before = lease && lease.watchers ? lease.watchers : [];
var before = lease && lease.watchers ? lease.watchers : [];
lease = m.state;
lease = m.state;
@@ -364,6 +499,31 @@
@@ -364,6 +499,31 @@
if (changed) { paintPeople(); paintRemote(); }
if (changed) { paintPeople(); paintRemote(); }
}, 500);
}, 500);
video.addEventListener('play', function () { everPlayed = true; paintVeil(); });
video.addEventListener('play', function () { everPlayed = true; paintVeil(); });
+ video.addEventListener('ended', function () {
+ if (isRadio && !guest && self && radio && radio.current && (self.subject === owner || radio.allow_everyone))
+ radioAction('skip', { expected_id: radio.current.id });
+ });
+ video.addEventListener('loadedmetadata', applyGuestClock);
+ if (isRadio && guest) {
+ 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; }
+ clock = v.party.clock; radio = v.party.radio;
+ if (radio && !radio.current) { video.pause(); video.removeAttribute('src'); src = null; }
+ else if (clock && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
+ paintRadio(); applyGuestClock();
+ }).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 */ });
// ── invite ───────────────────────────────────────────────────────────────────
// ── invite ───────────────────────────────────────────────────────────────────
if (invite && !guest) {
if (invite && !guest) {
@@ -385,6 +545,6 @@
@@ -385,6 +545,6 @@
invite.appendChild(ib);
invite.appendChild(ib);
}
}
- paintVeil(); paintRemote(); paintPeople();
+ paintVeil(); paintRemote(); paintPeople(); paintRadio();
connect();
connect();
})();
})();
@@ -645,6 +645,13 @@ class ChatServer {
@@ -645,6 +645,13 @@ class ChatServer {
const access = await rooms.access(room, client.user);
const access = await rooms.access(room, client.user);
const subject = client.user.subject_id;
const subject = client.user.subject_id;
if (!access.read || !subject) { this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'sync.sign_in', message: 'Sign in to change the room\'s clock' }); break; }
if (!access.read || !subject) { this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'sync.sign_in', message: 'Sign in to change the room\'s clock' }); break; }
+ const radioParty = await db.get("SELECT party_kind, radio_track FROM room_parties WHERE room_id = ? AND party_kind = 'radio'", [room.id]);
+ if (radioParty) {
+ const track = typeof radioParty.radio_track === 'string' ? JSON.parse(radioParty.radio_track) : radioParty.radio_track;
+ if (!track || (msg.media !== undefined && msg.media !== track.audio_url)) {
+ this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'radio.licence', message: 'Choose a licensed Radio track from the queue' }); break;
+ }
+ }
let holder;
let holder;
try { holder = (await controlLease.state(room, this.roomWatchers(room.id))).holder; } catch (err) { this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'sync.lease', message: 'The room\'s remote could not be read' }); break; }
try { holder = (await controlLease.state(room, this.roomWatchers(room.id))).holder; } catch (err) { this.sendTo(ws, { type: 'sync_error', room: room.slug, code: 'sync.lease', message: 'The room\'s remote could not be read' }); break; }
if (holder.kind !== 'person' || holder.subject !== subject) {
if (holder.kind !== 'person' || holder.subject !== subject) {
@@ -74,10 +74,10 @@ function sourceUrlOf(value) {
@@ -74,10 +74,10 @@ function sourceUrlOf(value) {
function mediaFromRow(value) {
function mediaFromRow(value) {
if (value == null) return null;
if (value == null) return null;
if (typeof value !== 'string') return value;
if (typeof value !== 'string') return value;
- try { return JSON.parse(value); } catch { return null; }
+ try { return JSON.parse(value); } catch { return value.startsWith('https://') ? value : null; }
}
}
-const kindOfParty = (party) => (party && party.party_kind === 'media' ? 'media' : 'agent_stream');
+const kindOfParty = (party) => (party && ['media', 'radio'].includes(party.party_kind) ? party.party_kind : 'agent_stream');
/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */
/** The party's position now: on a VOD, a playing one moves with the clock; a live one is the live edge. */
function syncOf(party, now = Date.now()) {
function syncOf(party, now = Date.now()) {
@@ -94,7 +94,7 @@ function syncOf(party, now = Date.now()) {
@@ -94,7 +94,7 @@ function syncOf(party, now = Date.now()) {
function joinUrl(row, kind, rawSecret = null) {
function joinUrl(row, kind, rawSecret = null) {
const slug = encodeURIComponent(row.slug);
const slug = encodeURIComponent(row.slug);
- const base = kind === 'media' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;
+ const base = kind === 'media' || kind === 'radio' ? `${PARTY_BASE_URL()}/p/${slug}` : `${BASE_URL()}/rooms/${slug}`;
return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;
return rawSecret ? `${base}?k=${encodeURIComponent(rawSecret)}` : base;
}
}
@@ -105,7 +105,8 @@ function view(row, party, { rawSecret = null, now = Date.now() } = {}) {
@@ -105,7 +105,8 @@ function view(row, party, { rawSecret = null, now = Date.now() } = {}) {
kind,
kind,
agent_stream_id: party.agent_stream_id || null,
agent_stream_id: party.agent_stream_id || null,
vod_id: party.vod_id == null ? null : String(party.vod_id),
vod_id: party.vod_id == null ? null : String(party.vod_id),
- ...(kind === 'media' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),
+ ...(kind === 'media' || kind === 'radio' ? { media: mediaFromRow(party.media), source_url: party.source_url || null } : {}),
+ ...(kind === 'radio' ? { radio_track: mediaFromRow(party.radio_track) } : {}),
host: party.host_subject,
host: party.host_subject,
title: row.name,
title: row.name,
...(kind === 'agent_stream' ? { sync: syncOf(party, now) } : {}),
...(kind === 'agent_stream' ? { sync: syncOf(party, now) } : {}),
@@ -133,7 +134,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
@@ -133,7 +134,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
host_name: host ? (host.display_name || host.username) : null,
host_name: host ? (host.display_name || host.username) : null,
member,
member,
};
};
- if (kindOfParty(party) === 'media') {
+ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'radio') {
const room = { id: row.id, slug: row.slug };
const room = { id: row.id, slug: row.slug };
out.clock = await syncClock.get(room);
out.clock = await syncClock.get(room);
if (member) {
if (member) {
@@ -143,6 +144,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
@@ -143,6 +144,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
.map((m) => ({ subject: m.subject_id || null, name: m.display_name || m.username, role: m.role }));
.map((m) => ({ subject: m.subject_id || null, name: m.display_name || m.username, role: m.role }));
}
}
}
}
+ if (kindOfParty(party) === 'radio') out.radio = await require('./radio').state(row, party, { user, key });
return out;
return out;
}
}
@@ -151,6 +153,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
@@ -151,6 +153,7 @@ async function detail(row, party, { user = null, key = null, now = Date.now() }
* Returns the party and the one time its raw join secret exists.
* Returns the party and the one time its raw join secret exists.
*/
*/
async function create(user, input = {}) {
async function create(user, input = {}) {
+ if (input && input.radio_track_id) return await require('./radio').start(user, input);
if (input && input.media !== undefined && input.media !== null && input.media !== '') return await createMedia(user, input);
if (input && input.media !== undefined && input.media !== null && input.media !== '') return await createMedia(user, input);
return await createAgentStream(user, input);
return await createAgentStream(user, input);
}
}
@@ -183,7 +186,7 @@ async function createAgentStream(user, { agent_stream_id: agentStreamId, vod_id:
@@ -183,7 +186,7 @@ async function createAgentStream(user, { agent_stream_id: agentStreamId, vod_id:
* the remote granted to the person who started it. The join link is openvibe.party/p/<slug>?k=…, and the secret is
* the remote granted to the person who started it. The join link is openvibe.party/p/<slug>?k=…, and the secret is
* in this answer only.
* in this answer only.
*/
*/
-async function createMedia(user, { media, source_url: sourceUrl = null, title, listed = false } = {}) {
+async function createMedia(user, { media, source_url: sourceUrl = null, title, listed = false, partyKind = 'media', radioTrack = null } = {}) {
if (!user) fail(401, 'party.sign_in', 'Sign in to start a watch party');
if (!user) fail(401, 'party.sign_in', 'Sign in to start a watch party');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');
if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(user.subject_id || ''))) fail(409, 'party.no_subject', 'This account has no OpenVibe subject yet; sign in again');
const chosen = mediaOf(media);
const chosen = mediaOf(media);
@@ -195,9 +198,9 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l
@@ -195,9 +198,9 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l
const room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });
const room = await rooms.create(user, { name: roomName, slug, kind: 'party', visibility: 'private', topic: name, party: true });
const id = room.id;
const id = room.id;
await db.run(
await db.run(
- `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash, party_kind, media, source_url)
- VALUES (?, NULL, NULL, ?, ?, 'vod', false, ?, ?, 'media', ?, ?)`,
- [id, user.id, user.subject_id || '', !!listed, sha256(raw), JSON.stringify(chosen), source]
+ `INSERT INTO room_parties (room_id, agent_stream_id, vod_id, host_user_id, host_subject, mode, playing, listed, join_secret_hash, party_kind, media, source_url, radio_track)
+ VALUES (?, NULL, NULL, ?, ?, 'vod', false, ?, ?, ?, ?, ?, ?)`,
+ [id, user.id, user.subject_id || '', !!listed, sha256(raw), partyKind, JSON.stringify(chosen), source, radioTrack ? JSON.stringify(radioTrack) : null]
);
);
const shared = { id, slug: room.slug };
const shared = { id, slug: room.slug };
await syncClock.set(shared, user.subject_id, { media: chosen, position_ms: 0, rate: 1, paused: true });
await syncClock.set(shared, user.subject_id, { media: chosen, position_ms: 0, rate: 1, paused: true });
@@ -208,7 +211,7 @@ async function createMedia(user, { media, source_url: sourceUrl = null, title, l
@@ -208,7 +211,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 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 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;
}
}
@@ -218,7 +221,7 @@ function partyOf(row) {
@@ -218,7 +221,7 @@ function partyOf(row) {
host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,
host_user_id: row.host_user_id, host_subject: row.host_subject, mode: row.mode,
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,
+ party_kind: row.party_kind || 'agent_stream', media: row.media, source_url: row.source_url, radio_track: row.radio_track,
} : null;
} : null;
}
}
@@ -238,7 +241,7 @@ async function mine(user, { limit = 6 } = {}) {
@@ -238,7 +241,7 @@ async function mine(user, { limit = 6 } = {}) {
const since = new Date(Date.now() - 14 * 86400_000).toISOString();
const since = new Date(Date.now() - 14 * 86400_000).toISOString();
const rows = await db.all(`SELECT r.slug, r.name, p.host_user_id, p.created_at
const rows = await db.all(`SELECT r.slug, r.name, p.host_user_id, p.created_at
FROM room_members m JOIN rooms r ON r.id = m.room_id JOIN room_parties p ON p.room_id = r.id
FROM room_members m JOIN rooms r ON r.id = m.room_id JOIN room_parties p ON p.room_id = r.id
- WHERE m.user_id = ? AND p.party_kind = 'media' AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?
+ WHERE m.user_id = ? AND p.party_kind IN ('media', 'radio') AND p.ended_at IS NULL AND r.archived_at IS NULL AND p.created_at > ?
ORDER BY p.created_at DESC LIMIT ?`, [user.id, since, Math.max(1, Math.min(12, Number(limit) || 6))]);
ORDER BY p.created_at DESC LIMIT ?`, [user.id, since, Math.max(1, Math.min(12, Number(limit) || 6))]);
return (rows || []).map((x) => ({
return (rows || []).map((x) => ({
id: x.slug, title: x.name, hosting: Number(x.host_user_id) === Number(user.id),
id: x.slug, title: x.name, hosting: Number(x.host_user_id) === Number(user.id),
@@ -267,7 +270,7 @@ async function joinWithLink(row, party, user, key) {
@@ -267,7 +270,7 @@ async function joinWithLink(row, party, user, key) {
/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */
/** The host moves the VOD (or pauses it). Live parties follow the live edge and take no position. */
async function setSync(row, party, user, { position_s: position, playing } = {}) {
async function setSync(row, party, user, { position_s: position, playing } = {}) {
if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');
if (!user || Number(user.id) !== Number(party.host_user_id)) fail(403, 'party.not_host', 'Only the host moves the party');
- if (kindOfParty(party) === 'media') fail(409, 'party.clock', 'A media party follows the room\'s shared clock; the remote moves it');
+ if (kindOfParty(party) === 'media' || kindOfParty(party) === 'radio') fail(409, 'party.clock', 'A media party follows the room\'s shared clock; the remote moves it');
if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');
if (party.mode === 'live') fail(409, 'party.live_edge', 'A live party follows the live edge; there is no position to set');
let pos = party.position_s;
let pos = party.position_s;
if (position !== undefined) {
if (position !== undefined) {
@@ -16,6 +16,7 @@ const cache = require('openvibe-shared/cache-policy');
@@ -16,6 +16,7 @@ const cache = require('openvibe-shared/cache-policy');
const { requireAuth, optionalAuth } = require('../auth/auth');
const { requireAuth, optionalAuth } = require('../auth/auth');
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 router = express.Router();
const router = express.Router();
@@ -35,13 +36,35 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
@@ -35,13 +36,35 @@ router.post('/', requireAuth, createLimit, async (req, res) => {
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
+router.get('/radio/tracks', async (_req, res) => {
+ res.set('Cache-Control', 'public, max-age=30').json({ tracks: await radio.catalogue() });
+});
+
+router.get('/:slug/radio', optionalAuth, async (req, res) => {
+ try {
+ const row = await parties.bySlug(req.params.slug), party = parties.partyOf(row);
+ const out = await radio.state(row, party, { user: req.user || null, key: req.query.k });
+ if (!out) return res.status(404).json({ code: 'rooms.not_found' });
+ res.set('Cache-Control', 'private, no-store').json({ radio: out });
+ } catch (err) { send(res, err); }
+});
+
+router.post('/:slug/radio/:action', requireAuth, createLimit, 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 radio.change(row, party, req.user, req.params.action, req.body || {});
+ res.set('Cache-Control', 'private, no-store').json({ radio: out });
+ } catch (err) { send(res, err); }
+});
+
router.get('/:slug', optionalAuth, async (req, res) => {
router.get('/:slug', optionalAuth, async (req, res) => {
try {
try {
const row = await parties.bySlug(req.params.slug);
const row = await parties.bySlug(req.params.slug);
const party = parties.partyOf(row);
const party = parties.partyOf(row);
const out = row && party ? await parties.detail(row, party, { user: req.user || null, key: req.query.k }) : null;
const out = row && party ? await parties.detail(row, party, { user: req.user || null, key: req.query.k }) : null;
if (!out) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
if (!out) return res.status(404).json({ error: 'No such room', code: 'rooms.not_found' });
- res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out });
+ res.set('Cache-Control', cache.htmlHeaders({ private: true })).json({ party: out, server_time_ms: Date.now() });
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+const db = require('../db/database');
+const rooms = require('./rooms');
+const parties = require('./parties');
+const clock = require('./sync-clock');
+
+const TRACK_ID = /^rad_[0-9A-HJKMNP-TV-Z]{26}$/;
+const LICENCES = new Set(['creator_grant', 'CC0-1.0', 'CC-BY-4.0', 'CC-BY-SA-4.0', 'public_domain']);
+const base = () => String(process.env.CHAT_RADIO_URL || (process.env.NODE_ENV === 'test' ? '' : 'https://openvibe.download')).replace(/\/+$/, '');
+const publicBase = 'https://openvibe.download';
+const fail = (status, code, message) => { throw new parties.PartyError(status, code, message); };
+const decode = (v) => { if (typeof v !== 'string') return v; try { return JSON.parse(v); } catch { return null; } };
+const trackView = (t) => t && ({ id: t.id, title: t.title, artist: t.artist, source: t.source, source_url: t.source_url,
+ licence: t.licence, licence_url: t.licence_url, changes_note: t.changes_note || null, ai_made: !!t.ai_made, audio_url: t.audio_url });
+
+async function catalogue() {
+ if (!base()) return [];
+ try {
+ const response = await fetch(`${base()}/api/v1/radio/tracks`, { redirect: 'error', signal: AbortSignal.timeout(2500) });
+ if (!response.ok) return [];
+ const body = await response.json();
+ return (Array.isArray(body.tracks) ? body.tracks : []).filter((t) => TRACK_ID.test(t.id)
+ && t.audio_url === `${publicBase}/api/v1/radio/tracks/${encodeURIComponent(t.id)}/audio`
+ && t.title && t.artist && LICENCES.has(t.licence) && /^https:\/\//.test(t.licence_url)).slice(0, 100).map(trackView);
+ } catch { return []; }
+}
+
+async function track(id) {
+ if (!TRACK_ID.test(String(id || ''))) fail(422, 'radio.track', 'Choose an OpenVibe Radio track');
+ if (!base()) fail(503, 'radio.unavailable', 'OpenVibe Radio is not configured');
+ let response;
+ try { response = await fetch(`${base()}/api/v1/radio/tracks/${encodeURIComponent(id)}`, { redirect: 'error', signal: AbortSignal.timeout(2500) }); }
+ catch { fail(503, 'radio.unavailable', 'OpenVibe Radio did not answer'); }
+ if (!response.ok) fail(422, 'radio.track', 'That track is not published on OpenVibe Radio');
+ const value = (await response.json().catch(() => null))?.track;
+ const expected = `${publicBase}/api/v1/radio/tracks/${encodeURIComponent(id)}/audio`;
+ if (!value || value.id !== id || value.audio_url !== expected || !value.title || !value.artist || !LICENCES.has(value.licence) || !/^https:\/\//.test(value.licence_url) || !['creator', 'creative_commons', 'public_domain', 'ai'].includes(value.source))
+ fail(502, 'radio.catalogue', 'OpenVibe Radio returned incomplete track rights');
+ return trackView(value);
+}
+
+async function start(user, input) {
+ const chosen = await track(input.radio_track_id);
+ return parties.createMedia(user, { media: chosen.audio_url, source_url: `${publicBase}/radio/tracks/${chosen.id}`,
+ title: input.title || `${chosen.title} listening party`, partyKind: 'radio', radioTrack: chosen });
+}
+
+async function state(row, party, { user = null, key = null } = {}) {
+ if (!row || !party || party.party_kind !== 'radio') return null;
+ if (!await parties.mayView(row, party, user, key)) return null;
+ const fresh = await db.get('SELECT radio_track, radio_queue, radio_allow_all, radio_version FROM room_parties WHERE room_id = ?', [row.id]);
+ return { current: decode(fresh.radio_track), queue: decode(fresh.radio_queue) || [], allow_everyone: !!fresh.radio_allow_all, version: Number(fresh.radio_version) || 0 };
+}
+
+async function change(row, party, user, action, input = {}) {
+ const access = user ? await rooms.access(row, user) : null;
+ if (!access || !access.read) fail(404, 'rooms.not_found', 'No such room');
+ if (!party || party.party_kind !== 'radio' || party.ended_at) fail(409, 'radio.party', 'This is not an active listening party');
+ const isHost = user.subject_id === party.host_subject;
+ const shared = { id: row.id, slug: row.slug };
+ for (let tries = 0; tries < 5; tries++) {
+ const old = await state(row, party, { user });
+ if (!isHost && !old.allow_everyone) fail(403, 'radio.rights', 'Only the host may change the queue');
+ const queue = old.queue.slice();
+ let current = old.current;
+ if (action === 'queue') {
+ if (queue.length >= 100) fail(409, 'radio.queue_full', 'The queue is full');
+ queue.push(await track(input.track_id));
+ } else if (action === 'skip') {
+ if (input.expected_id && input.expected_id !== (current && current.id)) return old;
+ current = null;
+ while (queue.length && !current) {
+ const next = queue.shift();
+ try { current = await track(next.id); } catch (err) { if (!(err instanceof parties.PartyError) || err.status >= 500) throw err; }
+ }
+ } else if (action === 'allow') {
+ if (!isHost) fail(403, 'radio.rights', 'Only the host changes queue rights');
+ } else fail(422, 'radio.action', 'Unknown queue action');
+ const allow = action === 'allow' ? input.allow_everyone === true : old.allow_everyone;
+ const updated = await db.get(`UPDATE room_parties SET radio_track = ?, radio_queue = ?, radio_allow_all = ?, radio_version = radio_version + 1
+ WHERE room_id = ? AND radio_version = ? RETURNING radio_version`, [current ? JSON.stringify(current) : null, JSON.stringify(queue), allow, row.id, old.version]);
+ if (!updated) continue;
+ if (action === 'skip') {
+ const sync = await clock.set(shared, user.subject_id, current
+ ? { media: current.audio_url, position_ms: 0, paused: false, rate: 1 }
+ : { position_ms: 0, paused: true });
+ require('../chat/chat-server').broadcastToRoom(row.id, { type: 'sync', room: row.slug, state: sync });
+ }
+ const result = { current, queue, allow_everyone: allow, version: Number(updated.radio_version) };
+ require('../chat/chat-server').broadcastToRoom(row.id, { type: 'radio', room: row.slug, state: result });
+ return result;
+ }
+ fail(409, 'radio.conflict', 'The queue changed; try again');
+}
+
+module.exports = { track, catalogue, start, state, change };
@@ -26,6 +26,9 @@ const { renderPage, contentSecurityPolicy, esc } = require('./layout');
@@ -26,6 +26,9 @@ const { renderPage, contentSecurityPolicy, esc } = require('./layout');
const { createSessionRoutes } = require('./session');
const { createSessionRoutes } = require('./session');
const { viewerOf } = require('./pages');
const { viewerOf } = require('./pages');
const parties = require('../rooms/parties');
const parties = require('../rooms/parties');
+const radio = require('../rooms/radio');
+const radioSource = (v) => ({ creator: 'Creator upload', creative_commons: 'Creative Commons', public_domain: 'Public domain', ai: 'AI-made music' })[v] || v;
+const radioLicence = (v) => v === 'creator_grant' ? 'Creator’s permission to stream' : v === 'public_domain' ? 'Public domain mark' : v;
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public', 'web');
const SITE = Object.freeze({ name: 'OpenVibe.Party', service: 'party', links: [{ key: 'start', label: 'Start a party', href: '/' }] });
const SITE = Object.freeze({ name: 'OpenVibe.Party', service: 'party', links: [{ key: 'start', label: 'Start a party', href: '/' }] });
@@ -138,7 +141,7 @@ function createPartyRoutes({ config }) {
@@ -138,7 +141,7 @@ function createPartyRoutes({ config }) {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
const signedIn = actor.kind === 'user';
const signedIn = actor.kind === 'user';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
const error = req.query.error ? notice('error', String(req.query.error).slice(0, 200)) : '';
- const picks = await latestWatchable();
+ const [picks, tracks] = await Promise.all([latestWatchable(), radio.catalogue()]);
const mine = signedIn ? await parties.mine(actor.user, { limit: 6 }).catch(() => []) : [];
const mine = signedIn ? await parties.mine(actor.user, { limit: 6 }).catch(() => []) : [];
const since = (iso) => {
const since = (iso) => {
const days = Math.floor((Date.now() - Date.parse(iso)) / 86400_000);
const days = Math.floor((Date.now() - Date.parse(iso)) / 86400_000);
@@ -176,6 +179,9 @@ ${error}
@@ -176,6 +179,9 @@ ${error}
${start}
${start}
</header>
</header>
${yours}
${yours}
+<section class="pty-radio" aria-labelledby="pty-radio-h"><h2 id="pty-radio-h">OpenVibe Radio</h2><p>Listen together using music its creator has shared for streaming. Every track shows its source and licence.</p>
+${tracks.length ? `<ul class="pty-radio-list">${tracks.slice(0, 12).map((t) => `<li><div><strong>${esc(t.title)}</strong><span>${esc(t.artist)}${t.ai_made ? ' · AI-made music' : ''}</span><small>Source: ${esc(radioSource(t.source))} · <a href="${esc(t.licence_url)}">${esc(radioLicence(t.licence))}</a>${t.source_url ? ` · <a href="${esc(t.source_url)}">Original source</a>` : ''}</small></div><a class="oc-button" href="/new?radio=${encodeURIComponent(t.id)}">Listen together</a></li>`).join('')}</ul>` : '<p>No Radio tracks are published yet. Creators can mark their own audio uploads as music on OpenVibe.Download.</p>'}
+<p>Spotify, Apple Music and YouTube Music listening is coming after those services approve it.</p></section>
${grid}
${grid}
<section class="pty-how" aria-labelledby="pty-how-h">
<section class="pty-how" aria-labelledby="pty-how-h">
<h2 id="pty-how-h">How it works</h2>
<h2 id="pty-how-h">How it works</h2>
@@ -193,6 +199,12 @@ ${grid}
@@ -193,6 +199,12 @@ ${grid}
router.get('/new', async (req, res) => {
router.get('/new', async (req, res) => {
const actor = await viewerOf(req);
const actor = await viewerOf(req);
if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
if (actor.kind !== 'user') return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
+ if (req.query.radio) {
+ let chosen;
+ try { chosen = await radio.track(String(req.query.radio)); } catch { return res.redirect(303, '/?error=That%20Radio%20track%20is%20not%20available'); }
+ return page(req, res, 200, { actor, title: 'Start a listening party', path: '/new', robots: 'noindex, nofollow', footer: 'compact', partyHome: true,
+ body: `<section class="oc-party-confirm pty-confirm"><h1>Start a listening party?</h1><p class="oc-lede"><strong>${esc(chosen.title)}</strong> by ${esc(chosen.artist)}</p><p>Source: ${esc(radioSource(chosen.source))} · <a href="${esc(chosen.licence_url)}">${esc(radioLicence(chosen.licence))}</a>${chosen.ai_made ? ' · AI-made music' : ''}</p><p>Everyone hears the track at the party’s position. You control the queue and can allow everyone to add and skip.</p><form method="post" action="/new"><input type="hidden" name="radio_track_id" value="${esc(chosen.id)}"><button class="oc-button" type="submit">Start listening</button></form></section>` });
+ }
const asked = String(req.query.media || '').trim();
const asked = String(req.query.media || '').trim();
if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
const want = await playable({ media: asked, title: String(req.query.title || '').replace(/\s+/g, ' ').trim().slice(0, 200), source: req.query.source_url ? String(req.query.source_url).slice(0, 2048) : '' });
const want = await playable({ media: asked, title: String(req.query.title || '').replace(/\s+/g, ' ').trim().slice(0, 200), source: req.query.source_url ? String(req.query.source_url).slice(0, 2048) : '' });
@@ -227,6 +239,14 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
@@ -227,6 +239,14 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
const origin = String(req.get('origin') || '');
const origin = String(req.get('origin') || '');
const fetchSite = String(req.get('sec-fetch-site') || '');
const fetchSite = String(req.get('sec-fetch-site') || '');
if ((origin && origin !== base) || (fetchSite && fetchSite !== 'same-origin')) return res.status(403).type('text/plain').send('Start a party from openvibe.party itself.');
if ((origin && origin !== base) || (fetchSite && fetchSite !== 'same-origin')) return res.status(403).type('text/plain').send('Start a party from openvibe.party itself.');
+ if (req.body && req.body.radio_track_id) {
+ try {
+ const out = await radio.start(actor.user, { radio_track_id: String(req.body.radio_track_id) });
+ return res.redirect(303, `/p/${encodeURIComponent(out.party.id)}?k=${encodeURIComponent(out.join_secret)}`);
+ } catch (err) {
+ return res.redirect(303, `/?error=${encodeURIComponent(err instanceof parties.PartyError ? err.message : 'That listening party could not be started')}`);
+ }
+ }
const asked = String((req.body && req.body.media) || '').trim();
const asked = String((req.body && req.body.media) || '').trim();
if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
if (!asked) return res.redirect(303, `/?error=${encodeURIComponent('Paste an OpenVibe link to start a party')}`);
// A watch page posted here directly (not through the confirmation) is made playable the same way.
// A watch page posted here directly (not through the confirmation) is made playable the same way.
@@ -269,15 +289,15 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
@@ -269,15 +289,15 @@ ${source ? `<input type="hidden" name="source_url" value="${esc(source)}">` : ''
actor, title: out.title, path: `/p/${out.id}`, robots: 'noindex, nofollow', footer: 'compact',
actor, title: out.title, path: `/p/${out.id}`, robots: 'noindex, nofollow', footer: 'compact',
description: 'Watch together on OpenVibe.',
description: 'Watch together on OpenVibe.',
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">
+ 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></header>
<header class="oc-party-head"><h1>${esc(out.title)}</h1><p class="oc-muted">Started by ${esc(out.host_name || 'someone')}</p></header>
-<div class="oc-party-stage" data-party-stage aria-label="The video"></div>
+<div class="oc-party-stage" data-party-stage aria-label="${out.kind === 'radio' ? 'The music stage' : 'The video'}"></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>
<div class="oc-party-invite" data-party-invite aria-label="Invite"></div>
<div class="oc-party-invite" data-party-invite aria-label="Invite"></div>
</article>
</article>
-<script type="application/json" id="party-data">${escJson({ party: out })}</script>`,
+<script type="application/json" id="party-data">${escJson({ party: out, server_time_ms: Date.now() })}</script>`,
});
});
} catch (err) { next(err); }
} catch (err) { next(err); }
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
+'use strict';
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, suite } = require('./helpers');
+
+const t = suite('party radio');
+const ID = 'rad_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const AUDIO = `https://openvibe.download/api/v1/radio/tracks/${ID}/audio`;
+const TRACK = { id: ID, title: 'Friday Song', artist: 'Ann', source: 'creator', source_url: null,
+ licence: 'creator_grant', licence_url: 'https://openvibe.download/radio/licence', ai_made: false, audio_url: AUDIO };
+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 });
+
+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: [TRACK] }));
+ if (req.url === `/api/v1/radio/tracks/${ID}`) return res.end(JSON.stringify({ track: TRACK }));
+ 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 published track starts a listening party with rights on the stage', async () => {
+ const made = await api('POST', '/', ann, { radio_track_id: ID });
+ assert.strictEqual(made.status, 201, made.text);
+ party = made.body.party;
+ key = made.body.join_secret;
+ assert.strictEqual(party.kind, 'radio');
+ assert.strictEqual(party.media, AUDIO);
+ const detail = await api('GET', `/${party.id}`, ann);
+ assert.strictEqual(detail.body.party.radio.current.licence, 'creator_grant');
+ assert.strictEqual(detail.body.party.clock.position_ms, 0);
+ assert.strictEqual(detail.body.party.clock.paused, true);
+ const guest = await api('GET', `/${party.id}?k=${made.body.join_secret}`, null);
+ assert.strictEqual(guest.status, 200);
+ assert.strictEqual(guest.body.party.radio.current.source, 'creator');
+});
+
+t('the host controls queue rights and a skip starts the next track at zero', async () => {
+ const join = await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: bob.token, body: { k: key } });
+ assert.strictEqual(join.status, 200, join.text);
+ const forbidden = await api('POST', `/${party.id}/radio/queue`, bob, { track_id: ID });
+ assert.strictEqual(forbidden.status, 403);
+ assert.strictEqual(forbidden.body.code, 'radio.rights');
+ assert.strictEqual((await api('POST', `/${party.id}/radio/queue`, ann, { track_id: 'https://example.org/song.mp3' })).body.code, 'radio.track');
+ const queued = await api('POST', `/${party.id}/radio/queue`, ann, { track_id: ID });
+ assert.strictEqual(queued.status, 200, queued.text);
+ assert.strictEqual(queued.body.radio.queue.length, 1);
+ assert.strictEqual((await api('POST', `/${party.id}/radio/allow`, bob, { allow_everyone: true })).status, 403);
+ const allowed = await api('POST', `/${party.id}/radio/allow`, ann, { allow_everyone: true });
+ assert.strictEqual(allowed.body.radio.allow_everyone, true);
+ assert.strictEqual((await api('POST', `/${party.id}/radio/queue`, bob, { track_id: ID })).status, 200);
+ const skipped = await api('POST', `/${party.id}/radio/skip`, bob, {});
+ assert.strictEqual(skipped.status, 200, skipped.text);
+ assert.strictEqual(skipped.body.radio.current.id, ID);
+ const now = await api('GET', `/${party.id}`, ann);
+ assert.strictEqual(now.body.party.clock.media, AUDIO);
+ assert.strictEqual(now.body.party.clock.position_ms, 0);
+ assert.strictEqual(now.body.party.clock.paused, false);
+});
+
+t('a late joiner receives the shared clock at the right second', async () => {
+ const clock = require('../server/rooms/sync-clock');
+ const row = await require('../server/rooms/parties').bySlug(party.id);
+ const room = { id: row.id, slug: row.slug };
+ await clock.set(room, ann.subject_id, { media: AUDIO, position_ms: 12000, paused: false, rate: 1 }, { now: Date.now() - 5000 });
+ const joined = await h.http('POST', `/api/chat/rooms/${party.id}/party/join`, { token: cat.token, body: { k: key } });
+ assert.strictEqual(joined.status, 200, joined.text);
+ const ws = await h.ws({ token: cat.token, origin: 'https://openvibe.party' });
+ ws.sendJson({ type: 'join_room', room: party.id });
+ await ws.next((m) => m.type === 'room_joined');
+ const sync = await ws.next((m) => m.type === 'sync');
+ const position = sync.state.position_ms + (Date.now() - sync.state.at_ms) * sync.state.rate;
+ assert.ok(position >= 17000 && position < 18500, `late joiner position ${position}`);
+ 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');
+ host.sendJson({ type: 'sync', media: 'https://openvibe.media/unlicensed.mp3', position_ms: 0 });
+ assert.strictEqual((await host.next((m) => m.type === 'sync_error')).code, 'radio.licence');
+ host.close();
+ ws.close();
+});
+
+t('close', async () => { await h.close(); await new Promise((r) => service.close(r)); delete process.env.CHAT_RADIO_URL; });
+t.run();
@@ -124,6 +124,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
@@ -124,6 +124,7 @@ t('ratchet: every file that makes an outbound request itself is reviewed', () =>
'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",
'server/inbox/webhooks.js': "an inbox owner's webhook addresses: https only, assertPublicUrl when saved and safeLookup on every delivery, no redirects followed",
'server/inbox/actions.js': "an inbox owner's action addresses: https only, assertPublicUrl when saved and safeLookup on every call, no redirects followed, 8 s",
'server/inbox/actions.js': "an inbox owner's action addresses: https only, assertPublicUrl when saved and safeLookup on every call, no redirects followed, 8 s",
'server/web/party-pages.js': 'OpenVibe.Video (fixed: https://openvibe.video, or a test\'s CHAT_PARTY_VIDEO_URL), fixed /api/v1/video/watchable paths; a pasted link is only a query parameter, never fetched; redirects refused, 2.5 s, cached a minute',
'server/web/party-pages.js': 'OpenVibe.Video (fixed: https://openvibe.video, or a test\'s CHAT_PARTY_VIDEO_URL), fixed /api/v1/video/watchable paths; a pasted link is only a query parameter, never fetched; redirects refused, 2.5 s, cached a minute',
+ 'server/rooms/radio.js': 'OpenVibe.Download Radio catalogue (fixed https origin, or a test\'s CHAT_RADIO_URL); track ids match rad_ ULIDs, redirects refused, 2.5 s',
'server/chat/link-cards.js': 'OpenVibe.Video and OpenVibe.Actor (fixed https origins, or a test\'s CHAT_LINK_VIDEO_URL / CHAT_LINK_ACTOR_URL), fixed paths (a watchable lookup, /voices/<id>.json, /skills/<slug>.json); a pasted link is a query parameter or a path segment only, never fetched; redirects refused, 2.5 s, cached a minute',
'server/chat/link-cards.js': 'OpenVibe.Video and OpenVibe.Actor (fixed https origins, or a test\'s CHAT_LINK_VIDEO_URL / CHAT_LINK_ACTOR_URL), fixed paths (a watchable lookup, /voices/<id>.json, /skills/<slug>.json); a pasted link is a query parameter or a path segment only, never fetched; redirects refused, 2.5 s, cached a minute',
'server/web/room-embed-pages.js': 'not the server: the sign-in window\'s own script posts to /embed/bind on this same site',
'server/web/room-embed-pages.js': 'not the server: the sign-in window\'s own script posts to /embed/bind on this same site',
};
};