openvibers/openvibe.live Commits
Stream sound comes back by itself: viewers follow a broadcaster who reconnects, and the first touch anywhere turns sound on (#370)
Changes
@@@ -2112,6 +2112,9 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }.unmute-overlay { position: absolute; inset: 0; z-index: 20; cursor: pointer; background: transparent; -webkit-tap-highlight-color: transparent; }.unmute-pill { position: absolute; left: 12px; top: 12px; display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px 8px 11px; border-radius: 999px; background: rgba(0,0,0,0.66); color: #fff; font: 650 13px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,0.35); animation: unmute-pop .3s ease-out both; }.unmute-overlay:hover .unmute-pill, .unmute-overlay:focus-visible .unmute-pill { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, #fff); }++/* Muted by the person's own choice (stream-player.js showMutedHint): the same pill as a button, for six seconds. */++.muted-hint { z-index: 21; border: 0; cursor: pointer; -webkit-tap-highlight-color: transparent; }++.muted-hint:hover, .muted-hint:focus-visible { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, #fff); }@keyframes unmute-pop { from { opacity: 0; transform: translateY(-4px) scale(.96); } }@media (prefers-reduced-motion: reduce) { .unmute-pill { animation: none; } }@@@ -1034,24 +1034,8 @@ function startJSMPEG(wsUrl, canvas, placeholder, bufferProfile = getJsmpegBuffer// Check if AudioContext is suspended (autoplay policy)const audioCtx = player?.audioOut?.context || player?.audioOut?.destination?.context;if (audioCtx && audioCtx.state === 'suspended' && !audioPrefs.muted) {-- console.warn('[JSMPEG] AudioContext suspended — showing unmute overlay');-- // Show unmute overlay on the canvas container-- const container = document.getElementById('video-container');-- if (container && !document.getElementById('unmute-overlay')) {-- const overlay = document.createElement('div');-- overlay.id = 'unmute-overlay';-- overlay.className = 'unmute-overlay';-- overlay.setAttribute('role', 'button');-- overlay.setAttribute('aria-label', 'Turn the sound on');-- overlay.innerHTML = '<span class="unmute-pill"><i class="fa-solid fa-volume-xmark"></i> Tap for sound</span>';-- overlay.addEventListener('click', () => {-- audioCtx.resume().then(() => {-- console.log('[JSMPEG] AudioContext resumed by user click');-- }).catch(() => {});-- overlay.remove();-- }, { once: true });-- container.appendChild(overlay);-- }++ console.warn('[JSMPEG] AudioContext suspended — sound comes on with the first touch');++ showUnmuteOverlay(null);}// Delay clip recording start so JSMpeg has time to decode & render@@@ -1384,6 +1368,15 @@ async function initWebRTC(stream) {showDiagnostics: true,});break;++ case 'sfu-source-moved':++ // The broadcaster reconnected and produces on a new transport; this viewer was still on the old++ // producers (a frozen picture, no sound). Watching again gets the current ones, with no reload.++ console.log('[Player] The stream moved to a new producer — watching it');++ if (!player?.ws || player.ws.readyState !== WebSocket.OPEN || _viewerIntentionalClose) break;++ player.watchSent = false;++ sendPlayerSignal({ type: 'watch' });++ player.watchSent = true;++ break;case 'sfu-keyframe-requested':// Server acknowledged the re-watch by requesting a keyframe from the producer.// Clear the offer timeout so we don't loop into "Still warming up".@@@ -1655,6 +1648,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatcvideo.muted = true;video.volume = 0;document.getElementById('unmute-overlay')?.remove();++ showMutedHint();}_maybeHidePlayerPlaceholderForLiveVideo(video);}).catch((err) => {@@@ -2112,11 +2106,7 @@ function initHLS(endpoint, stream) {showDiagnostics: true,});video.playsInline = true;-- video.autoplay = true;-- video.muted = true;-- video.play().catch(() => {-- showUnmuteOverlay(video);-- });++ playThenSound(video, () => video.play());});hls.on(Hls.Events.ERROR, (event, data) => {if (data.fatal) {@@@ -2170,11 +2160,7 @@ function initHLS(endpoint, stream) {showDiagnostics: true,});video.playsInline = true;-- video.autoplay = true;-- video.muted = true;-- video.play().catch(() => {-- showUnmuteOverlay(video);-- });++ playThenSound(video, () => video.play());});hls.on(Hls.Events.ERROR, (event, data) => {if (data.fatal) {@@@ -2242,11 +2228,7 @@ function tryFlvPlayer(flvUrl, video) {showDiagnostics: true,});video.playsInline = true;-- video.autoplay = true;-- video.muted = true;-- flvPlayer.play().catch(() => {-- showUnmuteOverlay(video);-- });++ playThenSound(video, () => flvPlayer.play());player = { flv: flvPlayer, video };} else {loadExternalScriptOnce('https://cdn.jsdelivr.net/npm/flv.js@latest').then(() => {@@@ -2277,11 +2259,7 @@ function tryFlvPlayer(flvUrl, video) {showDiagnostics: true,});video.playsInline = true;-- video.autoplay = true;-- video.muted = true;-- flvPlayer.play().catch(() => {-- showUnmuteOverlay(video);-- });++ playThenSound(video, () => flvPlayer.play());player = { flv: flvPlayer, video };} else {showStreamError('Your browser does not support FLV playback');@@@ -2705,8 +2683,12 @@ function setupVideoControls() {volSlider.value = savedVol;// For WebRTC (SFU), the player start path sets volume + handles autoplay policy.// For JSMPEG, onSourceEstablished handles it. For HLS, set it here.++ // HLS: the level only. Unmuting here, before the page was touched, made the browser pause the video; playThenSound++ // turns the sound on when it may.if (playerType === 'hls') {-- setVolume(savedVol / 100, { muted: audioPrefs.muted });++ const hv = document.getElementById('video-element');++ if (audioPrefs.muted) setVolume(savedVol / 100, { muted: true });++ else if (hv) hv.volume = savedVol / 100;}let muted = audioPrefs.muted;@@@ -3570,19 +3552,64 @@ function _clearStreamError() {}}--// The media volume keys (UI Events names, and the older ones): while the stream is muted for autoplay, volume up is a tap.++/* ── Sound, on with the first touch (the owner, 2026-10-26) ───────────────────────────────────++ A browser lets a page play sound only after the person has touched it, so a stream that should be heard starts++ muted (so it starts at all). People had to find the "Tap for sound" pill on the picture, and many refreshed instead.++ Now the first tap, click or key ANYWHERE on the page turns the sound on, and from then on every reconnect comes back++ with sound by itself (the page has been touched). The pill stays as the visible hint until then. A mute the person++ chose (the volume button, the slider at 0) is kept: only sound the browser held back is turned on. */++let _soundHeldBack = false;++++/** Turn on sound the browser held back. Returns whether there was any. */++function unlockSound() {++ if (!_soundHeldBack || getSavedMuted()) return false;++ _soundHeldBack = false;++ const savedVol = getSavedVolume();++ const vol = savedVol > 0 ? savedVol / 100 : 0.75;++ const ctx = player && player.audioOut && (player.audioOut.context || (player.audioOut.destination && player.audioOut.destination.context));++ if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => {});++ if (player && player.audioOut && player.audioOut.gain) player.audioOut.gain.value = vol;++ const video = document.getElementById('video-element');++ if (video && (video.srcObject || video.currentSrc)) {++ video.volume = vol;++ video.muted = false;++ // Still refused (no real activation, e.g. a synthetic event): muted again, and the hint comes back.++ video.play().catch(() => { video.muted = true; video.play().catch(() => {}); showUnmuteOverlay(video); });++ }++ try { localStorage.setItem(PLAYER_MUTED_KEY, '0'); } catch {}++ const slider = document.getElementById('volume-slider');++ if (slider) slider.value = Math.round(vol * 100);++ const volBtn = document.getElementById('btn-volume');++ if (volBtn) volBtn.innerHTML = '<i class="fa-solid fa-volume-high"></i>';++ document.getElementById('unmute-overlay')?.remove();++ return true;++}++++// The first touch anywhere: a tap, a click, a key (the events that count as a person's activation). The volume control++// decides for itself (a click on the mute button means mute).++for (const type of ['pointerup', 'touchend', 'click', 'keydown']) {++ document.addEventListener(type, (e) => {++ if (!_soundHeldBack) return;++ if (type === 'keydown' && (e.key === 'Escape' || e.key === 'Tab' || e.metaKey || e.ctrlKey || e.altKey)) return;++ if (e.target && e.target.closest && e.target.closest('#btn-volume, #volume-slider')) return;++ unlockSound();++ }, { capture: true, passive: true });++}++++// The media volume keys (UI Events names, and the older ones): while the browser holds the sound back, volume up is a tap.document.addEventListener('keydown', (e) => {if (!/^(AudioVolumeUp|VolumeUp|AudioVolumeMute|VolumeMute)$/.test(e.key)) return;-- const overlay = document.getElementById('unmute-overlay');-- if (overlay) overlay.click();++ unlockSound();});/**-- * Show a "Tap for sound" pill when autoplay policy forced muted playback.-- * One tap anywhere on the picture unmutes, restores volume, and removes it.++ * The browser held the stream's sound back: a "Tap for sound" pill on the picture until the first touch anywhere on++ * the page (unlockSound) turns it on. `video` is the element (null for JSMPEG's Web Audio).*/function showUnmuteOverlay(video) {-- // Don't duplicate++ void video;++ if (getSavedMuted()) return; // the person's own mute: no hint of a block++ _soundHeldBack = true;if (document.getElementById('unmute-overlay')) return;const container = document.getElementById('video-container');if (!container) return;@@@ -3590,29 +3617,53 @@ function showUnmuteOverlay(video) {overlay.id = 'unmute-overlay';overlay.className = 'unmute-overlay';overlay.setAttribute('role', 'button');++ overlay.setAttribute('tabindex', '0');overlay.setAttribute('aria-label', 'Turn the sound on');-- // The stream stays in view (owner, 2026-10-25): a small pill says it is muted, and a tap anywhere on the picture or a-- // media volume key (where the browser passes it to the page) turns the sound on.++ // The stream stays in view (owner, 2026-10-25): a small pill says the sound waits for a tap, anywhere on the page.overlay.innerHTML = '<span class="unmute-pill"><i class="fa-solid fa-volume-xmark"></i> Tap for sound</span>';-- overlay.addEventListener('click', () => {++ overlay.addEventListener('click', () => unlockSound());++ container.appendChild(overlay);++}++++/**++ * Start an HLS or FLV stream: muted first (autoplay always allows that), then the sound, right away when the page has++ * been touched, else with the first touch (the pill). Muted by the person's choice: the short hint instead.++ */++function playThenSound(video, play) {++ video.autoplay = true;++ video.muted = true;++ Promise.resolve().then(play).then(() => {++ if (getSavedMuted()) { showMutedHint(); return; }++ const savedVol = getSavedVolume();++ video.volume = savedVol > 0 ? savedVol / 100 : 0.75;video.muted = false;-- // Restore volume from saved preference / slider++ const p = video.play();++ if (p) p.catch(() => { video.muted = true; video.play().catch(() => {}); showUnmuteOverlay(video); });++ }).catch(() => showUnmuteOverlay(video));++}++++/** Muted because the person muted it (here or on another stream): say so for a few seconds, one tap turns it on. */++function showMutedHint() {++ const container = document.getElementById('video-container');++ if (!container || document.getElementById('unmute-overlay') || document.getElementById('muted-hint')) return;++ const hint = document.createElement('button');++ hint.type = 'button';++ hint.id = 'muted-hint';++ hint.className = 'unmute-pill muted-hint';++ hint.innerHTML = '<i class="fa-solid fa-volume-xmark"></i> Sound is off · tap to turn on';++ hint.addEventListener('click', (e) => {++ e.stopPropagation();++ hint.remove();++ setVolume((getSavedVolume() || 75) / 100, { muted: false });++ const video = document.getElementById('video-element');++ if (video) video.play().catch(() => {});const slider = document.getElementById('volume-slider');-- const savedVol = getSavedVolume();-- const vol = savedVol > 0 ? savedVol / 100 : (slider ? slider.value / 100 : 0.75);-- video.volume = vol;-- if (slider) slider.value = Math.round(vol * 100);-- try { localStorage.setItem(PLAYER_MUTED_KEY, '0'); } catch {}-- overlay.remove();-- // Sync the volume button icon++ if (slider) slider.value = getSavedVolume() || 75;const volBtn = document.getElementById('btn-volume');-- if (volBtn) volBtn.innerHTML = vol === 0-- ? '<i class="fa-solid fa-volume-xmark"></i>'-- : '<i class="fa-solid fa-volume-high"></i>';-- // Try playing again now that user has interacted-- video.play().catch(() => {});-- }, { once: true });-- container.appendChild(overlay);++ if (volBtn) volBtn.innerHTML = '<i class="fa-solid fa-volume-high"></i>';++ });++ container.appendChild(hint);++ setTimeout(() => hint.remove(), 6000);}/* ── Player stats overlay ─────────────────────────────────────@@@ -149,11 +149,14 @@ class BroadcastServer extends EventEmitter {// When a WHIP producer is added to the SFU, notify any pending viewerswebrtcSFU.on('producer-added', ({ roomId, kind }) => {-- if (kind !== 'video') return; // Notify only on video producerconst match = roomId.match(/^stream-(\d+)$/);if (!match) return;const streamId = parseInt(match[1]);-- this._notifyPendingWatchers(streamId);++ if (kind === 'video') this._notifyPendingWatchers(streamId);++ // A broadcaster who reconnected produces again while the old producers stay open on the dead transport.++ // Viewers already watching kept consuming those (frozen picture, no sound, "refresh to get audio back"):++ // once the new producer is the one findProducerByKind picks (its ICE up), tell them to watch again.++ for (const ms of [1500, 5000]) setTimeout(() => this._moveViewersToCurrent(streamId), ms).unref?.();});// When WHIP ICE connects, pending viewers that were queued during ICE negotiation@@@ -697,6 +700,39 @@ class BroadcastServer extends EventEmitter {// ── SFU Viewer Path (mediasoup-client signaling) ──────────++ /**++ * Is this viewer consuming something other than the stream's current producers (findProducerByKind: the newest++ * transport whose ICE is up)? True when a consumer's producer is not the current one of its kind, or a kind the++ * stream now has (audio arriving later) is missing.++ */++ _viewerOnStaleProducers(client) {++ const room = client._sfuViewerRoomId && webrtcSFU.rooms?.get(client._sfuViewerRoomId);++ if (!room) return false;++ const consumed = new Map();++ for (const cid of client._sfuViewerConsumerIds || []) {++ const c = room.consumers.get(cid)?.consumer;++ if (c && !c.closed) consumed.set(c.kind, c.producerId);++ }++ if (!consumed.size) return false;++ for (const kind of ['video', 'audio']) {++ const cur = webrtcSFU.findProducerByKind(client._sfuViewerRoomId, kind);++ if (cur && consumed.get(kind) !== cur.id) return true;++ }++ return false;++ }++++ /** Ask every viewer of a stream that is on stale producers to watch again (it then gets the current ones). */++ _moveViewersToCurrent(streamId) {++ let moved = 0;++ for (const [viewerWs, client] of this.clients) {++ if (client.streamId !== streamId || client.role !== 'viewer' || !client._sfuViewerTransportId) continue;++ if (!this._viewerOnStaleProducers(client)) continue;++ this.safeSend(viewerWs, { type: 'sfu-source-moved' });++ moved++;++ }++ if (moved) console.log(`[Broadcast] Stream ${streamId}: ${moved} viewer(s) on an old producer asked to watch the current one`);++ }++/*** Check if SFU producers exist and notify viewer to start mediasoup-client flow.* Returns true if an sfu-viewer-ready was sent.@@@ -720,7 +756,12 @@ class BroadcastServer extends EventEmitter {return entry?.consumer && !entry.consumer.closed;});-- if (activeConsumerIds.length > 0) {++ if (activeConsumerIds.length > 0 && this._viewerOnStaleProducers(client)) {++ // Alive, but on producers the broadcaster left behind (a reconnect): a keyframe nudge would ask++ // the dead producer again. A fresh consume of the current ones below.++ console.log(`[Broadcast] Viewer ${client.peerId} re-watch on an old producer — moving it to the current ones`);++ this._cleanupSfuViewerTransport(client);++ } else if (activeConsumerIds.length > 0) {// Consumers are alive — nudge with keyframe and ack the client so// startWatchOfferTimeout doesn't fire and loop.// Throttle: a looping client (or many viewers re-watching at once) must@@@ -809,14 +850,20 @@ class BroadcastServer extends EventEmitter {return false;}++ // One producer per kind: the current one (findProducerByKind), never also the one a reconnect left behind. A++ // viewer given both played the first video track it got, which could be the dead one.++ const chosen = ['video', 'audio'].map((kind) => {++ const cur = webrtcSFU.findProducerByKind(roomId, kind);++ return (cur && liveProducers.find((p) => p.id === cur.id)) || liveProducers.find((p) => p.kind === kind);++ }).filter(Boolean);// Send capabilities + producer list — viewer will use mediasoup-client Devicethis.safeSend(ws, {type: 'sfu-viewer-ready',rtpCapabilities: caps,-- producers: liveProducers.map(p => ({ id: p.id, kind: p.kind })),++ producers: chosen.map(p => ({ id: p.id, kind: p.kind })),});-- console.log(`[Broadcast] SFU viewer ready sent to ${client.peerId} for stream ${client.streamId} (${liveProducers.length}/${allProducers.length} live producer(s))`);++ console.log(`[Broadcast] SFU viewer ready sent to ${client.peerId} for stream ${client.streamId} (${chosen.length} chosen of ${liveProducers.length}/${allProducers.length} live producer(s))`);return true;}nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++// Sound on the stream page (the owner, 2026-10-26: people "having to refresh the stream page over and over again, or++// click or something like that, to get audio to come back properly … so that people don't even realize they're++// enabling it").++//++// Two causes:++// 1. A broadcaster whose network drops produces again on a new transport while the old producers stay open on the++// dead one. Viewers already watching kept consuming those: a frozen picture and no sound until a reload. A++// re-watch only asked the dead producer for a keyframe. Now the server moves them: a re-watch on a stale++// producer gets a fresh consume of the current ones, a new producer taking over sends `sfu-source-moved`++// (the player watches again), and a viewer is given one producer per kind, the current one.++// 2. Sound the browser held back came on only from a pill on the picture. Now the first tap, click or key anywhere++// on the page turns it on; a mute the person chose is kept and gets a short hint instead.++//++// node test/viewer-follow-sound.test.js++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const vm = require('vm');++++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = () => {};++console.warn = () => {};++++(async () => {++ // ── 1. The server follows the broadcaster's current producers ──++ const sfu = require('../server/streaming/webrtc-sfu');++ const bs = require('../server/streaming/broadcast-server');++ const current = { video: 'v-new', audio: 'a-new' };++ sfu.findProducerByKind = (roomId, kind) => (current[kind] ? { id: current[kind], kind } : null);++ const consumers = new Map([++ ['c1', { consumer: { kind: 'video', producerId: 'v-old', closed: false } }],++ ['c2', { consumer: { kind: 'audio', producerId: 'a-old', closed: false } }],++ ['c3', { consumer: { kind: 'video', producerId: 'v-new', closed: false } }],++ ['c4', { consumer: { kind: 'audio', producerId: 'a-new', closed: false } }],++ ]);++ sfu.rooms = new Map([['stream-9', { consumers, transports: new Map(), producers: new Map() }]]);++ const stale = { streamId: 9, role: 'viewer', peerId: 'p1', _sfuViewerRoomId: 'stream-9', _sfuViewerTransportId: 't1', _sfuViewerConsumerIds: ['c1', 'c2'] };++ const fresh = { streamId: 9, role: 'viewer', peerId: 'p2', _sfuViewerRoomId: 'stream-9', _sfuViewerTransportId: 't2', _sfuViewerConsumerIds: ['c3', 'c4'] };++ const videoOnly = { ...fresh, peerId: 'p3', _sfuViewerConsumerIds: ['c3'] };++ assert.strictEqual(bs._viewerOnStaleProducers(stale), true, 'consuming the producers a reconnect left behind');++ assert.strictEqual(bs._viewerOnStaleProducers(fresh), false, 'on the current ones');++ assert.strictEqual(bs._viewerOnStaleProducers(videoOnly), true, 'audio arrived later and this viewer has none');++ assert.strictEqual(bs._viewerOnStaleProducers({ ...stale, _sfuViewerConsumerIds: [] }), false, 'nothing consumed yet: not this check');++++ const sent = [];++ const ws = (name) => ({ name, readyState: 1, send: (m) => sent.push([name, JSON.parse(m).type]) });++ const wsStale = ws('stale'), wsFresh = ws('fresh');++ const realClients = bs.clients;++ bs.clients = new Map([[wsStale, stale], [wsFresh, fresh], [ws('other'), { ...stale, streamId: 10 }]]);++ bs.safeSend = (w, msg) => w.send(JSON.stringify(msg));++ bs._moveViewersToCurrent(9);++ assert.deepStrictEqual(sent, [['stale', 'sfu-source-moved']], 'only the viewer on old producers is asked to watch again');++ bs.clients = realClients;++++ // The server code: a re-watch on a stale producer is a fresh consume (not a keyframe nudge), one producer per kind,++ // and a producer added in the room checks the viewers.++ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'streaming', 'broadcast-server.js'), 'utf8');++ assert.match(src, /activeConsumerIds\.length > 0 && this\._viewerOnStaleProducers\(client\)\) \{[\s\S]{0,400}this\._cleanupSfuViewerTransport\(client\);\s*\} else if \(activeConsumerIds\.length > 0\)/);++ assert.match(src, /const chosen = \['video', 'audio'\]\.map\(\(kind\) => \{[\s\S]{0,800}producers: chosen\.map/);++ assert.match(src, /webrtcSFU\.on\('producer-added'[\s\S]{0,700}this\._moveViewersToCurrent\(streamId\)/);++++ // ── 2. Sound on with the first touch ──++ const player = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'stream-player.js'), 'utf8');++ assert.match(player, /case 'sfu-source-moved':[\s\S]{0,1200}sendPlayerSignal\(\{ type: 'watch' \}\)/, 'the player watches again when told');++ const block = player.slice(player.indexOf('let _soundHeldBack = false;'), player.indexOf('/* ── Player stats overlay'));++ function page({ muted = false, volume = 80 } = {}) {++ const store = { openvibe_player_muted: muted ? '1' : '0', openvibe_player_volume: String(volume) };++ const listeners = {};++ const els = {};++ const el = (id, extra = {}) => (els[id] = Object.assign({ id, innerHTML: '', value: 0, children: [], appendChild(c) { this.children.push(c); els[c.id] = c; c.parent = this; }, remove() { delete els[this.id]; }, addEventListener(t, f) { (this.l ||= {})[t] = f; }, setAttribute() {}, click() { this.l && this.l.click && this.l.click({ stopPropagation() {} }); } }, extra));++ const video = el('video-element', { srcObject: {}, muted: true, volume: 0, plays: 0, play() { this.plays++; return Promise.resolve(); } });++ el('video-container'); el('volume-slider'); el('btn-volume');++ const ctx = {++ console, Promise, setTimeout: (f) => f && 0, JSON, Math, String, Number,++ localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },++ document: {++ getElementById: (id) => els[id] || null,++ createElement: () => el('new-' + Math.random(), {}),++ addEventListener: (t, f, o) => (listeners[t] ||= []).push(f),++ },++ player: null, playerType: 'webrtc',++ PLAYER_MUTED_KEY: 'openvibe_player_muted',++ getSavedMuted: () => store.openvibe_player_muted === '1',++ getSavedVolume: () => Number(store.openvibe_player_volume),++ setVolume: (v, o) => { video.volume = o.muted ? 0 : v; video.muted = !!o.muted; store.openvibe_player_muted = o.muted ? '1' : '0'; },++ };++ vm.createContext(ctx);++ vm.runInContext(block, ctx);++ const fire = (type, target = { closest: () => null }, extra = {}) => (listeners[type] || []).forEach((f) => f({ target, key: 'a', ...extra }));++ return { ctx, video, store, els, fire };++ }++ // Held back by the browser: a tap anywhere turns the sound on, at the saved level.++ let p = page();++ p.ctx.showUnmuteOverlay(p.video);++ assert.ok(p.els['unmute-overlay'], 'the pill shows');++ p.fire('pointerup');++ assert.deepStrictEqual([p.video.muted, p.video.volume, p.store.openvibe_player_muted], [false, 0.8, '0'], 'a tap on the chat, the page, anything');++ assert.ok(!p.els['unmute-overlay'], 'and the pill goes');++ // A key does it too (not Escape, Tab or a shortcut).++ p = page();++ p.ctx.showUnmuteOverlay(p.video);++ p.fire('keydown', undefined, { key: 'Escape' });++ assert.strictEqual(p.video.muted, true, 'Escape is not "turn the sound on"');++ p.fire('keydown', undefined, { key: 'k' });++ assert.strictEqual(p.video.muted, false);++ // The volume control decides for itself: a click on the mute button is a mute, not an unmute.++ p = page();++ p.ctx.showUnmuteOverlay(p.video);++ p.fire('click', { closest: (sel) => (sel.includes('#btn-volume') ? {} : null) });++ assert.strictEqual(p.video.muted, true, 'the mute button is left alone');++ // A mute the person chose: no pill, no unmute on a tap; the short hint instead.++ p = page({ muted: true });++ p.ctx.showUnmuteOverlay(p.video);++ assert.ok(!p.els['unmute-overlay'], 'no "tap for sound" for a chosen mute');++ p.fire('pointerup');++ assert.strictEqual(p.video.muted, true, 'their mute stands');++ p.ctx.showMutedHint();++ assert.ok(p.els['muted-hint'], 'a short "sound is off" hint');++ p.els['muted-hint'].click();++ assert.deepStrictEqual([p.video.muted, p.store.openvibe_player_muted], [false, '0'], 'one tap on it turns the sound on');++++ // HLS and FLV start muted, then turn the sound on when they may (no more silent playback with no pill).++ assert.strictEqual((player.match(/playThenSound\(video, \(\) => (video|flvPlayer)\.play\(\)\)/g) || []).length, 4);++ assert.ok(!/video\.muted = true;\n\s*video\.play\(\)\.catch\(\(\) => \{\n\s*showUnmuteOverlay\(video\);/.test(player), 'no start path left that stays muted without a word');++++ quiet('viewer follow + sound: all checks passed');++ process.exit(0);++})().catch((err) => { quiet(err); process.exit(1); });
@@ -2112,6 +2112,9 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
@@ -2112,6 +2112,9 @@ a.pulse-card:hover { border-color: var(--accent); transform: translateY(-2px); }
.unmute-overlay { position: absolute; inset: 0; z-index: 20; cursor: pointer; background: transparent; -webkit-tap-highlight-color: transparent; }
.unmute-overlay { position: absolute; inset: 0; z-index: 20; cursor: pointer; background: transparent; -webkit-tap-highlight-color: transparent; }
.unmute-pill { position: absolute; left: 12px; top: 12px; display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px 8px 11px; border-radius: 999px; background: rgba(0,0,0,0.66); color: #fff; font: 650 13px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,0.35); animation: unmute-pop .3s ease-out both; }
.unmute-pill { position: absolute; left: 12px; top: 12px; display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px 8px 11px; border-radius: 999px; background: rgba(0,0,0,0.66); color: #fff; font: 650 13px/1 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; -webkit-backdrop-filter: blur(8px); backdrop-filter: blur(8px); box-shadow: 0 4px 18px rgba(0,0,0,0.35); animation: unmute-pop .3s ease-out both; }
.unmute-overlay:hover .unmute-pill, .unmute-overlay:focus-visible .unmute-pill { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, #fff); }
.unmute-overlay:hover .unmute-pill, .unmute-overlay:focus-visible .unmute-pill { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, #fff); }
+/* Muted by the person's own choice (stream-player.js showMutedHint): the same pill as a button, for six seconds. */
+.muted-hint { z-index: 21; border: 0; cursor: pointer; -webkit-tap-highlight-color: transparent; }
+.muted-hint:hover, .muted-hint:focus-visible { background: var(--accent-strong, var(--accent, #3b82f6)); color: var(--on-accent-strong, #fff); }
@keyframes unmute-pop { from { opacity: 0; transform: translateY(-4px) scale(.96); } }
@keyframes unmute-pop { from { opacity: 0; transform: translateY(-4px) scale(.96); } }
@media (prefers-reduced-motion: reduce) { .unmute-pill { animation: none; } }
@media (prefers-reduced-motion: reduce) { .unmute-pill { animation: none; } }
@@ -1034,24 +1034,8 @@ function startJSMPEG(wsUrl, canvas, placeholder, bufferProfile = getJsmpegBuffer
@@ -1034,24 +1034,8 @@ function startJSMPEG(wsUrl, canvas, placeholder, bufferProfile = getJsmpegBuffer
// Check if AudioContext is suspended (autoplay policy)
// Check if AudioContext is suspended (autoplay policy)
const audioCtx = player?.audioOut?.context || player?.audioOut?.destination?.context;
const audioCtx = player?.audioOut?.context || player?.audioOut?.destination?.context;
if (audioCtx && audioCtx.state === 'suspended' && !audioPrefs.muted) {
if (audioCtx && audioCtx.state === 'suspended' && !audioPrefs.muted) {
- console.warn('[JSMPEG] AudioContext suspended — showing unmute overlay');
- // Show unmute overlay on the canvas container
- const container = document.getElementById('video-container');
- if (container && !document.getElementById('unmute-overlay')) {
- const overlay = document.createElement('div');
- overlay.id = 'unmute-overlay';
- overlay.className = 'unmute-overlay';
- overlay.setAttribute('role', 'button');
- overlay.setAttribute('aria-label', 'Turn the sound on');
- overlay.innerHTML = '<span class="unmute-pill"><i class="fa-solid fa-volume-xmark"></i> Tap for sound</span>';
- overlay.addEventListener('click', () => {
- audioCtx.resume().then(() => {
- console.log('[JSMPEG] AudioContext resumed by user click');
- }).catch(() => {});
- overlay.remove();
- }, { once: true });
- container.appendChild(overlay);
- }
+ console.warn('[JSMPEG] AudioContext suspended — sound comes on with the first touch');
+ showUnmuteOverlay(null);
}
}
// Delay clip recording start so JSMpeg has time to decode & render
// Delay clip recording start so JSMpeg has time to decode & render
@@ -1384,6 +1368,15 @@ async function initWebRTC(stream) {
@@ -1384,6 +1368,15 @@ async function initWebRTC(stream) {
showDiagnostics: true,
showDiagnostics: true,
});
});
break;
break;
+ case 'sfu-source-moved':
+ // The broadcaster reconnected and produces on a new transport; this viewer was still on the old
+ // producers (a frozen picture, no sound). Watching again gets the current ones, with no reload.
+ console.log('[Player] The stream moved to a new producer — watching it');
+ if (!player?.ws || player.ws.readyState !== WebSocket.OPEN || _viewerIntentionalClose) break;
+ player.watchSent = false;
+ sendPlayerSignal({ type: 'watch' });
+ player.watchSent = true;
+ break;
case 'sfu-keyframe-requested':
case 'sfu-keyframe-requested':
// Server acknowledged the re-watch by requesting a keyframe from the producer.
// Server acknowledged the re-watch by requesting a keyframe from the producer.
// Clear the offer timeout so we don't loop into "Still warming up".
// Clear the offer timeout so we don't loop into "Still warming up".
@@ -1655,6 +1648,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
@@ -1655,6 +1648,7 @@ async function handleSfuViewerReady(msg, ws, video, updateStatus, scheduleRewatc
video.muted = true;
video.muted = true;
video.volume = 0;
video.volume = 0;
document.getElementById('unmute-overlay')?.remove();
document.getElementById('unmute-overlay')?.remove();
+ showMutedHint();
}
}
_maybeHidePlayerPlaceholderForLiveVideo(video);
_maybeHidePlayerPlaceholderForLiveVideo(video);
}).catch((err) => {
}).catch((err) => {
@@ -2112,11 +2106,7 @@ function initHLS(endpoint, stream) {
@@ -2112,11 +2106,7 @@ function initHLS(endpoint, stream) {
showDiagnostics: true,
showDiagnostics: true,
});
});
video.playsInline = true;
video.playsInline = true;
- video.autoplay = true;
- video.muted = true;
- video.play().catch(() => {
- showUnmuteOverlay(video);
- });
+ playThenSound(video, () => video.play());
});
});
hls.on(Hls.Events.ERROR, (event, data) => {
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
if (data.fatal) {
@@ -2170,11 +2160,7 @@ function initHLS(endpoint, stream) {
@@ -2170,11 +2160,7 @@ function initHLS(endpoint, stream) {
showDiagnostics: true,
showDiagnostics: true,
});
});
video.playsInline = true;
video.playsInline = true;
- video.autoplay = true;
- video.muted = true;
- video.play().catch(() => {
- showUnmuteOverlay(video);
- });
+ playThenSound(video, () => video.play());
});
});
hls.on(Hls.Events.ERROR, (event, data) => {
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
if (data.fatal) {
@@ -2242,11 +2228,7 @@ function tryFlvPlayer(flvUrl, video) {
@@ -2242,11 +2228,7 @@ function tryFlvPlayer(flvUrl, video) {
showDiagnostics: true,
showDiagnostics: true,
});
});
video.playsInline = true;
video.playsInline = true;
- video.autoplay = true;
- video.muted = true;
- flvPlayer.play().catch(() => {
- showUnmuteOverlay(video);
- });
+ playThenSound(video, () => flvPlayer.play());
player = { flv: flvPlayer, video };
player = { flv: flvPlayer, video };
} else {
} else {
loadExternalScriptOnce('https://cdn.jsdelivr.net/npm/flv.js@latest').then(() => {
loadExternalScriptOnce('https://cdn.jsdelivr.net/npm/flv.js@latest').then(() => {
@@ -2277,11 +2259,7 @@ function tryFlvPlayer(flvUrl, video) {
@@ -2277,11 +2259,7 @@ function tryFlvPlayer(flvUrl, video) {
showDiagnostics: true,
showDiagnostics: true,
});
});
video.playsInline = true;
video.playsInline = true;
- video.autoplay = true;
- video.muted = true;
- flvPlayer.play().catch(() => {
- showUnmuteOverlay(video);
- });
+ playThenSound(video, () => flvPlayer.play());
player = { flv: flvPlayer, video };
player = { flv: flvPlayer, video };
} else {
} else {
showStreamError('Your browser does not support FLV playback');
showStreamError('Your browser does not support FLV playback');
@@ -2705,8 +2683,12 @@ function setupVideoControls() {
@@ -2705,8 +2683,12 @@ function setupVideoControls() {
volSlider.value = savedVol;
volSlider.value = savedVol;
// For WebRTC (SFU), the player start path sets volume + handles autoplay policy.
// For WebRTC (SFU), the player start path sets volume + handles autoplay policy.
// For JSMPEG, onSourceEstablished handles it. For HLS, set it here.
// For JSMPEG, onSourceEstablished handles it. For HLS, set it here.
+ // HLS: the level only. Unmuting here, before the page was touched, made the browser pause the video; playThenSound
+ // turns the sound on when it may.
if (playerType === 'hls') {
if (playerType === 'hls') {
- setVolume(savedVol / 100, { muted: audioPrefs.muted });
+ const hv = document.getElementById('video-element');
+ if (audioPrefs.muted) setVolume(savedVol / 100, { muted: true });
+ else if (hv) hv.volume = savedVol / 100;
}
}
let muted = audioPrefs.muted;
let muted = audioPrefs.muted;
@@ -3570,19 +3552,64 @@ function _clearStreamError() {
@@ -3570,19 +3552,64 @@ function _clearStreamError() {
}
}
}
}
-// The media volume keys (UI Events names, and the older ones): while the stream is muted for autoplay, volume up is a tap.
+/* ── Sound, on with the first touch (the owner, 2026-10-26) ───────────────────────────────────
+ A browser lets a page play sound only after the person has touched it, so a stream that should be heard starts
+ muted (so it starts at all). People had to find the "Tap for sound" pill on the picture, and many refreshed instead.
+ Now the first tap, click or key ANYWHERE on the page turns the sound on, and from then on every reconnect comes back
+ with sound by itself (the page has been touched). The pill stays as the visible hint until then. A mute the person
+ chose (the volume button, the slider at 0) is kept: only sound the browser held back is turned on. */
+let _soundHeldBack = false;
+
+/** Turn on sound the browser held back. Returns whether there was any. */
+function unlockSound() {
+ if (!_soundHeldBack || getSavedMuted()) return false;
+ _soundHeldBack = false;
+ const savedVol = getSavedVolume();
+ const vol = savedVol > 0 ? savedVol / 100 : 0.75;
+ const ctx = player && player.audioOut && (player.audioOut.context || (player.audioOut.destination && player.audioOut.destination.context));
+ if (ctx && ctx.state === 'suspended') ctx.resume().catch(() => {});
+ if (player && player.audioOut && player.audioOut.gain) player.audioOut.gain.value = vol;
+ const video = document.getElementById('video-element');
+ if (video && (video.srcObject || video.currentSrc)) {
+ video.volume = vol;
+ video.muted = false;
+ // Still refused (no real activation, e.g. a synthetic event): muted again, and the hint comes back.
+ video.play().catch(() => { video.muted = true; video.play().catch(() => {}); showUnmuteOverlay(video); });
+ }
+ try { localStorage.setItem(PLAYER_MUTED_KEY, '0'); } catch {}
+ const slider = document.getElementById('volume-slider');
+ if (slider) slider.value = Math.round(vol * 100);
+ const volBtn = document.getElementById('btn-volume');
+ if (volBtn) volBtn.innerHTML = '<i class="fa-solid fa-volume-high"></i>';
+ document.getElementById('unmute-overlay')?.remove();
+ return true;
+}
+
+// The first touch anywhere: a tap, a click, a key (the events that count as a person's activation). The volume control
+// decides for itself (a click on the mute button means mute).
+for (const type of ['pointerup', 'touchend', 'click', 'keydown']) {
+ document.addEventListener(type, (e) => {
+ if (!_soundHeldBack) return;
+ if (type === 'keydown' && (e.key === 'Escape' || e.key === 'Tab' || e.metaKey || e.ctrlKey || e.altKey)) return;
+ if (e.target && e.target.closest && e.target.closest('#btn-volume, #volume-slider')) return;
+ unlockSound();
+ }, { capture: true, passive: true });
+}
+
+// The media volume keys (UI Events names, and the older ones): while the browser holds the sound back, volume up is a tap.
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e) => {
if (!/^(AudioVolumeUp|VolumeUp|AudioVolumeMute|VolumeMute)$/.test(e.key)) return;
if (!/^(AudioVolumeUp|VolumeUp|AudioVolumeMute|VolumeMute)$/.test(e.key)) return;
- const overlay = document.getElementById('unmute-overlay');
- if (overlay) overlay.click();
+ unlockSound();
});
});
/**
/**
- * Show a "Tap for sound" pill when autoplay policy forced muted playback.
- * One tap anywhere on the picture unmutes, restores volume, and removes it.
+ * The browser held the stream's sound back: a "Tap for sound" pill on the picture until the first touch anywhere on
+ * the page (unlockSound) turns it on. `video` is the element (null for JSMPEG's Web Audio).
*/
*/
function showUnmuteOverlay(video) {
function showUnmuteOverlay(video) {
- // Don't duplicate
+ void video;
+ if (getSavedMuted()) return; // the person's own mute: no hint of a block
+ _soundHeldBack = true;
if (document.getElementById('unmute-overlay')) return;
if (document.getElementById('unmute-overlay')) return;
const container = document.getElementById('video-container');
const container = document.getElementById('video-container');
if (!container) return;
if (!container) return;
@@ -3590,29 +3617,53 @@ function showUnmuteOverlay(video) {
@@ -3590,29 +3617,53 @@ function showUnmuteOverlay(video) {
overlay.id = 'unmute-overlay';
overlay.id = 'unmute-overlay';
overlay.className = 'unmute-overlay';
overlay.className = 'unmute-overlay';
overlay.setAttribute('role', 'button');
overlay.setAttribute('role', 'button');
+ overlay.setAttribute('tabindex', '0');
overlay.setAttribute('aria-label', 'Turn the sound on');
overlay.setAttribute('aria-label', 'Turn the sound on');
- // The stream stays in view (owner, 2026-10-25): a small pill says it is muted, and a tap anywhere on the picture or a
- // media volume key (where the browser passes it to the page) turns the sound on.
+ // The stream stays in view (owner, 2026-10-25): a small pill says the sound waits for a tap, anywhere on the page.
overlay.innerHTML = '<span class="unmute-pill"><i class="fa-solid fa-volume-xmark"></i> Tap for sound</span>';
overlay.innerHTML = '<span class="unmute-pill"><i class="fa-solid fa-volume-xmark"></i> Tap for sound</span>';
- overlay.addEventListener('click', () => {
+ overlay.addEventListener('click', () => unlockSound());
+ container.appendChild(overlay);
+}
+
+/**
+ * Start an HLS or FLV stream: muted first (autoplay always allows that), then the sound, right away when the page has
+ * been touched, else with the first touch (the pill). Muted by the person's choice: the short hint instead.
+ */
+function playThenSound(video, play) {
+ video.autoplay = true;
+ video.muted = true;
+ Promise.resolve().then(play).then(() => {
+ if (getSavedMuted()) { showMutedHint(); return; }
+ const savedVol = getSavedVolume();
+ video.volume = savedVol > 0 ? savedVol / 100 : 0.75;
video.muted = false;
video.muted = false;
- // Restore volume from saved preference / slider
+ const p = video.play();
+ if (p) p.catch(() => { video.muted = true; video.play().catch(() => {}); showUnmuteOverlay(video); });
+ }).catch(() => showUnmuteOverlay(video));
+}
+
+/** Muted because the person muted it (here or on another stream): say so for a few seconds, one tap turns it on. */
+function showMutedHint() {
+ const container = document.getElementById('video-container');
+ if (!container || document.getElementById('unmute-overlay') || document.getElementById('muted-hint')) return;
+ const hint = document.createElement('button');
+ hint.type = 'button';
+ hint.id = 'muted-hint';
+ hint.className = 'unmute-pill muted-hint';
+ hint.innerHTML = '<i class="fa-solid fa-volume-xmark"></i> Sound is off · tap to turn on';
+ hint.addEventListener('click', (e) => {
+ e.stopPropagation();
+ hint.remove();
+ setVolume((getSavedVolume() || 75) / 100, { muted: false });
+ const video = document.getElementById('video-element');
+ if (video) video.play().catch(() => {});
const slider = document.getElementById('volume-slider');
const slider = document.getElementById('volume-slider');
- const savedVol = getSavedVolume();
- const vol = savedVol > 0 ? savedVol / 100 : (slider ? slider.value / 100 : 0.75);
- video.volume = vol;
- if (slider) slider.value = Math.round(vol * 100);
- try { localStorage.setItem(PLAYER_MUTED_KEY, '0'); } catch {}
- overlay.remove();
- // Sync the volume button icon
+ if (slider) slider.value = getSavedVolume() || 75;
const volBtn = document.getElementById('btn-volume');
const volBtn = document.getElementById('btn-volume');
- if (volBtn) volBtn.innerHTML = vol === 0
- ? '<i class="fa-solid fa-volume-xmark"></i>'
- : '<i class="fa-solid fa-volume-high"></i>';
- // Try playing again now that user has interacted
- video.play().catch(() => {});
- }, { once: true });
- container.appendChild(overlay);
+ if (volBtn) volBtn.innerHTML = '<i class="fa-solid fa-volume-high"></i>';
+ });
+ container.appendChild(hint);
+ setTimeout(() => hint.remove(), 6000);
}
}
/* ── Player stats overlay ─────────────────────────────────────
/* ── Player stats overlay ─────────────────────────────────────
@@ -149,11 +149,14 @@ class BroadcastServer extends EventEmitter {
@@ -149,11 +149,14 @@ class BroadcastServer extends EventEmitter {
// When a WHIP producer is added to the SFU, notify any pending viewers
// When a WHIP producer is added to the SFU, notify any pending viewers
webrtcSFU.on('producer-added', ({ roomId, kind }) => {
webrtcSFU.on('producer-added', ({ roomId, kind }) => {
- if (kind !== 'video') return; // Notify only on video producer
const match = roomId.match(/^stream-(\d+)$/);
const match = roomId.match(/^stream-(\d+)$/);
if (!match) return;
if (!match) return;
const streamId = parseInt(match[1]);
const streamId = parseInt(match[1]);
- this._notifyPendingWatchers(streamId);
+ if (kind === 'video') this._notifyPendingWatchers(streamId);
+ // A broadcaster who reconnected produces again while the old producers stay open on the dead transport.
+ // Viewers already watching kept consuming those (frozen picture, no sound, "refresh to get audio back"):
+ // once the new producer is the one findProducerByKind picks (its ICE up), tell them to watch again.
+ for (const ms of [1500, 5000]) setTimeout(() => this._moveViewersToCurrent(streamId), ms).unref?.();
});
});
// When WHIP ICE connects, pending viewers that were queued during ICE negotiation
// When WHIP ICE connects, pending viewers that were queued during ICE negotiation
@@ -697,6 +700,39 @@ class BroadcastServer extends EventEmitter {
@@ -697,6 +700,39 @@ class BroadcastServer extends EventEmitter {
// ── SFU Viewer Path (mediasoup-client signaling) ──────────
// ── SFU Viewer Path (mediasoup-client signaling) ──────────
+ /**
+ * Is this viewer consuming something other than the stream's current producers (findProducerByKind: the newest
+ * transport whose ICE is up)? True when a consumer's producer is not the current one of its kind, or a kind the
+ * stream now has (audio arriving later) is missing.
+ */
+ _viewerOnStaleProducers(client) {
+ const room = client._sfuViewerRoomId && webrtcSFU.rooms?.get(client._sfuViewerRoomId);
+ if (!room) return false;
+ const consumed = new Map();
+ for (const cid of client._sfuViewerConsumerIds || []) {
+ const c = room.consumers.get(cid)?.consumer;
+ if (c && !c.closed) consumed.set(c.kind, c.producerId);
+ }
+ if (!consumed.size) return false;
+ for (const kind of ['video', 'audio']) {
+ const cur = webrtcSFU.findProducerByKind(client._sfuViewerRoomId, kind);
+ if (cur && consumed.get(kind) !== cur.id) return true;
+ }
+ return false;
+ }
+
+ /** Ask every viewer of a stream that is on stale producers to watch again (it then gets the current ones). */
+ _moveViewersToCurrent(streamId) {
+ let moved = 0;
+ for (const [viewerWs, client] of this.clients) {
+ if (client.streamId !== streamId || client.role !== 'viewer' || !client._sfuViewerTransportId) continue;
+ if (!this._viewerOnStaleProducers(client)) continue;
+ this.safeSend(viewerWs, { type: 'sfu-source-moved' });
+ moved++;
+ }
+ if (moved) console.log(`[Broadcast] Stream ${streamId}: ${moved} viewer(s) on an old producer asked to watch the current one`);
+ }
+
/**
/**
* Check if SFU producers exist and notify viewer to start mediasoup-client flow.
* Check if SFU producers exist and notify viewer to start mediasoup-client flow.
* Returns true if an sfu-viewer-ready was sent.
* Returns true if an sfu-viewer-ready was sent.
@@ -720,7 +756,12 @@ class BroadcastServer extends EventEmitter {
@@ -720,7 +756,12 @@ class BroadcastServer extends EventEmitter {
return entry?.consumer && !entry.consumer.closed;
return entry?.consumer && !entry.consumer.closed;
});
});
- if (activeConsumerIds.length > 0) {
+ if (activeConsumerIds.length > 0 && this._viewerOnStaleProducers(client)) {
+ // Alive, but on producers the broadcaster left behind (a reconnect): a keyframe nudge would ask
+ // the dead producer again. A fresh consume of the current ones below.
+ console.log(`[Broadcast] Viewer ${client.peerId} re-watch on an old producer — moving it to the current ones`);
+ this._cleanupSfuViewerTransport(client);
+ } else if (activeConsumerIds.length > 0) {
// Consumers are alive — nudge with keyframe and ack the client so
// Consumers are alive — nudge with keyframe and ack the client so
// startWatchOfferTimeout doesn't fire and loop.
// startWatchOfferTimeout doesn't fire and loop.
// Throttle: a looping client (or many viewers re-watching at once) must
// Throttle: a looping client (or many viewers re-watching at once) must
@@ -809,14 +850,20 @@ class BroadcastServer extends EventEmitter {
@@ -809,14 +850,20 @@ class BroadcastServer extends EventEmitter {
return false;
return false;
}
}
+ // One producer per kind: the current one (findProducerByKind), never also the one a reconnect left behind. A
+ // viewer given both played the first video track it got, which could be the dead one.
+ const chosen = ['video', 'audio'].map((kind) => {
+ const cur = webrtcSFU.findProducerByKind(roomId, kind);
+ return (cur && liveProducers.find((p) => p.id === cur.id)) || liveProducers.find((p) => p.kind === kind);
+ }).filter(Boolean);
// Send capabilities + producer list — viewer will use mediasoup-client Device
// Send capabilities + producer list — viewer will use mediasoup-client Device
this.safeSend(ws, {
this.safeSend(ws, {
type: 'sfu-viewer-ready',
type: 'sfu-viewer-ready',
rtpCapabilities: caps,
rtpCapabilities: caps,
- producers: liveProducers.map(p => ({ id: p.id, kind: p.kind })),
+ producers: chosen.map(p => ({ id: p.id, kind: p.kind })),
});
});
- console.log(`[Broadcast] SFU viewer ready sent to ${client.peerId} for stream ${client.streamId} (${liveProducers.length}/${allProducers.length} live producer(s))`);
+ console.log(`[Broadcast] SFU viewer ready sent to ${client.peerId} for stream ${client.streamId} (${chosen.length} chosen of ${liveProducers.length}/${allProducers.length} live producer(s))`);
return true;
return true;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+// Sound on the stream page (the owner, 2026-10-26: people "having to refresh the stream page over and over again, or
+// click or something like that, to get audio to come back properly … so that people don't even realize they're
+// enabling it").
+//
+// Two causes:
+// 1. A broadcaster whose network drops produces again on a new transport while the old producers stay open on the
+// dead one. Viewers already watching kept consuming those: a frozen picture and no sound until a reload. A
+// re-watch only asked the dead producer for a keyframe. Now the server moves them: a re-watch on a stale
+// producer gets a fresh consume of the current ones, a new producer taking over sends `sfu-source-moved`
+// (the player watches again), and a viewer is given one producer per kind, the current one.
+// 2. Sound the browser held back came on only from a pill on the picture. Now the first tap, click or key anywhere
+// on the page turns it on; a mute the person chose is kept and gets a short hint instead.
+//
+// node test/viewer-follow-sound.test.js
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const vm = require('vm');
+
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = () => {};
+console.warn = () => {};
+
+(async () => {
+ // ── 1. The server follows the broadcaster's current producers ──
+ const sfu = require('../server/streaming/webrtc-sfu');
+ const bs = require('../server/streaming/broadcast-server');
+ const current = { video: 'v-new', audio: 'a-new' };
+ sfu.findProducerByKind = (roomId, kind) => (current[kind] ? { id: current[kind], kind } : null);
+ const consumers = new Map([
+ ['c1', { consumer: { kind: 'video', producerId: 'v-old', closed: false } }],
+ ['c2', { consumer: { kind: 'audio', producerId: 'a-old', closed: false } }],
+ ['c3', { consumer: { kind: 'video', producerId: 'v-new', closed: false } }],
+ ['c4', { consumer: { kind: 'audio', producerId: 'a-new', closed: false } }],
+ ]);
+ sfu.rooms = new Map([['stream-9', { consumers, transports: new Map(), producers: new Map() }]]);
+ const stale = { streamId: 9, role: 'viewer', peerId: 'p1', _sfuViewerRoomId: 'stream-9', _sfuViewerTransportId: 't1', _sfuViewerConsumerIds: ['c1', 'c2'] };
+ const fresh = { streamId: 9, role: 'viewer', peerId: 'p2', _sfuViewerRoomId: 'stream-9', _sfuViewerTransportId: 't2', _sfuViewerConsumerIds: ['c3', 'c4'] };
+ const videoOnly = { ...fresh, peerId: 'p3', _sfuViewerConsumerIds: ['c3'] };
+ assert.strictEqual(bs._viewerOnStaleProducers(stale), true, 'consuming the producers a reconnect left behind');
+ assert.strictEqual(bs._viewerOnStaleProducers(fresh), false, 'on the current ones');
+ assert.strictEqual(bs._viewerOnStaleProducers(videoOnly), true, 'audio arrived later and this viewer has none');
+ assert.strictEqual(bs._viewerOnStaleProducers({ ...stale, _sfuViewerConsumerIds: [] }), false, 'nothing consumed yet: not this check');
+
+ const sent = [];
+ const ws = (name) => ({ name, readyState: 1, send: (m) => sent.push([name, JSON.parse(m).type]) });
+ const wsStale = ws('stale'), wsFresh = ws('fresh');
+ const realClients = bs.clients;
+ bs.clients = new Map([[wsStale, stale], [wsFresh, fresh], [ws('other'), { ...stale, streamId: 10 }]]);
+ bs.safeSend = (w, msg) => w.send(JSON.stringify(msg));
+ bs._moveViewersToCurrent(9);
+ assert.deepStrictEqual(sent, [['stale', 'sfu-source-moved']], 'only the viewer on old producers is asked to watch again');
+ bs.clients = realClients;
+
+ // The server code: a re-watch on a stale producer is a fresh consume (not a keyframe nudge), one producer per kind,
+ // and a producer added in the room checks the viewers.
+ const src = fs.readFileSync(path.join(__dirname, '..', 'server', 'streaming', 'broadcast-server.js'), 'utf8');
+ assert.match(src, /activeConsumerIds\.length > 0 && this\._viewerOnStaleProducers\(client\)\) \{[\s\S]{0,400}this\._cleanupSfuViewerTransport\(client\);\s*\} else if \(activeConsumerIds\.length > 0\)/);
+ assert.match(src, /const chosen = \['video', 'audio'\]\.map\(\(kind\) => \{[\s\S]{0,800}producers: chosen\.map/);
+ assert.match(src, /webrtcSFU\.on\('producer-added'[\s\S]{0,700}this\._moveViewersToCurrent\(streamId\)/);
+
+ // ── 2. Sound on with the first touch ──
+ const player = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'stream-player.js'), 'utf8');
+ assert.match(player, /case 'sfu-source-moved':[\s\S]{0,1200}sendPlayerSignal\(\{ type: 'watch' \}\)/, 'the player watches again when told');
+ const block = player.slice(player.indexOf('let _soundHeldBack = false;'), player.indexOf('/* ── Player stats overlay'));
+ function page({ muted = false, volume = 80 } = {}) {
+ const store = { openvibe_player_muted: muted ? '1' : '0', openvibe_player_volume: String(volume) };
+ const listeners = {};
+ const els = {};
+ const el = (id, extra = {}) => (els[id] = Object.assign({ id, innerHTML: '', value: 0, children: [], appendChild(c) { this.children.push(c); els[c.id] = c; c.parent = this; }, remove() { delete els[this.id]; }, addEventListener(t, f) { (this.l ||= {})[t] = f; }, setAttribute() {}, click() { this.l && this.l.click && this.l.click({ stopPropagation() {} }); } }, extra));
+ const video = el('video-element', { srcObject: {}, muted: true, volume: 0, plays: 0, play() { this.plays++; return Promise.resolve(); } });
+ el('video-container'); el('volume-slider'); el('btn-volume');
+ const ctx = {
+ console, Promise, setTimeout: (f) => f && 0, JSON, Math, String, Number,
+ localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
+ document: {
+ getElementById: (id) => els[id] || null,
+ createElement: () => el('new-' + Math.random(), {}),
+ addEventListener: (t, f, o) => (listeners[t] ||= []).push(f),
+ },
+ player: null, playerType: 'webrtc',
+ PLAYER_MUTED_KEY: 'openvibe_player_muted',
+ getSavedMuted: () => store.openvibe_player_muted === '1',
+ getSavedVolume: () => Number(store.openvibe_player_volume),
+ setVolume: (v, o) => { video.volume = o.muted ? 0 : v; video.muted = !!o.muted; store.openvibe_player_muted = o.muted ? '1' : '0'; },
+ };
+ vm.createContext(ctx);
+ vm.runInContext(block, ctx);
+ const fire = (type, target = { closest: () => null }, extra = {}) => (listeners[type] || []).forEach((f) => f({ target, key: 'a', ...extra }));
+ return { ctx, video, store, els, fire };
+ }
+ // Held back by the browser: a tap anywhere turns the sound on, at the saved level.
+ let p = page();
+ p.ctx.showUnmuteOverlay(p.video);
+ assert.ok(p.els['unmute-overlay'], 'the pill shows');
+ p.fire('pointerup');
+ assert.deepStrictEqual([p.video.muted, p.video.volume, p.store.openvibe_player_muted], [false, 0.8, '0'], 'a tap on the chat, the page, anything');
+ assert.ok(!p.els['unmute-overlay'], 'and the pill goes');
+ // A key does it too (not Escape, Tab or a shortcut).
+ p = page();
+ p.ctx.showUnmuteOverlay(p.video);
+ p.fire('keydown', undefined, { key: 'Escape' });
+ assert.strictEqual(p.video.muted, true, 'Escape is not "turn the sound on"');
+ p.fire('keydown', undefined, { key: 'k' });
+ assert.strictEqual(p.video.muted, false);
+ // The volume control decides for itself: a click on the mute button is a mute, not an unmute.
+ p = page();
+ p.ctx.showUnmuteOverlay(p.video);
+ p.fire('click', { closest: (sel) => (sel.includes('#btn-volume') ? {} : null) });
+ assert.strictEqual(p.video.muted, true, 'the mute button is left alone');
+ // A mute the person chose: no pill, no unmute on a tap; the short hint instead.
+ p = page({ muted: true });
+ p.ctx.showUnmuteOverlay(p.video);
+ assert.ok(!p.els['unmute-overlay'], 'no "tap for sound" for a chosen mute');
+ p.fire('pointerup');
+ assert.strictEqual(p.video.muted, true, 'their mute stands');
+ p.ctx.showMutedHint();
+ assert.ok(p.els['muted-hint'], 'a short "sound is off" hint');
+ p.els['muted-hint'].click();
+ assert.deepStrictEqual([p.video.muted, p.store.openvibe_player_muted], [false, '0'], 'one tap on it turns the sound on');
+
+ // HLS and FLV start muted, then turn the sound on when they may (no more silent playback with no pill).
+ assert.strictEqual((player.match(/playThenSound\(video, \(\) => (video|flvPlayer)\.play\(\)\)/g) || []).length, 4);
+ assert.ok(!/video\.muted = true;\n\s*video\.play\(\)\.catch\(\(\) => \{\n\s*showUnmuteOverlay\(video\);/.test(player), 'no start path left that stays muted without a word');
+
+ quiet('viewer follow + sound: all checks passed');
+ process.exit(0);
+})().catch((err) => { quiet(err); process.exit(1); });