screentinker/server/lib/player-media-health.js
ScreenTinker 26c72d62bf fix(#146): web player — no-change refresh loses video (keeps audio); re-attach idempotently
ROOT CAUSE (hypothesis A, pre-existing — NOT a beta7 regression; server/player/index.html
is untouched since v1.9.2-beta6): handlePlaylistUpdate's "Playlist unchanged" branch blindly
returned. The media re-attach (renderContent) lives ONLY in the content-changed branch, so
if the <video> surface was lost (element detached from the DOM while still decoding — video
gone, audio still playing) a no-new-content refresh never re-attached it. New-content
refreshes were fine because they re-render.

FIX (make the refresh idempotent for the media surface, no flicker on the healthy path):
- server/lib/player-media-health.js (new, UMD + unit-testable, mirrors schedule-eval.js):
  needsReattach(state) — re-attach ONLY when playback should be happening but the current
  item's surface is actually lost (video null / detached / ended / errored; non-video: no
  mounted surface). A healthy attached+live video returns false, so a routine poll stays a
  no-op (no re-render, no flicker). Served at /player/player-media-health.js from the single
  source; loaded by the player.
- index.html no-change branch: extract the current item's DOM facts and, iff
  PlayerMediaHealth.needsReattach, call playCurrentItem() to re-render the current item.
  Wrapped so the health check can never break a refresh.
- teardownCurrentMedia: also release currentVideoEl even when it was DETACHED from the
  container — a detached-but-playing <video> keeps emitting audio and the container-scoped
  querySelectorAll can't find it. This kills the "ghost audio" on re-attach.
- sw.js cache bumped v9 -> v10 so players pick up the new index.html + module.

Tests: test/player-media-health.test.js (6) exercises the branch selection — healthy video
-> no re-attach; detached/null/ended/errored -> re-attach; idle -> never; non-video by
surface presence. Inline player JS syntax-checked; module served + referenced verified on a
booted server. Suite 316/316.

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

49 lines
2.5 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;
}
return { needsReattach: needsReattach };
});