screentinker/server/test/player-media-health.test.js
ScreenTinker b57e7eec7f fix(#146): web player — reconnect drops video to "Waiting for content" (idle reset over live playback)
CONFIRMED from a live console capture (restore cache -> video plays -> reconnect ->
"Playlist unchanged" -> screen falls to "Waiting for content..."). Audio survived because
only the "showing content" VIEW was covered, not the audio path.

ROOT CAUSE: the server re-emits device:paired on EVERY re-register of an already-paired
device (ws/deviceSocket.js:510) — i.e. on every reconnect, while content is already playing.
The player's device:paired handler called showStatus('Waiting for content...') UNCONDITIONALLY
(the "falls through to idle" sibling), putting the idle overlay OVER the live video. The
following device:playlist-update -> "Playlist unchanged" branch returned early and never
cleared it, so the idle screen stuck on top of playing content.

FIX (idle screen only when genuinely idle; unchanged is a strict no-op that keeps playback):
- lib/player-media-health.js: new shouldShowIdle(state) — idle ONLY when nothing is playing
  AND there's genuinely no content. Already-playing (or content-present-about-to-render) is
  never idle.
- device:paired handler: gate showStatus on shouldShowIdle({isPlaying, hasContent}) instead
  of showing it unconditionally. On a reconnect while playing -> no-op.
- "Playlist unchanged" branch: when healthy playback is confirmed, hideStatus() to clear any
  stale idle overlay a reconnect's device:paired may have put up — so the confirmation can
  never leave "Waiting for content..." over live content. Still leaves the actual media
  element exactly as-is (no teardown, no flicker).
- sw.js cache v10 -> v11.

SIBLING SCAN: device:paired was the only unconditional idle reset. The connect() idle
prompts (connecting / connecting_muted) were already guarded by !isPlaying; empty-playlist
and no-renderable idles are genuine.

Tests: player-media-health.test.js +2 (shouldShowIdle: playing never idle; idle only when
empty+not-playing). Inline player JS syntax-checked; module served + guard referenced on a
booted server. Suite 318/318.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 22:03:44 -05:00

56 lines
3 KiB
JavaScript

'use strict';
// #146 web-player fix — the no-change-refresh branch-selection decision. This is the unit
// the refresh handler consults to decide whether to re-attach the media surface. It is the
// smallest testable piece of the "no new content lost the video but not the audio" fix.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { needsReattach, shouldShowIdle } = require('../lib/player-media-health');
const video = (o) => ({ isPlaying: true, hasCurrentItem: true, itemKind: 'video', videoEl: o, surfaceAttached: true });
test('healthy attached+live video on a no-change refresh -> NO re-attach (no flicker)', () => {
assert.equal(needsReattach(video({ attached: true, ended: false, errored: false })), false);
});
test('THE BUG: a detached-but-playing <video> (audio persists, surface gone) -> re-attach', () => {
assert.equal(needsReattach(video({ attached: false, ended: false, errored: false })), true);
});
test('video element gone entirely -> re-attach', () => {
assert.equal(needsReattach({ isPlaying: true, hasCurrentItem: true, itemKind: 'video', videoEl: null }), true);
});
test('ended or errored video -> re-attach', () => {
assert.equal(needsReattach(video({ attached: true, ended: true, errored: false })), true);
assert.equal(needsReattach(video({ attached: true, ended: false, errored: true })), true);
});
test('idle / no current item -> never re-attach (leave the waiting screen)', () => {
assert.equal(needsReattach({ isPlaying: false, hasCurrentItem: true, itemKind: 'video', videoEl: null }), false);
assert.equal(needsReattach({ isPlaying: true, hasCurrentItem: false, itemKind: 'video', videoEl: null }), false);
assert.equal(needsReattach(undefined), false);
});
test('non-video surface (image/youtube/widget): re-attach only when the surface is missing', () => {
const base = { isPlaying: true, hasCurrentItem: true };
assert.equal(needsReattach({ ...base, itemKind: 'image', surfaceAttached: true }), false);
assert.equal(needsReattach({ ...base, itemKind: 'image', surfaceAttached: false }), true);
assert.equal(needsReattach({ ...base, itemKind: 'youtube', surfaceAttached: false }), true);
assert.equal(needsReattach({ ...base, itemKind: 'widget', surfaceAttached: true }), false);
});
// shouldShowIdle — the reconnect reset guard (device:paired / unchanged reconciliation).
test('THE RECONNECT BUG: already playing -> NEVER show the idle "Waiting" screen', () => {
// reconnect re-emits device:paired while content is playing: must stay on the content
assert.equal(shouldShowIdle({ isPlaying: true, hasContent: true }), false);
assert.equal(shouldShowIdle({ isPlaying: true, hasContent: false }), false);
});
test('idle screen shows ONLY when genuinely no content and nothing playing', () => {
assert.equal(shouldShowIdle({ isPlaying: false, hasContent: false }), true); // first pair, empty
assert.equal(shouldShowIdle({ isPlaying: false, hasContent: true }), false); // content present, about to render
assert.equal(shouldShowIdle(undefined), true);
});