openvibers/openvibe.chat Commits

Add YouTube and Twitch party playback (#414)

OP OpenVibers committed 5 days ago · 9b4628536b · 11 files, +268 −34

* Add YouTube and Twitch party playback

* Party CSP: allow embed.twitch.tv frames (Twitch.Embed draws its player there)

* Merge origin/main with party provider playback

Changes

ddiff --git a/docs/room-sync-clock.md b/docs/room-sync-clock.md
iindex 39d9f41..8b1a822 100644
---- a/docs/room-sync-clock.md
++++ b/docs/room-sync-clock.md
@@@ -35,8 +35,9 @@ State:
"set_by": "usr_01JAB2C3D4E5F6G7H8J9K0MNPS", "seq": 7 }
```
--`media` is a `media.media-ref@1` object or an `https` URL on an OpenVibe host (a domain a Contracts service manifest
--names; Party v1 plays OpenVibe media only, so a holder cannot point every viewer's player at an outside server). A player is at
++`media` is a `media.media-ref@1` object, an `https` URL on an OpenVibe host (a domain a Contracts service manifest
++names), or an official YouTube/Twitch watch URL. Provider URLs are identifiers for the provider's own official embed;
++Chat never proxies or downloads those streams. A player is at
`position_ms + (serverNow - at_ms) * rate` while `paused` is false, and at `position_ms` while it is true. `at_ms`
is always Chat's clock when Chat accepted the set; a player measures that clock with `time` (below).
ddiff --git a/public/web/party-provider-sync.js b/public/web/party-provider-sync.js
nnew file mode 100644
iindex 0000000..ba58bfe
---- /dev/null
++++ b/public/web/party-provider-sync.js
@@@ -0,0 +1,26 @@
++(function (root, factory) {
++ if (typeof module === 'object' && module.exports) module.exports = factory();
++ else root.PartyProviderSync = factory();
++}(typeof window === 'undefined' ? globalThis : window, function () {
++ 'use strict';
++ function expected(state, now) {
++ var position = Math.max(0, Number(state.position_ms) || 0) / 1000;
++ if (!state.paused) position += Math.max(0, now - Number(state.at_ms || now)) / 1000 * (Number(state.rate) || 1);
++ return position;
++ }
++ function follow(player, state, now, limit) {
++ if (!player || !state) return 0;
++ if (player.live) {
++ if (state.paused) player.pause(); else player.play();
++ return 0;
++ }
++ var target = expected(state, now == null ? Date.now() : now);
++ var drift = (Number(player.time()) || 0) - target;
++ if (Math.abs(drift) > (limit == null ? 1 : limit)) player.seek(target);
++ else if (player.supportsRate && player.rate) player.rate(Math.max(0.95, Math.min(1.05, (Number(state.rate) || 1) - drift * 0.03)));
++ else if (player.rate) player.rate(Number(state.rate) || 1);
++ if (state.paused) player.pause(); else player.play();
++ return drift;
++ }
++ return { expected: expected, follow: follow };
++}));
ddiff --git a/public/web/party-providers.js b/public/web/party-providers.js
nnew file mode 100644
iindex 0000000..bfafe8c
---- /dev/null
++++ b/public/web/party-providers.js
@@@ -0,0 +1,75 @@
++/* Official player adapters for openvibe.party. This file is CommonJS-testable and also exposes the browser API. */
++(function (root, factory) {
++ if (typeof module === 'object' && module.exports) module.exports = factory();
++ else root.PartyProviders = factory();
++}(typeof window === 'undefined' ? globalThis : window, function () {
++ 'use strict';
++ function parse(value) {
++ try {
++ var u = new URL(value), host = u.hostname.toLowerCase();
++ if (u.protocol !== 'https:' || u.username || u.password || u.port || u.hash) return null;
++ var m;
++ if (host === 'youtu.be' && (m = /^\/([A-Za-z0-9_-]{11})\/?$/.exec(u.pathname))) return { kind: 'youtube', id: m[1], live: false, url: 'https://www.youtube.com/watch?v=' + m[1] };
++ if (['youtube.com', 'www.youtube.com', 'm.youtube.com'].includes(host)) {
++ m = /^\/(?:shorts|embed)\/([A-Za-z0-9_-]{11})\/?$/.exec(u.pathname);
++ var id = u.pathname === '/watch' ? u.searchParams.get('v') : (m && m[1]);
++ if (/^[A-Za-z0-9_-]{11}$/.test(id || '')) return { kind: 'youtube', id: id, live: false, url: 'https://www.youtube.com/watch?v=' + id };
++ }
++ if (host === 'twitch.tv' || host === 'www.twitch.tv') {
++ m = /^\/videos\/(\d+)\/?$/.exec(u.pathname);
++ if (m) return { kind: 'twitch', id: m[1], live: false, url: 'https://www.twitch.tv/videos/' + m[1] };
++ m = /^\/([A-Za-z0-9_]{1,25})\/?$/.exec(u.pathname);
++ if (m) return { kind: 'twitch', id: m[1], live: true, url: 'https://www.twitch.tv/' + m[1] };
++ }
++ } catch (_) { /* invalid URL */ }
++ return null;
++ }
++ function loadScript(src, test, ready) {
++ if (test()) return Promise.resolve();
++ return new Promise(function (resolve, reject) {
++ ready(resolve); var script = document.createElement('script'); script.src = src; script.async = true;
++ script.onerror = function () { reject(new Error('The player could not be loaded.')); };
++ document.head.appendChild(script);
++ });
++ }
++ function youtube(target, media, onState) {
++ var player, dead = false, readyResolve, readyReject;
++ var ready = new Promise(function (r, j) { readyResolve = r; readyReject = j; });
++ var previous = window.onYouTubeIframeAPIReady;
++ window.onYouTubeIframeAPIReady = function () { if (typeof previous === 'function') previous(); create(); };
++ function create() {
++ if (dead || player || !window.YT || !window.YT.Player) return;
++ player = new window.YT.Player(target, { videoId: media.id, host: 'https://www.youtube-nocookie.com', playerVars: { playsinline: 1, origin: location.origin }, events: {
++ onReady: function () { readyResolve(); },
++ onStateChange: function (e) { onState(e.data === 1 ? 'playing' : e.data === 2 ? 'paused' : e.data === 0 ? 'ended' : e.data === 3 ? 'seek' : 'buffering'); },
++ onPlaybackRateChange: function (e) { onState('rate', e.data); },
++ onError: function () { onState('refused'); readyReject(new Error('YouTube refused this video.')); }
++ }});
++ }
++ loadScript('https://www.youtube.com/iframe_api', function () { return !!(window.YT && window.YT.Player); }, create).then(create).catch(function (err) { onState('refused'); readyReject(err); });
++ return { ready: ready, supportsRate: true, play: function () { player.playVideo(); }, pause: function () { player.pauseVideo(); }, seek: function (s) { player.seekTo(s, true); }, time: function () { return player.getCurrentTime(); }, rate: function (r) { if (player.setPlaybackRate) player.setPlaybackRate(r); }, destroy: function () { dead = true; if (player) player.destroy(); } };
++ }
++ function twitch(target, media, onState) {
++ var player, dead = false, readyResolve, readyReject;
++ var ready = new Promise(function (r, j) { readyResolve = r; readyReject = j; });
++ var options = { width: '100%', height: '100%', parent: [location.hostname], autoplay: false, muted: false, layout: 'video' };
++ options[media.live ? 'channel' : 'video'] = media.id;
++ loadScript('https://embed.twitch.tv/embed/v1.js', function () { return !!window.Twitch && !!window.Twitch.Embed; }, function () {}).then(function () {
++ if (dead) return;
++ var embed = new window.Twitch.Embed(target, options);
++ embed.addEventListener(window.Twitch.Embed.VIDEO_READY, function () {
++ clearTimeout(timer);
++ player = embed.getPlayer();
++ readyResolve();
++ ['PLAY', 'PAUSE', 'ENDED', 'SEEK', 'PLAYBACK_BLOCKED', 'OFFLINE'].forEach(function (name) {
++ var type = window.Twitch.Player && window.Twitch.Player[name];
++ if (type) player.addEventListener(type, function () { onState(name === 'PLAY' ? 'playing' : name === 'PAUSE' ? 'paused' : name === 'ENDED' ? 'ended' : name === 'SEEK' ? 'seek' : 'refused'); });
++ });
++ });
++ var timer = setTimeout(function () { if (!player) { onState('refused'); readyReject(new Error('Twitch player did not become ready.')); } }, 15000);
++ }).catch(function (err) { onState('refused'); readyReject(err); });
++ return { ready: ready, supportsRate: false, play: function () { player.play(); }, pause: function () { player.pause(); }, seek: function (s) { if (!media.live) player.seek(s); }, time: function () { return media.live ? 0 : player.getCurrentTime(); }, live: media.live, destroy: function () { dead = true; } };
++ }
++ function create(target, url, onState) { var p = parse(url); if (!p) return null; return p.kind === 'youtube' ? youtube(target, p, onState) : twitch(target, p, onState); }
++ return { parse: parse, create: create };
++}));
ddiff --git a/public/web/party.css b/public/web/party.css
iindex 23f351c..c660726 100644
---- a/public/web/party.css
++++ b/public/web/party.css
@@@ -46,6 +46,10 @@ 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-provider { position: absolute; inset: 0; width: 100%; height: 100%; }
++.pt-provider iframe { width: 100%; height: 100%; border: 0; }
++.pt-provider-refusal { margin: 0; padding: 10px 14px; color: var(--pt-muted); }
++.pt-provider-refusal[hidden] { display: none; }
.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;
ddiff --git a/public/web/party.js b/public/web/party.js
iindex d5157ef..00e11f3 100644
---- a/public/web/party.js
++++ b/public/web/party.js
@@@ -84,16 +84,21 @@
if (guest) root.classList.add('is-guest');
// ── the stage ────────────────────────────────────────────────────────────────
-- var video = el(isRadio ? 'audio' : 'video');
-- if (!isRadio) video.setAttribute('data-ov-player', '');
-- video.setAttribute('playsinline', '');
-- video.preload = 'auto';
-- 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 providerInfo = !isRadio && src && window.PartyProviders ? window.PartyProviders.parse(src) : null;
++ var video = providerInfo ? null : el(isRadio ? 'audio' : 'video');
++ var providerTarget = providerInfo ? el('div', 'pt-provider') : null;
++ var provider = null, providerApplying = false, providerFailure = false;
++ if (video) {
++ if (!isRadio) video.setAttribute('data-ov-player', '');
++ video.setAttribute('playsinline', '');
++ video.preload = 'auto';
++ video.controls = !guest || isRadio;
++ if (src) video.src = src;
++ }
var frame = el('div', 'pt-frame');
-- frame.appendChild(video);
++ if (video) frame.appendChild(video); else if (providerTarget) frame.appendChild(providerTarget);
var veil = el('div', 'pt-veil'); // waiting / reconnecting, over the picture
veil.setAttribute('role', 'status');
var drift = el('span', 'pt-drift', 'Catching up…');
@@@ -101,6 +106,14 @@
var floats = el('div', 'pt-floats'); // reactions rising over the picture
floats.setAttribute('aria-hidden', 'true');
frame.appendChild(veil); frame.appendChild(drift); frame.appendChild(floats);
++ var refusal = el('p', 'pt-provider-refusal'); refusal.hidden = true;
++ if (providerInfo) {
++ refusal.appendChild(el('span', '', 'This player could not play the link.'));
++ refusal.appendChild(document.createTextNode(' '));
++ var external = el('a', '', 'Open on ' + (providerInfo.kind === 'youtube' ? 'YouTube' : 'Twitch'));
++ external.href = providerInfo.url; external.target = '_blank'; external.rel = 'noopener';
++ refusal.appendChild(external);
++ }
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.
@@@ -123,6 +136,7 @@
if (invite) { var h1 = head.querySelector('h1'); if (h1 && h1.nextSibling) head.insertBefore(invite, h1.nextSibling); else head.appendChild(invite); }
stage.appendChild(head);
stage.appendChild(frame);
++ if (providerInfo) stage.appendChild(refusal);
if (!guest) stage.appendChild(bar);
if (isRadio) {
var radioBox = el('section', 'pt-radio');
@@@ -176,6 +190,7 @@
}
var text = '';
if (!ws || ws.readyState !== 1) text = joined ? 'Reconnecting…' : 'Joining the party…';
++ else if (providerFailure) text = '';
else if (!src) text = 'This party has nothing to play yet.';
else if (!everPlayed && clock && clock.paused) text = iHold() ? 'Press play when everyone is here.' : 'Waiting for ' + (holder().kind === 'person' ? nameOf(holder().subject) : 'the host') + ' to press play';
veil.textContent = text;
@@@ -183,8 +198,42 @@
frame.classList.toggle('is-waiting', !!text);
}
++ function providerClock(state, userAction) {
++ if (!provider || !state || !providerReady) return;
++ providerApplying = true;
++ try { window.PartyProviderSync.follow(provider, state, Date.now() + serverOffset, DRIFT_SHOWN_MS / 1000); } catch (e) { /* a provider may reject autoplay */ }
++ setTimeout(function () { providerApplying = false; }, 150);
++ if (userAction && iHold()) {
++ var pos = provider.live ? 0 : Math.max(0, Math.round((Number(provider.time()) || 0) * 1000));
++ send({ type: 'sync', media: src, position_ms: pos, rate: Number(state.rate) || 1, paused: !!state.paused });
++ }
++ }
++ var providerReady = false;
++ if (providerInfo && providerTarget && window.PartyProviders) {
++ provider = window.PartyProviders.create(providerTarget, src, function (event, value) {
++ if (event === 'refused') {
++ providerFailure = true; refusal.hidden = false; paintVeil(); return;
++ }
++ if (event === 'playing' || event === 'paused') {
++ if (providerApplying) return;
++ everPlayed = everPlayed || event === 'playing';
++ var state = { paused: event === 'paused', rate: clock && clock.rate || 1, position_ms: Math.round((Number(provider.time()) || 0) * 1000), at_ms: Date.now() };
++ if (!clock || !!clock.paused !== state.paused) providerClock(state, true);
++ paintVeil();
++ } else if (event === 'seek' && !providerApplying && iHold()) {
++ send({ type: 'sync', media: src, position_ms: provider.live ? 0 : Math.round((Number(provider.time()) || 0) * 1000), rate: Number(clock && clock.rate) || 1, paused: !!(clock && clock.paused) });
++ } else if (event === 'rate' && !providerApplying && iHold()) {
++ send({ type: 'sync', media: src, position_ms: Math.round((Number(provider.time()) || 0) * 1000), rate: Number(value) || 1, paused: !!(clock && clock.paused) });
++ }
++ });
++ if (!provider) { providerFailure = true; refusal.hidden = false; }
++ else provider.ready.then(function () {
++ providerReady = true; if (clock) providerClock(clock, false);
++ if (provider.live && iHold()) send({ type: 'sync', media: src, position_ms: 0, rate: 1, paused: !!(clock && clock.paused) });
++ }).catch(function () { providerFailure = true; refusal.hidden = false; paintVeil(); });
++ }
function applyGuestClock() {
-- if (!isRadio || !guest || !guestListening || !clock) return;
++ if (!video || !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 */ } }
@@@ -453,11 +502,13 @@
} else if (m.type === 'sync' && m.state) {
clock = m.state;
if (!clock.paused || clock.position_ms > 0) everPlayed = true;
-- if ((!isRadio || radio && radio.current) && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
-- if (guest) applyGuestClock();
++ if (video && (!isRadio || radio && radio.current) && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
++ if (provider) providerClock(clock, false);
++ if (isRadio && guest) applyGuestClock();
} else if (m.type === 'radio' && m.state) {
radio = m.state;
-- if (!radio.current) { video.pause(); video.removeAttribute('src'); src = null; }
++ if (!radio.current && video) { video.pause(); video.removeAttribute('src'); src = null; }
++ else if (radio.current && video && clock && typeof clock.media === 'string' && src !== clock.media) { src = clock.media; video.src = src; }
paintRadio();
} else if (m.type === 'lease' && m.state) {
var before = lease && lease.watchers ? lease.watchers : [];
@@@ -492,18 +543,23 @@
// Catching up shows only past a second of drift, and only while the room plays.
setInterval(function () {
var d = sync && sync.drift ? Math.abs(Number(sync.drift()) || 0) : 0;
++ if (provider && clock && !clock.paused && providerReady && !provider.live) {
++ var expected = window.PartyProviderSync.expected(clock, Date.now());
++ d = Math.abs((Number(provider.time()) || 0) - expected) * 1000;
++ if (d > DRIFT_SHOWN_MS) providerClock(clock, false);
++ }
drift.hidden = !(clock && !clock.paused && d > DRIFT_SHOWN_MS);
// A seat waits a minute; after it, the person is simply not here.
var now = Date.now(), changed = false;
Object.keys(away).forEach(function (s) { if (now - away[s] > 60000) { delete away[s]; changed = true; } });
if (changed) { paintPeople(); paintRemote(); }
}, 500);
-- video.addEventListener('play', function () { everPlayed = true; paintVeil(); });
-- video.addEventListener('ended', function () {
++ if (video) video.addEventListener('play', function () { everPlayed = true; paintVeil(); });
++ if (video) 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 (video) video.addEventListener('loadedmetadata', applyGuestClock);
if (isRadio && guest) {
setInterval(function () {
var key = null;
@@@ -516,8 +572,8 @@
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; }
++ if (radio && !radio.current && video) { video.pause(); video.removeAttribute('src'); src = null; }
++ else if (radio && radio.current && clock && typeof clock.media === 'string' && src !== clock.media && video) { src = clock.media; video.src = src; }
paintRadio(); applyGuestClock();
}).catch(function () { /* retry next poll */ });
}, 3000);
ddiff --git a/server/rooms/parties.js b/server/rooms/parties.js
iindex 44c8d03..f38d6f7 100644
---- a/server/rooms/parties.js
++++ b/server/rooms/parties.js
@@@ -5,9 +5,9 @@
*
* - An agent-stream party watches a public agent stream or its VOD: on a VOD the position follows the host's; on a
* live stream it follows the live edge (no position).
-- * - A media party watches a Media ref or an https URL on an OpenVibe host, exactly as a room's shared clock accepts
-- * media (server/rooms/sync-clock.js). Its timeline is the room's clock (paused at 0 when it starts) and its remote
-- * is the room's control lease, granted to the person who started it.
++ * - A media party watches a Media ref, an https URL on an OpenVibe host, or an official YouTube/Twitch watch URL,
++ * exactly as the room's shared clock accepts media (server/rooms/sync-clock.js). Its timeline is the room's clock
++ * (paused at 0 when it starts) and its remote is the room's control lease, granted to the person who started it.
* - Joining is by the party's link. The link carries a secret, the row stores its sha256 only, and a stranger
* without it sees the same 404 as a missing room.
* - Private by default: rooms.visibility is 'private', the party is never in the directory, and `listed` only lets
@@@ -51,7 +51,7 @@ function vodOf(value) {
function modeOf(vodId) { return vodId == null ? 'live' : 'vod'; }
/**
-- * A media party's media: a Media ref or an https URL on an OpenVibe host, validated by the shared clock's own rules
++ * A media party's media: a Media ref, an OpenVibe URL, or an official YouTube/Twitch URL, validated by the shared clock's own rules
* (server/rooms/sync-clock.js). A refusal is the party API's 422, not the clock's.
*/
function mediaOf(value) {
ddiff --git a/server/rooms/sync-clock.js b/server/rooms/sync-clock.js
iindex 3ae7599..7b7e5c3 100644
---- a/server/rooms/sync-clock.js
++++ b/server/rooms/sync-clock.js
@@@ -7,10 +7,9 @@
* The latest state lives in the module's Map (the live copy) and in room_sync_clocks (the durable copy). The cache is
* per process, like Chat's other room state; a second process reads the row from the database.
*
-- * Media is a Media ref or an https URL on an OpenVibe host (a domain one of the Contracts service manifests names).
-- * Party v1 plays OpenVibe media only: a holder must not be able to point every viewer's player, and so their
-- * addresses, at a server outside the platform. A set may leave media out to keep the room's current one, and may
-- * leave position_ms out to pause or resume where the timeline is now.
++ * Media is a Media ref, an https URL on an OpenVibe host (a domain one of the Contracts service manifests names),
++ * or an official YouTube/Twitch watch URL. Provider links identify official embeds; they are not media servers. A set
++ * may leave media out to keep the room's current one, and may leave position_ms out to pause or resume the timeline.
*/
const db = require('../db/database');
@@@ -49,6 +48,20 @@ function onOpenVibe(url) {
} catch (e) { return false; }
}
++function providerUrl(url) {
++ try {
++ const u = new URL(url);
++ if (u.protocol !== 'https:' || u.username || u.password || u.port || u.hash) return false;
++ const h = u.hostname.toLowerCase();
++ if (h === 'youtu.be') return /^\/[A-Za-z0-9_-]{11}\/?$/.test(u.pathname) && !u.search;
++ if (h === 'youtube.com' || h === 'www.youtube.com' || h === 'm.youtube.com') {
++ return (u.pathname === '/watch' && /^[A-Za-z0-9_-]{11}$/.test(u.searchParams.get('v') || '')) || /^\/(shorts|embed)\/[A-Za-z0-9_-]{11}\/?$/.test(u.pathname);
++ }
++ if (h === 'twitch.tv' || h === 'www.twitch.tv') return /^\/videos\/\d+\/?$/.test(u.pathname) || /^\/[A-Za-z0-9_]{1,25}\/?$/.test(u.pathname);
++ return false;
++ } catch { return false; }
++}
++
class SyncError extends Error {
constructor(code, message) { super(message || code); this.code = code; }
}
@@@ -99,7 +112,7 @@ function rowState(row, room) {
function mediaOf(value) {
if (typeof value === 'string') {
if (!HTTP_URL.test(value)) throw new SyncError('sync.media', 'media is an https URL or a Media ref');
-- if (!onOpenVibe(value)) throw new SyncError('sync.media', 'a party plays OpenVibe media: the link must be on an OpenVibe site');
++ if (!onOpenVibe(value) && !providerUrl(value)) throw new SyncError('sync.media', 'media is on an OpenVibe site, YouTube or Twitch');
return value;
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@@ -205,5 +218,5 @@ function close() { _cache.clear(); _queues.clear(); }
module.exports = {
MAX_POSITION_MS, MAX_RATE, SyncError, set, get, evict, close,
// Exposed for the WS layer, the party row and their tests; the validation stays here.
-- onOpenVibe, _mediaOf: mediaOf, _positionOf: positionOf, _rateOf: rateOf, _pausedOf: pausedOf,
++ onOpenVibe, providerUrl, _mediaOf: mediaOf, _positionOf: positionOf, _rateOf: rateOf, _pausedOf: pausedOf,
};
ddiff --git a/server/web/layout.js b/server/web/layout.js
iindex 70b2e9b..a6b6565 100644
---- a/server/web/layout.js
++++ b/server/web/layout.js
@@@ -78,11 +78,11 @@ function navConfig(o, config, site = {}, siteLinks = null) {
* pictures, avatars), and connections go to this site, Network (sign-in, the Frame) and Events (release and
* notification streams). Checked on production pages with the policy attached before shipping (2026-10-17).
*/
--function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
++function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat', partyProviders = false) {
return [
"default-src 'self'",
// 'inline-speculation-rules': the shared navbar's prefetch rules (Shared 4.70) run under the nonce policy too.
-- `script-src 'self' 'nonce-${nonce}' 'inline-speculation-rules' https://static.cloudflareinsights.com`,
++ `script-src 'self' 'nonce-${nonce}' 'inline-speculation-rules' https://static.cloudflareinsights.com${partyProviders ? ' https://www.youtube.com https://embed.twitch.tv' : ''}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"media-src 'self' blob: https:",
@@@ -93,7 +93,7 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
// GETs and only with the person's own Bearer (the Actor API's CORS route).
`connect-src 'self' ${wsOrigin} https://openvibe.chat https://openvibe.actor https://openvibe.network wss://openvibe.network https://openvibe.events wss://openvibe.events https://cloudflareinsights.com`,
// A workspace room's shared screen: a screen share page from OpenVibe.Bot (rooms.screen_url).
-- "frame-src 'self' https://openvibe.bot",
++ `frame-src 'self' https://openvibe.bot${partyProviders ? ' https://www.youtube-nocookie.com https://player.twitch.tv https://embed.twitch.tv' : ''}`,
"frame-ancestors 'self'",
"base-uri 'self'",
"object-src 'none'",
@@@ -162,7 +162,7 @@ ${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="
${o.app ? `<link rel="stylesheet" href="${asset(o.appStyle || 'vibe.css')}">` : ''}
${o.feedView ? `<link rel="stylesheet" href="${asset('feed-view.css')}">\n<script src="${asset('feed-view.js')}" defer></script>` : ''}
${o.partyHome ? `<link rel="stylesheet" href="${asset('party-home.css')}">` : ''}
--${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
++${o.party ? `<link rel="stylesheet" href="${ovServe.url('player.css')}">\n<link rel="stylesheet" href="${asset('party.css')}">\n<script src="${ovServe.url('player.js')}" defer></script>\n<script src="${ovServe.url('ov-sync.js')}" defer></script>\n<script src="${asset('party-providers.js')}" defer></script>\n<script src="${asset('party-provider-sync.js')}" defer></script>\n<script src="${asset('party.js')}" defer></script>` : ''}
${(o.scripts || []).map((n) => `<script src="${asset(n)}" defer></script>`).join('\n')}
<script src="${ovServe.url('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
ddiff --git a/server/web/party-pages.js b/server/web/party-pages.js
iindex df10c46..355afac 100644
---- a/server/web/party-pages.js
++++ b/server/web/party-pages.js
@@@ -66,7 +66,7 @@ function createPartyRoutes({ config }) {
const nonce = crypto.randomBytes(16).toString('base64');
res.status(status)
.set('Cache-Control', cache.htmlHeaders({ private: true }))
-- .set('Content-Security-Policy', contentSecurityPolicy(nonce, wsOrigin))
++ .set('Content-Security-Policy', contentSecurityPolicy(nonce, wsOrigin, !!o.party))
.type('html')
.send(renderPage({ config: partyConfig, actor, nonce, site: SITE, ...o }));
};
ddiff --git a/test/party-media.test.js b/test/party-media.test.js
iindex 4c29a6c..4590ae5 100644
---- a/test/party-media.test.js
++++ b/test/party-media.test.js
@@@ -84,6 +84,12 @@ t('a media party starts paused at 0, on the media, with the remote to the person
});
t('the media must be OpenVibe media, and the source page on an OpenVibe host', async () => {
++ for (const media of ['https://youtube.com/watch?v=abcdefghijk', 'https://youtu.be/abcdefghijk', 'https://youtube.com/shorts/abcdefghijk', 'https://twitch.tv/videos/12345', 'https://twitch.tv/some_channel']) {
++ const provider = await api('POST', '/parties', ann, { media, title: 'Provider watch' });
++ assert.strictEqual(provider.status, 201, provider.text);
++ const view = await api('GET', `/parties/${provider.body.party.id}`, ann);
++ assert.strictEqual(view.body.party.clock.media, media);
++ }
const outside = await api('POST', '/parties', ann, { media: 'https://tracker.example.com/film.webm', title: 'Elsewhere' });
assert.strictEqual(outside.status, 422, outside.text);
assert.strictEqual(outside.body.code, 'party.media');
@@@ -202,8 +208,13 @@ t('openvibe.party: the start form, sign-in, and /new', async () => {
// The navbar's Messages count calls Chat's DM API from this host: the page's CSP lets it.
const csp = String(shell.headers['content-security-policy'] || '');
assert.match(csp, /connect-src[^;]*https:\/\/openvibe\.chat(\s|;|$)/, 'connect-src names https://openvibe.chat');
++ assert.match(csp, /script-src[^;]*https:\/\/www\.youtube\.com/);
++ assert.match(csp, /script-src[^;]*https:\/\/embed\.twitch\.tv/);
++ assert.match(csp, /frame-src[^;]*https:\/\/www\.youtube-nocookie\.com/);
++ assert.match(csp, /frame-src[^;]*https:\/\/player\.twitch\.tv/);
++ assert.match(csp, /frame-src[^;]*https:\/\/embed\.twitch\.tv/, 'Twitch.Embed draws its player in an embed.twitch.tv frame');
// The page loads Opus's page and Shared's player and clock.
-- for (const file of ['/web/party.js', '/web/party.css', '/shared/ov-sync.js', '/shared/player.js', '/shared/player.css']) {
++ for (const file of ['/web/party.js', '/web/party.css', '/web/party-providers.js', '/web/party-provider-sync.js', '/shared/ov-sync.js', '/shared/player.js', '/shared/player.css']) {
assert.ok(shell.text.includes(file), `${file} is on the page`);
}
ddiff --git a/test/party-providers.test.js b/test/party-providers.test.js
nnew file mode 100644
iindex 0000000..aa8ab39
---- /dev/null
++++ b/test/party-providers.test.js
@@@ -0,0 +1,48 @@
++'use strict';
++const assert = require('assert');
++const { suite } = require('./helpers');
++const providers = require('../public/web/party-providers');
++const follow = require('../public/web/party-provider-sync').follow;
++const clock = require('../server/rooms/sync-clock');
++const t = suite('party providers');
++
++t('accepts only supported official YouTube and Twitch watch links', () => {
++ for (const [url, kind, id, live] of [
++ ['https://youtube.com/watch?v=abcdefghijk', 'youtube', 'abcdefghijk', false],
++ ['https://www.youtube.com/shorts/abcdefghijk', 'youtube', 'abcdefghijk', false],
++ ['https://youtu.be/abcdefghijk', 'youtube', 'abcdefghijk', false],
++ ['https://twitch.tv/videos/12345', 'twitch', '12345', false],
++ ['https://www.twitch.tv/some_channel', 'twitch', 'some_channel', true],
++ ]) {
++ const parsed = providers.parse(url);
++ assert.deepStrictEqual([parsed.kind, parsed.id, parsed.live], [kind, id, live]);
++ assert.strictEqual(clock._mediaOf(url), url);
++ }
++ for (const url of [
++ 'http://youtube.com/watch?v=abcdefghijk', 'https://youtube.com.evil.example/watch?v=abcdefghijk',
++ 'https://youtube.com/watch?v=bad', 'https://youtube.com.evil/watch?v=abcdefghijk',
++ 'https://twitch.tv.evil/some_channel', 'https://twitch.tv/videos/nope',
++ 'https://[email protected]/watch?v=abcdefghijk',
++ 'https://vimeo.com/12345', 'https://tracker.example/film.webm',
++ ]) {
++ assert.strictEqual(providers.parse(url), null, url);
++ assert.throws(() => clock._mediaOf(url), /media/);
++ }
++});
++
++t('corrects VOD drift and follows live Twitch without seeking', () => {
++ const actions = [];
++ const vod = { time: () => 14, supportsRate: true, seek: (s) => actions.push(['seek', s]), rate: (r) => actions.push(['rate', r]), play: () => actions.push(['play']), pause: () => actions.push(['pause']) };
++ const state = { position_ms: 10000, at_ms: 100000, rate: 1, paused: false };
++ follow(vod, state, 102000);
++ assert.deepStrictEqual(actions, [['seek', 12], ['play']]);
++ actions.length = 0;
++ vod.time = () => 11.2;
++ follow(vod, state, 102000);
++ assert.ok(actions.some((x) => x[0] === 'rate' && x[1] !== 1), 'small drift gets a rate nudge');
++ actions.length = 0;
++ follow({ live: true, time: () => 999, seek: () => actions.push(['seek']), play: () => actions.push(['play']), pause: () => actions.push(['pause']) }, state, 102000);
++ assert.deepStrictEqual(actions, [['play']]);
++});
++
++t.run();