screentinker/server/lib/player-media-health.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

61 lines
3.3 KiB
JavaScript

// Player media-surface health decision (#146 web-player fix).
//
// THE BUG (hypothesis A): a NO-NEW-CONTENT refresh in handlePlaylistUpdate returned early
// ("Playlist unchanged") without verifying the media surface is still attached. If the
// <video> element had been detached from the DOM while still decoding (audio keeps playing,
// video surface gone), the re-attach — which lived ONLY in the content-changed branch —
// never ran, so the video never came back. This module is the branch-selection decision the
// no-change path now consults: re-attach ONLY when playback should be happening but the
// surface is actually lost, so a healthy poll stays a no-op (no flicker every refresh).
//
// Pure + dependency-free so it is unit-testable without a DOM: the caller extracts the DOM
// facts (is the <video> in the document? ended? errored?) into a plain state object.
//
// Dependency-free UMD: Node (require) + browser/Tizen (window.PlayerMediaHealth).
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.PlayerMediaHealth = factory();
})(typeof self !== 'undefined' ? self : this, function () {
'use strict';
// state = {
// isPlaying: boolean // the player believes an item is playing
// hasCurrentItem: boolean // playlist[currentIndex] exists
// itemKind: 'video' | 'youtube' | 'image' | 'widget' | 'other'
// videoEl: { attached, ended, errored } | null // for a plain <video> item
// surfaceAttached:boolean // for non-video: a rendered surface is present in the DOM
// }
// Returns true iff the no-change refresh must re-render/re-attach the current item.
function needsReattach(state) {
var s = state || {};
// Idle or no content: nothing to re-attach — leave the idle/waiting screen alone.
if (!s.isPlaying || !s.hasCurrentItem) return false;
if (s.itemKind === 'video') {
// The exact bug: a <video> that is gone or detached from the DOM (its element may
// still be emitting audio) — or one that ended/errored — must be re-attached.
if (!s.videoEl) return true;
if (!s.videoEl.attached) return true;
if (s.videoEl.ended || s.videoEl.errored) return true;
return false; // attached + live -> healthy, do NOT re-render (avoids flicker)
}
// Non-video surfaces (image / youtube iframe / widget): healthy iff a surface is mounted.
return !s.surfaceAttached;
}
// Whether the idle "Waiting for content..." screen should be shown, given player state.
// THE RECONNECT BUG: the server re-emits device:paired on every re-register of an already-
// paired device, and the player showed the idle overlay UNCONDITIONALLY — covering live
// content (audio kept playing underneath), and the following "Playlist unchanged" left it
// up. Rule: only fall to idle when nothing is playing AND there is genuinely no content to
// play. Already playing, or content present and about to render, is NEVER idle.
function shouldShowIdle(state) {
var s = state || {};
if (s.isPlaying) return false; // something is playing -> never cover it with idle
return !s.hasContent; // idle only when there's genuinely no content
}
return { needsReattach: needsReattach, shouldShowIdle: shouldShowIdle };
});