Apply a playlist change even when the outgoing item never advances

Replacing the single item of a one-item playlist did nothing. The old promo, board or clip kept
playing while the dashboard showed the new playlist published and the device perfectly healthy —
only a reboot or a manual refresh cleared it.

#157 defers a rotation so a live item is not yanked mid-play, and applies it "on the next natural
advance". For a one-item playlist there is no such thing, by design: single-item rendering
deliberately never advances. A video gets `loop = (playlist.length === 1)` and so never fires
`ended`; a YouTube embed loops for the same reason and skips its safety net; a solo widget is "held"
on a self-re-arming refresh that never calls nextItem, because reloading it would reset a directory
board's scroll. Tizen is worse still — `single` makes every renderer skip its timer, so images
freeze too.

Two guards, the same pair already applied to the Android controller:

- A one-item playlist is never deferred. There is nothing to protect from being cut off, since
  nothing was going to advance anyway.
- Any deferral that does happen gets a 60-second deadline. The deferral is a bet that an advance is
  coming; if the bet loses, the change must still land rather than strand the screen on content the
  operator has already replaced.

Verified in headless Chrome: a one-item playlist holding a solo widget (the "held" case that never
advances), its only item replaced with a different widget — the screen followed, with no reload and
no restart. Before the change it stayed on the replaced item indefinitely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
This commit is contained in:
Claude 2026-07-30 21:17:44 -05:00
parent d3f6af831b
commit f4d309a0d4
2 changed files with 44 additions and 2 deletions

View file

@ -404,6 +404,7 @@
// (e.g. it just expired) in solo playback, we keep it up and rotate to deferredSuccessorId on
// the next natural advance instead of interrupting/restarting.
let deferredRotation = false;
let deferredRotationDeadline = null; // a deferral must not wait forever — see the #157 block
let deferredSuccessorId = null;
function itemIdentity(x) { return x ? `${x.content_id || ''}|${x.widget_id || ''}|${x.remote_url || ''}|${x.filepath || ''}` : ''; }
@ -2025,10 +2026,30 @@
// video onended still fires nextItem). Schedule-driven modes (wall follower / group-sync)
// advance via their own tick, so they reconcile immediately as before.
const scheduleDriven = isWallFollower() || !!groupSync;
if (isPlaying && !scheduleDriven) {
// #157 defers so a live item is not yanked mid-play — but it assumes an advance is coming,
// and for a ONE-ITEM playlist that is never true. Single-item rendering deliberately never
// advances: a video gets `loop = (playlist.length === 1)`, a YouTube embed the same, and a
// solo widget is "held" on a self-re-arming refresh that never calls nextItem (reloading it
// would reset a directory board's scroll). So replacing the single item of a one-item
// playlist deferred forever — the old promo, board or clip kept playing while the dashboard
// showed the new playlist published and the device healthy. Only a reboot or a refresh
// command cleared it.
const outgoingNeverAdvances = oldPlaylist.length <= 1;
if (isPlaying && !scheduleDriven && !outgoingNeverAdvances) {
deferredRotation = true;
deferredSuccessorId = itemIdentity(playlist[nextIdx]);
console.log('#157: current item removed but still live — deferring rotation-out');
// Safety net for anything else that turns out not to advance: a deferral is a bet that
// one is coming, and if it never arrives the change must still land rather than strand
// the screen on content the operator has already replaced.
if (deferredRotationDeadline) clearTimeout(deferredRotationDeadline);
deferredRotationDeadline = setTimeout(() => {
if (!deferredRotation) return;
console.warn('#157: deferred rotation never got an advance — applying it now');
deferredRotation = false;
const di = deferredSuccessorId ? playlist.findIndex(x => itemIdentity(x) === deferredSuccessorId) : -1;
startPlaybackAt(di === -1 ? 0 : di);
}, 60000);
return;
}
@ -2116,6 +2137,7 @@
// into the (already-swapped) list at the preserved successor instead of interrupting.
if (deferredRotation) {
deferredRotation = false;
if (deferredRotationDeadline) { clearTimeout(deferredRotationDeadline); deferredRotationDeadline = null; }
const sid = deferredSuccessorId; deferredSuccessorId = null;
let idx = sid ? playlist.findIndex(x => itemIdentity(x) === sid) : -1;
if (idx < 0) idx = 0;

View file

@ -198,9 +198,28 @@ PlaylistPlayer.prototype.load = function (assignments) {
// #157: removed-but-live in solo playback -> don't interrupt; rotate out on the next advance
// (the current item's video onended / image timer still fires advance()). Group-sync (schedule-
// driven) and wall followers reconcile via their own tick, so play through immediately as before.
if (this.hasContentOnScreen() && !this.wallFollower && !this.scheduleDriven) {
// ...but only when an advance is actually coming. Single-item playback here deliberately has
// none: `single` makes renderImage, renderVideo and renderWidget all skip their timer (a solo
// item is meant to sit there), so replacing the one item of a one-item playlist deferred forever
// and the old content stayed on the screen. On Tizen this strands IMAGES too, not just video and
// widgets as on the web player, because the timer is skipped for every type.
var outgoingNeverAdvances = !this.items || this.items.length <= 1;
if (this.hasContentOnScreen() && !this.wallFollower && !this.scheduleDriven && !outgoingNeverAdvances) {
this._deferredRotation = true;
this._deferredSuccessorId = this.itemIdentity(items[nextIdx]);
// Safety net: a deferral is a bet that an advance will arrive. If it does not, apply the
// change anyway rather than leave the screen on content the operator has replaced.
var self = this;
if (this._deferredDeadline) clearTimeout(this._deferredDeadline);
this._deferredDeadline = setTimeout(function () {
if (!self._deferredRotation) return;
self._deferredRotation = false;
var di = -1;
for (var k = 0; k < self.items.length; k++) {
if (self.itemIdentity(self.items[k]) === self._deferredSuccessorId) { di = k; break; }
}
self.startPlaybackAt(di === -1 ? 0 : di);
}, 60000);
return;
}
@ -250,6 +269,7 @@ PlaylistPlayer.prototype.advance = function () {
// stashed list and continue at the preserved successor instead of interrupting/restarting.
if (this._deferredRotation) {
this._deferredRotation = false;
if (this._deferredDeadline) { clearTimeout(this._deferredDeadline); this._deferredDeadline = null; }
var sid = this._deferredSuccessorId; this._deferredSuccessorId = null;
var to = sid ? this.indexOfIdentity(this.items, sid) : -1;
this.startPlaybackAt(to >= 0 ? to : 0);