diff --git a/server/player/index.html b/server/player/index.html
index 54ef27a..95681ee 100644
--- a/server/player/index.html
+++ b/server/player/index.html
@@ -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;
diff --git a/tizen/js/player.js b/tizen/js/player.js
index a3fa7f4..31d8247 100644
--- a/tizen/js/player.js
+++ b/tizen/js/player.js
@@ -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);