mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 14:23:14 -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
80 lines
3.9 KiB
JavaScript
80 lines
3.9 KiB
JavaScript
'use strict';
|
|
|
|
// Muting a playlist item was implemented three times and agreed nowhere.
|
|
//
|
|
// A `<video>` honoured the per-item mute flag. A YouTube item did not — it is a cross-origin
|
|
// iframe, so setting `el.muted` reaches nothing, and the web player never consulted the flag when
|
|
// building the embed. An item an operator deliberately silenced in the admin console therefore
|
|
// played WITH SOUND. Tizen failed the opposite way: it hardcoded `mute=1` into the embed URL, so
|
|
// YouTube there was permanently silent and could not be unmuted by anything.
|
|
//
|
|
// Neither failure is visible from a dashboard. You find out when a shop floor gets audio it should
|
|
// not have, or when a customer says the sound never works.
|
|
//
|
|
// The ORDER of these rules is the substance, so each precedence step is pinned separately.
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const { resolveMuted, shouldOfferUnmute } = require('../lib/media-mute');
|
|
|
|
const base = { wallFollower: false, remoteMuted: null, itemMuted: false, userGesture: true };
|
|
|
|
test('THE BUG: an item flagged muted is silent — including a YouTube embed', () => {
|
|
assert.equal(resolveMuted({ ...base, itemMuted: true }), true);
|
|
});
|
|
|
|
test('an unflagged item with a gesture plays audio', () => {
|
|
assert.equal(resolveMuted(base), false);
|
|
});
|
|
|
|
test('a wall follower is ALWAYS silent — one wall, one audio source', () => {
|
|
// Otherwise a room gets the same track from six panels a few milliseconds apart.
|
|
assert.equal(resolveMuted({ ...base, wallFollower: true }), true);
|
|
assert.equal(resolveMuted({ ...base, wallFollower: true, itemMuted: false, remoteMuted: false }), true,
|
|
'not even an explicit operator unmute may break a wall');
|
|
});
|
|
|
|
test('autoplay policy outranks an operator unmute, because it is not a preference', () => {
|
|
// Unmuted playback without a gesture is REFUSED by the browser: asking for it loses the video,
|
|
// not just the audio. So it can never be granted, however it was requested.
|
|
assert.equal(resolveMuted({ ...base, userGesture: false, remoteMuted: false }), true);
|
|
assert.equal(resolveMuted({ ...base, userGesture: false, itemMuted: false }), true);
|
|
});
|
|
|
|
test('a live operator toggle outranks the item setting — they are looking at the screen', () => {
|
|
assert.equal(resolveMuted({ ...base, itemMuted: true, remoteMuted: false }), false, 'unmute a muted item');
|
|
assert.equal(resolveMuted({ ...base, itemMuted: false, remoteMuted: true }), true, 'mute an unmuted item');
|
|
});
|
|
|
|
test('remoteMuted null means "not set" and defers to the item, rather than reading as false', () => {
|
|
assert.equal(resolveMuted({ ...base, itemMuted: true, remoteMuted: null }), true);
|
|
assert.equal(resolveMuted({ ...base, itemMuted: true, remoteMuted: undefined }), true);
|
|
});
|
|
|
|
test('a missing or empty state is silent rather than blaring', () => {
|
|
// Called before config exists, or with a half-built item: silence is the safe failure.
|
|
assert.equal(resolveMuted(undefined), true);
|
|
assert.equal(resolveMuted({}), true);
|
|
});
|
|
|
|
// ---------------------------------------------------------------- the unmute prompt
|
|
|
|
test('the unmute prompt appears only when a gesture is the ONLY thing in the way', () => {
|
|
assert.equal(shouldOfferUnmute({ ...base, userGesture: false }), true);
|
|
});
|
|
|
|
test('THE TRAP: no prompt on an item an operator deliberately muted', () => {
|
|
// Otherwise the prompt trains viewers to click a button that un-mutes something silenced on
|
|
// purpose — worse than never offering it.
|
|
assert.equal(shouldOfferUnmute({ ...base, userGesture: false, itemMuted: true }), false);
|
|
assert.equal(shouldOfferUnmute({ ...base, userGesture: false, remoteMuted: true }), false);
|
|
});
|
|
|
|
test('no prompt on a wall follower — audio there is not the viewer to grant', () => {
|
|
assert.equal(shouldOfferUnmute({ ...base, userGesture: false, wallFollower: true }), false);
|
|
});
|
|
|
|
test('no prompt once audio is already unlocked', () => {
|
|
assert.equal(shouldOfferUnmute(base), false);
|
|
});
|