screentinker/server/lib/media-mute.js
ScreenTinker cf1124d687
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run
Mute reaches YouTube items — it never did, and failed opposite ways per player
Muting was implemented three times and agreed nowhere. A YouTube item is a
cross-origin iframe, so `el.muted` reaches nothing; only the IFrame API can
touch it. Both browser-family players got this wrong, in opposite directions:

  web    playerVars.mute was `userHasInteracted ? 0 : 1` — autoplay policy and
         NOTHING else. An item an operator muted in the admin console played
         WITH SOUND, a wall follower blared alongside its leader, and the
         real-time device:mute-changed toggle only ever touched `<video>`.
         onReady then unmuted unconditionally, and the click-to-unmute overlay
         appeared on deliberately-muted items and undid the operator's setting.

  tizen  the embed URL hardcoded `mute=1`, so YouTube there was PERMANENTLY
         silent: the per-item flag was never read and nothing could unmute it.
         device:mute-changed did nothing at all, because it dereferenced a
         <video> that is null for a YouTube item.

Android was already correct and is unchanged — it is the reference here.

The rule now lives once, in server/lib/media-mute.js, served to the web player
from its single source the same way schedule-eval.js is, and mirrored in Tizen
(which ships inside the .wgt and cannot import it). The ORDER is the substance:
a wall follower is always silent (one wall, one audio source) > autoplay policy,
which is a hard constraint rather than a preference because unmuted playback
without a gesture is refused outright and costs the VIDEO > a live operator
toggle, who is looking at the screen > the item's stored flag.

shouldOfferUnmute() exists so the prompt only appears when a gesture is the ONLY
thing in the way. Prompting on a muted item trains viewers to click a button
that undoes an operator's decision.

Tizen gains enablejsapi + a postMessage bridge so a live toggle flips the embed
without reloading it — reloading would restart the video from zero every time
someone touched the control.

11 new tests pinning each precedence step separately; 1055 pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-05 10:50:59 -05:00

68 lines
3.1 KiB
JavaScript

'use strict';
/*
* Whether a playlist item should be playing silently, right now.
*
* This existed three times and agreed nowhere. `<video>` honoured the per-item mute (#129); the
* YouTube embed did not, because it is a cross-origin iframe where `el.muted` reaches nothing —
* so an item flagged muted in the admin console played with sound on the web player, while Tizen
* hardcoded `mute=1` into the embed URL and could never unmute at all. Same feature, opposite
* failures, neither visible from the dashboard.
*
* Four inputs, and the order between them is the whole point:
*
* 1. wallFollower — a video wall has ONE audio source. Every follower is silent regardless of
* what the item says, or a room gets the same track from six panels a few
* milliseconds apart. This outranks everything.
* 2. remoteMuted — a live operator toggle. They are looking at the screen; honour it over the
* item's stored setting.
* 3. itemMuted — the per-item setting from the admin console.
* 4. userGesture — browser autoplay policy. Without a gesture, unmuted playback is REFUSED,
* so "unmuted" is not a state we can grant; asking for it loses the video
* rather than the audio.
*
* Kept pure and shared so the players cannot drift again: the web player loads it from
* /player/media-mute.js (single source, same trick as schedule-eval.js), Tizen mirrors it, and
* Android implements the same order in Kotlin.
*/
/**
* @param {object} s
* @param {boolean} s.wallFollower this panel is a follower in a video wall
* @param {boolean|null} s.remoteMuted live operator override, null when not set
* @param {boolean} s.itemMuted the item's stored mute flag
* @param {boolean} s.userGesture a user gesture has unlocked audio in this document
* @returns {boolean} true when playback must be silent
*/
function resolveMuted(s) {
const st = s || {};
if (st.wallFollower) return true;
// Autoplay policy is a hard constraint, not a preference: unmuted playback without a gesture is
// blocked outright, which costs the VIDEO, not just the audio.
if (!st.userGesture) return true;
if (st.remoteMuted !== null && st.remoteMuted !== undefined) return !!st.remoteMuted;
return !!st.itemMuted;
}
/*
* Should the player offer a "click to unmute" prompt?
*
* Only when a gesture is the ONLY thing standing between the viewer and audio. Prompting on an
* item that is deliberately muted trains people to click a button that then un-mutes something an
* operator silenced on purpose — worse than not offering it.
*/
function shouldOfferUnmute(s) {
const st = s || {};
if (st.userGesture) return false;
if (st.wallFollower) return false;
if (st.remoteMuted !== null && st.remoteMuted !== undefined) return !st.remoteMuted;
return !st.itemMuted;
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = { resolveMuted, shouldOfferUnmute };
}
if (typeof window !== 'undefined') {
window.MediaMute = { resolveMuted, shouldOfferUnmute };
}