mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
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
68 lines
3.1 KiB
JavaScript
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 };
|
|
}
|