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)

OP OpenVibers committed 18 days ago · 0c754cc29c · 4 files, +299 −68

Viewers kept consuming the producers a reconnecting broadcaster left open on the dead transport (frozen picture, no
sound, "refresh to get audio back"); a re-watch only asked the dead producer for a keyframe. Now 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 gets one producer per kind (findProducerByKind).

Sound the browser held back came on only from the pill on the picture: now the first tap, click or key anywhere on
the page turns it on (a chosen mute is kept, with a six-second "Sound is off" hint). HLS and FLV started muted and
stayed silent when muted autoplay worked; they now turn the sound on when they may (playThenSound), and the HLS
control setup no longer unmutes early (which made the browser pause the video).

Changes

ddiff --git a/public/css/style.css b/public/css/style.css
iindex c9154d89..b071e5aa 100644
---- a/public/css/style.css
++++ b/public/css/style.css
@@@ -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; } }
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex c954b5f1..7438966b 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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, scheduleRewatc
video.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 ─────────────────────────────────────
ddiff --git a/server/streaming/broadcast-server.js b/server/streaming/broadcast-server.js
iindex 34fc5a28..b746370a 100644
---- a/server/streaming/broadcast-server.js
++++ b/server/streaming/broadcast-server.js
@@@ -149,11 +149,14 @@ class BroadcastServer extends EventEmitter {
// When a WHIP producer is added to the SFU, notify any pending viewers
webrtcSFU.on('producer-added', ({ roomId, kind }) => {
-- if (kind !== 'video') return; // Notify only on video producer
const 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 Device
this.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;
}
ddiff --git a/test/viewer-follow-sound.test.js b/test/viewer-follow-sound.test.js
nnew file mode 100644
iindex 00000000..e04665fd
---- /dev/null
++++ b/test/viewer-follow-sound.test.js
@@@ -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); });