openvibers/openvibe.chat Commits
Add YouTube and Twitch party playback (#414)
Changes
@@@ -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).nnew file mode 100644@@@ -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 };++}));nnew file mode 100644@@@ -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 };++}));@@@ -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;@@@ -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 pictureveil.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 picturefloats.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);@@@ -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) {@@@ -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,};@@@ -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>@@@ -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 }));};@@@ -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`);}nnew file mode 100644@@@ -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();
@@ -35,8 +35,9 @@ State:
@@ -35,8 +35,9 @@ State:
"set_by": "usr_01JAB2C3D4E5F6G7H8J9K0MNPS", "seq": 7 }
"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`
`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).
is always Chat's clock when Chat accepted the set; a player measures that clock with `time` (below).
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -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 };
+}));
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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 };
+}));
@@ -46,6 +46,10 @@ main[data-party] [hidden] { display: none !important; }
@@ -46,6 +46,10 @@ 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-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 {
.pt-frame.is-radio {
aspect-ratio: auto; width: 100%; display: grid; gap: 14px; padding: 18px;
aspect-ratio: auto; width: 100%; display: grid; gap: 14px; padding: 18px;
background: var(--pt-panel); border: 1px solid var(--pt-line); box-shadow: none;
background: var(--pt-panel); border: 1px solid var(--pt-line); box-shadow: none;
@@ -84,16 +84,21 @@
@@ -84,16 +84,21 @@
if (guest) root.classList.add('is-guest');
if (guest) root.classList.add('is-guest');
// ── the stage ────────────────────────────────────────────────────────────────
// ── 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);
var src = typeof (clock && clock.media) === 'string' ? clock.media : (typeof data.media === 'string' ? data.media : null);
if (isRadio && radio && !radio.current) src = 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');
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
var veil = el('div', 'pt-veil'); // waiting / reconnecting, over the picture
veil.setAttribute('role', 'status');
veil.setAttribute('role', 'status');
var drift = el('span', 'pt-drift', 'Catching up…');
var drift = el('span', 'pt-drift', 'Catching up…');
@@ -101,6 +106,14 @@
@@ -101,6 +106,14 @@
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);
+ 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');
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
// 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.
// the player, then the status line.
@@ -123,6 +136,7 @@
@@ -123,6 +136,7 @@
if (invite) { var h1 = head.querySelector('h1'); if (h1 && h1.nextSibling) head.insertBefore(invite, h1.nextSibling); else head.appendChild(invite); }
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(head);
stage.appendChild(frame);
stage.appendChild(frame);
+ if (providerInfo) stage.appendChild(refusal);
if (!guest) stage.appendChild(bar);
if (!guest) stage.appendChild(bar);
if (isRadio) {
if (isRadio) {
var radioBox = el('section', 'pt-radio');
var radioBox = el('section', 'pt-radio');
@@ -176,6 +190,7 @@
@@ -176,6 +190,7 @@
}
}
var text = '';
var text = '';
if (!ws || ws.readyState !== 1) text = joined ? 'Reconnecting…' : 'Joining the party…';
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 (!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';
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;
veil.textContent = text;
@@ -183,8 +198,42 @@
@@ -183,8 +198,42 @@
frame.classList.toggle('is-waiting', !!text);
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() {
function applyGuestClock() {
- if (!isRadio || !guest || !guestListening || !clock) return;
+ if (!video || !isRadio || !guest || !guestListening || !clock) return;
var serverNow = Date.now() + serverOffset;
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;
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 (Math.abs(video.currentTime - target) > 1) { try { video.currentTime = target; } catch (e) { /* metadata loading */ } }
@@ -453,11 +502,13 @@
@@ -453,11 +502,13 @@
} 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 ((!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) {
} else if (m.type === 'radio' && m.state) {
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();
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 : [];
@@ -492,18 +543,23 @@
@@ -492,18 +543,23 @@
// Catching up shows only past a second of drift, and only while the room plays.
// Catching up shows only past a second of drift, and only while the room plays.
setInterval(function () {
setInterval(function () {
var d = sync && sync.drift ? Math.abs(Number(sync.drift()) || 0) : 0;
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);
drift.hidden = !(clock && !clock.paused && d > DRIFT_SHOWN_MS);
// A seat waits a minute; after it, the person is simply not here.
// A seat waits a minute; after it, the person is simply not here.
var now = Date.now(), changed = false;
var now = Date.now(), changed = false;
Object.keys(away).forEach(function (s) { if (now - away[s] > 60000) { delete away[s]; changed = true; } });
Object.keys(away).forEach(function (s) { if (now - away[s] > 60000) { delete away[s]; changed = true; } });
if (changed) { paintPeople(); paintRemote(); }
if (changed) { paintPeople(); paintRemote(); }
}, 500);
}, 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))
if (isRadio && !guest && self && radio && radio.current && (self.subject === owner || radio.allow_everyone))
radioAction('skip', { expected_id: radio.current.id });
radioAction('skip', { expected_id: radio.current.id });
});
});
- video.addEventListener('loadedmetadata', applyGuestClock);
+ if (video) video.addEventListener('loadedmetadata', applyGuestClock);
if (isRadio && guest) {
if (isRadio && guest) {
setInterval(function () {
setInterval(function () {
var key = null;
var key = null;
@@ -516,8 +572,8 @@
@@ -516,8 +572,8 @@
var rtt = Date.now() - sentAt;
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; }
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;
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();
paintRadio(); applyGuestClock();
}).catch(function () { /* retry next poll */ });
}).catch(function () { /* retry next poll */ });
}, 3000);
}, 3000);
@@ -5,9 +5,9 @@
@@ -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
* - 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).
* 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
* - 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.
* 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
* - 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) {
@@ -51,7 +51,7 @@ function vodOf(value) {
function modeOf(vodId) { return vodId == null ? 'live' : 'vod'; }
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.
* (server/rooms/sync-clock.js). A refusal is the party API's 422, not the clock's.
*/
*/
function mediaOf(value) {
function mediaOf(value) {
@@ -7,10 +7,9 @@
@@ -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
* 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.
* 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');
const db = require('../db/database');
@@ -49,6 +48,20 @@ function onOpenVibe(url) {
@@ -49,6 +48,20 @@ function onOpenVibe(url) {
} catch (e) { return false; }
} 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 {
class SyncError extends Error {
constructor(code, message) { super(message || code); this.code = code; }
constructor(code, message) { super(message || code); this.code = code; }
}
}
@@ -99,7 +112,7 @@ function rowState(row, room) {
@@ -99,7 +112,7 @@ function rowState(row, room) {
function mediaOf(value) {
function mediaOf(value) {
if (typeof value === 'string') {
if (typeof value === 'string') {
if (!HTTP_URL.test(value)) throw new SyncError('sync.media', 'media is an https URL or a Media ref');
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;
return value;
}
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -205,5 +218,5 @@ function close() { _cache.clear(); _queues.clear(); }
@@ -205,5 +218,5 @@ function close() { _cache.clear(); _queues.clear(); }
module.exports = {
module.exports = {
MAX_POSITION_MS, MAX_RATE, SyncError, set, get, evict, close,
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.
// 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,
};
};
@@ -78,11 +78,11 @@ function navConfig(o, config, site = {}, siteLinks = null) {
@@ -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
* 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).
* 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 [
return [
"default-src 'self'",
"default-src 'self'",
// 'inline-speculation-rules': the shared navbar's prefetch rules (Shared 4.70) run under the nonce policy too.
// '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'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob: https:",
"img-src 'self' data: blob: https:",
"media-src 'self' blob: https:",
"media-src 'self' blob: https:",
@@ -93,7 +93,7 @@ function contentSecurityPolicy(nonce, wsOrigin = 'wss://openvibe.chat') {
@@ -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).
// 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`,
`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).
// 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'",
"frame-ancestors 'self'",
"base-uri 'self'",
"base-uri 'self'",
"object-src 'none'",
"object-src 'none'",
@@ -162,7 +162,7 @@ ${o.kit ? `<link rel="stylesheet" href="${ovServe.url('kit.css')}">\n<link rel="
@@ -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.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.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.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')}
${(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('theme-loader.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
<script src="${ovServe.url('navbar.js')}" defer></script>
@@ -66,7 +66,7 @@ function createPartyRoutes({ config }) {
@@ -66,7 +66,7 @@ function createPartyRoutes({ config }) {
const nonce = crypto.randomBytes(16).toString('base64');
const nonce = crypto.randomBytes(16).toString('base64');
res.status(status)
res.status(status)
.set('Cache-Control', cache.htmlHeaders({ private: true }))
.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')
.type('html')
.send(renderPage({ config: partyConfig, actor, nonce, site: SITE, ...o }));
.send(renderPage({ config: partyConfig, actor, nonce, site: SITE, ...o }));
};
};
@@ -84,6 +84,12 @@ t('a media party starts paused at 0, on the media, with the remote to the person
@@ -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 () => {
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' });
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.status, 422, outside.text);
assert.strictEqual(outside.body.code, 'party.media');
assert.strictEqual(outside.body.code, 'party.media');
@@ -202,8 +208,13 @@ t('openvibe.party: the start form, sign-in, and /new', async () => {
@@ -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.
// 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'] || '');
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, /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.
// 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`);
assert.ok(shell.text.includes(file), `${file} is on the page`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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();