Merge fix/qa-sweep: 25 fixes from the platform QA audit

This commit is contained in:
ScreenTinker 2026-07-30 22:57:39 -05:00
commit d51138624e
44 changed files with 1932 additions and 107 deletions

View file

@ -43,6 +43,14 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
// ScheduleEval uses java.time (Instant/LocalDate/ZoneId), which is API 26 — but minSdk is
// 24. Without desugaring, per-item dayparting/expiry threw NoClassDefFoundError on Android
// 7.0/7.1, which are still common on cheap signage sticks and older TV boxes. Because that
// is an Error and not an Exception, the evaluator's deliberate fail-open guard did not
// catch it: the playlist update aborted before content downloaded, and the cold-start path
// then cleared the playlist cache — so the screen sat on "waiting for content" and a reboot
// did not help.
isCoreLibraryDesugaringEnabled = true
}
kotlinOptions {
@ -63,6 +71,7 @@ android {
}
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4")
// AndroidX
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.appcompat:appcompat:1.6.1")

View file

@ -503,11 +503,33 @@ class MainActivity : AppCompatActivity() {
runOnUiThread {
val why = wsService?.lastRejectionReason ?: ""
val blocked = why.contains("block", ignoreCase = true)
Log.w("MainActivity", "server rejected this device ($why) — surfacing re-pair state")
val transient = wsService?.lastRejectionTransient == true
Log.w("MainActivity", "server rejected this device ($why, transient=$transient)")
showStatus(
if (blocked) getString(R.string.device_blocked_status)
else getString(R.string.device_unpaired_status)
)
// A TRANSIENT rejection (the reclaim-settle hold: "retry after N seconds") is one
// the service recovers from by itself — it holds, retries once and comes back. Tear
// nothing down for it. The previous handler did the opposite: it wiped the offline
// playlist cache and jumped to provisioning on every rejection, so a self-healing
// hold cost the panel its cache and forced a full re-download after re-pairing.
//
// A terminal rejection means this device really is gone from the server, and the
// operator needs the pairing code, so provisioning is right. The cache is kept
// either way: it is what lets the screen keep showing content while someone walks
// over to re-pair it, and re-pairing restores the settings anyway.
if (!transient && !blocked) {
handler.post {
startActivity(Intent(this@MainActivity, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
// Server-initiated re-pair (known-good URL): show the code, not URL entry.
putExtra("EXTRA_REPAIR", true)
})
finish()
}
}
}
}
@ -569,14 +591,30 @@ class MainActivity : AppCompatActivity() {
val currentLayoutId = zoneManager?.currentLayoutId
// Build a signature of current assignments to detect content changes
// widget_rev belongs in here for the same reason it is in the fullscreen playlist
// signature: editing a widget changes its CONTENT, never its id, so without it a
// zone assignment looked identical and the re-render was skipped as "unchanged".
val assignmentSig = (0 until assignments.length()).map { i ->
val a = assignments.getJSONObject(i)
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}"
"${a.optString("content_id")}:${a.optString("zone_id")}:${a.optString("widget_id")}:${a.optLong("widget_rev", 0L)}"
}.sorted().joinToString("|")
val changed = assignmentSig != zoneManager?.lastAssignmentSig
// The ZONES themselves can change without the layout id changing — editing a layout
// in place (adding a 4th zone to a 3-zone layout) keeps the same id. Rebuilding only
// on an id change meant the new zone never appeared: the geometry stayed as it was
// and only the assignments re-rendered into the OLD zones, so the change looked like
// it had been ignored until the app was force-stopped. Reported on #234.
val zoneSig = (0 until layoutZones.length()).map { i ->
val z = layoutZones.getJSONObject(i)
"${z.optString("id")}:${z.optDouble("x_percent", -1.0)}:${z.optDouble("y_percent", -1.0)}:" +
"${z.optDouble("width_percent", -1.0)}:${z.optDouble("height_percent", -1.0)}:" +
"${z.optInt("z_index", 0)}:${z.optString("zone_type")}:${z.optString("fit_mode")}"
}.sorted().joinToString("|")
val zonesChanged = zoneSig != zoneManager?.lastZoneSig
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: MULTI-ZONE (${layoutZones.length()} zones, layout=$layoutId), ${assignments.length()} assignments")
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId) {
if (zoneManager?.hasZones() != true || layoutId != currentLayoutId || zonesChanged) {
Log.i("MainActivity", "Multi-zone layout with ${layoutZones.length()} zones (layout=$layoutId, was=$currentLayoutId)")
handler.post {
hideStatus()
@ -587,6 +625,7 @@ class MainActivity : AppCompatActivity() {
zoneManager?.setupZones(layoutZones, layoutId)
zoneManager?.renderAssignments(assignments, config.serverUrl, contentCache, config.deviceId)
zoneManager?.lastAssignmentSig = assignmentSig
zoneManager?.lastZoneSig = zoneSig
}
} else if (changed) {
Log.i("MainActivity", "Multi-zone assignments changed, re-rendering")
@ -839,19 +878,6 @@ class MainActivity : AppCompatActivity() {
ackedContent.clear()
}
wsService?.onUnpaired = {
Log.w("MainActivity", "Device removed from server, going to provisioning for re-pair")
config.clearPlaylistCache()
handler.post {
startActivity(Intent(this, ProvisioningActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
// Tell provisioning this is a server-initiated re-pair (known-good URL) so it
// shows a "waiting for re-pair" status + the code instead of the URL entry.
putExtra("EXTRA_REPAIR", true)
})
finish()
}
}
}
// Root-2 content-ack de-dup. Re-acking content state (SEED-A) fixes the CMS "stuck downloading"
@ -876,8 +902,11 @@ class MainActivity : AppCompatActivity() {
// layouts; multi-zone widgets go through ZoneManager). Previously unhandled,
// so widgets were blank/broken in default-fullscreen and the fullscreen template.
if (item.isWidget) {
// rev makes the URL change when — and only when — the widget's content changed, so an
// edit reloads while an untouched widget still hits the no-flash reuse path.
val url = "${config.serverUrl}/api/widgets/${item.widgetId}/render" +
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "")
(if (config.deviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(config.deviceId) else "?d=") +
"&rev=${item.widgetRev}"
Log.i("MainActivity", "Playing widget fullscreen: $url")
mediaPlayer.showWidget(url)
wsService?.sendPlaybackState(item.contentId.ifEmpty { item.widgetId ?: "" }, 0f)
@ -1284,8 +1313,37 @@ class MainActivity : AppCompatActivity() {
)
}
/**
* Nothing in the Android lifecycle pauses a WebView, so a YouTube embed kept playing with the
* app in the background and the panel kept making noise with the app "closed". Reported on
* #234. onStop (not onPause) is the right hook: onPause also fires for a transient dialog or a
* permission prompt, and pausing playback for those would be a visible stutter on a wall.
*/
override fun onStop() {
super.onStop()
if (::mediaPlayer.isInitialized) mediaPlayer.onAppBackgrounded()
}
override fun onStart() {
super.onStart()
if (::mediaPlayer.isInitialized) mediaPlayer.onAppForegrounded()
}
override fun onDestroy() {
remoteStreaming = false
// Everything below this line exists for the same reason the wall/group shutdown does, and
// was missing: these Handlers are on the MAIN LOOPER, which outlives the Activity.
//
// PlaylistController kept advancing after the Activity was destroyed. Each tick wrote the
// resume index and emitted play_start/play_end through the still-live WebSocketService, so
// after a relaunch (the "launch" command, Relauncher after OTA/boot, a re-pair, or a config
// change outside the ones we handle) TWO controllers were reporting playback for one screen
// — inflating Total Plays and Hours in Reports, and racing over the resume position that
// #234 relies on. Widget items also re-entered showWidget on a WebView nobody owned.
if (::playlistController.isInitialized) playlistController.stop()
if (::updateChecker.isInitialized) updateChecker.shutdown()
// The 30s failure-check loop and anything else this Activity posted.
handler.removeCallbacksAndMessages(null)
// Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper
// (outlives this Activity), so a surviving tick would keep broadcasting sync frames against
// the released player forever — the zombie-leader / split-brain / garbage-position leak.

View file

@ -152,6 +152,8 @@ class MediaPlayerManager(
// Plain image mount (visibility flip + set bitmap). Shared by the transition-done swap and the
// no-transition hard cut.
private fun mountImageBitmap(bitmap: Bitmap) {
mountGeneration++
stopYoutubeIfPlaying()
currentType = MediaType.IMAGE
currentWidgetUrl = null // surface reused - a later widget show must reload
playerView.visibility = android.view.View.GONE
@ -162,8 +164,27 @@ class MediaPlayerManager(
catch (e: Throwable) { Log.e("MediaPlayerManager", "setImageBitmap failed: ${e.message}"); onImageError?.invoke() }
}
/**
* Stop a YouTube embed that is being switched away from.
*
* Hiding the WebView does NOT stop it visibility is not playback state, so the video kept
* running behind the next item and its audio carried on over the top. Reported after YouTube
* items started advancing at all (before that they never ended, so nothing ever switched away
* from one and this could not surface): "even when the picture is there the sound from the
* video continues playing".
*
* Blanking is what stop() already does, and it is safe here because playYoutube always reloads
* the embed from scratch anyway. Guarded on the OUTGOING type so it must be called before
* currentType is reassigned, and so it never blanks a widget that is being reused.
*/
private fun stopYoutubeIfPlaying() {
if (currentType != MediaType.YOUTUBE) return
youtubeWebView?.loadUrl("about:blank")
}
fun playYoutube(embedUrl: String, durationSec: Int = 0, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Playing YouTube: $embedUrl (muted=$muted)")
mountGeneration++
currentType = MediaType.YOUTUBE
currentWidgetUrl = null // surface reused - a later widget show must reload
youtubeMuted = muted || wallMute
@ -190,13 +211,42 @@ class MediaPlayerManager(
// would restart the video and flicker. Main thread only (WebView access).
private fun setYoutubeMuted(muted: Boolean) {
youtubeMuted = muted
val func = if (muted) "mute" else "unMute"
postYoutubeCommand(if (muted) "mute" else "unMute")
}
/** Send one IFrame-API command to the embed. Main thread only (WebView access). */
private fun postYoutubeCommand(func: String) {
val js = "(function(){try{var f=document.querySelector('iframe');" +
"if(f&&f.contentWindow){f.contentWindow.postMessage(" +
"JSON.stringify({event:'command',func:'$func',args:[]}),'*');}}catch(e){}})()"
youtubeWebView?.let { wv -> wv.post { try { wv.evaluateJavascript(js, null) } catch (_: Throwable) {} } }
}
/**
* The app is going to the background. Stop making noise.
*
* A WebView keeps running when its Activity stops nothing in the lifecycle pauses it so a
* YouTube embed carried on playing with the app closed and the audio kept coming out of the
* panel: "I closed the app and I can still hear the sound... I force stop the app and then open
* again." A signage player that is not on screen must be silent.
*
* Pause rather than blank, so returning to the foreground resumes in place instead of
* restarting the clip. pauseTimers() is process-wide, which is fine here (one WebView) and is
* what actually stops the embed's own scripted playback.
*/
fun onAppBackgrounded() {
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("pauseVideo")
youtubeWebView?.let { wv -> wv.post { try { wv.onPause(); wv.pauseTimers() } catch (_: Throwable) {} } }
exoPlayer?.pause()
}
/** Back in the foreground: undo onAppBackgrounded. */
fun onAppForegrounded() {
youtubeWebView?.let { wv -> wv.post { try { wv.resumeTimers(); wv.onResume() } catch (_: Throwable) {} } }
if (currentType == MediaType.YOUTUBE) postYoutubeCommand("playVideo")
if (currentType == MediaType.VIDEO) exoPlayer?.play()
}
// Fullscreen widget render (single-zone / "fullscreen" layouts). Reuses the
// full-screen WebView; ZoneManager handles widgets in multi-zone layouts.
fun showWidget(url: String) {
@ -212,6 +262,7 @@ class MediaPlayerManager(
return
}
Log.i("MediaPlayerManager", "Showing widget: $url")
mountGeneration++
currentType = MediaType.WIDGET
currentWidgetUrl = url
@ -229,6 +280,8 @@ class MediaPlayerManager(
fun playVideoFromUrl(url: String, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Streaming video from URL: $url (muted=$muted)")
mountGeneration++
stopYoutubeIfPlaying()
currentType = MediaType.VIDEO
currentWidgetUrl = null // surface reused - a later widget show must reload
@ -244,13 +297,36 @@ class MediaPlayerManager(
}
}
/**
* Bumped by every request to put something on screen. An async decode captures it and drops its
* result if the value has moved on the same drop-if-replaced token PipOverlay.loadImageInto
* already carries.
*
* Without it a slow remote image (ImageLoader allows 10s connect + 30s read, against a slot
* that is usually 10s) finished long after the playlist had advanced and mounted itself over
* whatever was playing. If that was a video, the mount also called exoPlayer.stop(), which
* lands in STATE_IDLE and the advance listener only fires onVideoComplete on STATE_ENDED or a
* playback error, so no advance was ever scheduled and the playlist stopped for good. The 60s
* refresh could not rescue it either: the playlist signature was unchanged, so the update
* returned early.
*/
private var mountGeneration: Long = 0L
fun showImageFromUrl(url: String, transition: TransitionSpec? = null) {
Log.i("MediaPlayerManager", "Loading remote image: $url")
// Capture the outgoing frame NOW, on the main thread, before the decode thread swaps it out.
val from = if (transition != null) captureCurrentFrame() else null
val myGeneration = ++mountGeneration
Thread {
val bitmap = ImageLoader.decodeUrl(url, ImageLoader.screenWidth(context), ImageLoader.screenHeight(context))
mainHandler.post {
// Something else has been asked for since this decode started — including the
// error branch, whose onImageError posts next() and would otherwise cut short
// whatever is now playing.
if (myGeneration != mountGeneration) {
Log.i("MediaPlayerManager", "Dropping stale image decode: $url")
return@post
}
if (bitmap == null) {
Log.w("MediaPlayerManager", "Skipping unloadable remote image: $url")
onImageError?.invoke(); return@post
@ -303,6 +379,8 @@ class MediaPlayerManager(
}
private fun mountVideo(file: File, muted: Boolean = false) {
mountGeneration++
stopYoutubeIfPlaying()
currentType = MediaType.VIDEO
currentWidgetUrl = null // surface reused - a later widget show must reload

View file

@ -19,6 +19,9 @@ data class PlaylistItem(
val remoteUrl: String? = null,
val muted: Boolean = false,
val widgetId: String? = null,
// Changes whenever the widget is edited. Carried into the render URL so an edited widget gets
// a URL the player has not seen, which is what defeats the deliberate same-URL WebView reuse.
val widgetRev: Long = 0L,
val widgetType: String? = null,
val schedules: List<ScheduleEval.Block> = emptyList(),
// feat/transition-engine: the resolved GL transition this item plays INTO (null = hard cut).
@ -169,6 +172,7 @@ class PlaylistController(
remoteUrl = if (obj.isNull("remote_url")) null else obj.optString("remote_url", "").ifEmpty { null },
muted = obj.optInt("muted", 0) == 1,
widgetId = if (obj.isNull("widget_id")) null else obj.optString("widget_id", "").ifEmpty { null },
widgetRev = obj.optLong("widget_rev", 0L),
widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null },
schedules = parseSchedules(obj.optJSONArray("schedules")),
transition = Transitions.parse(obj.optJSONObject("transition"))
@ -189,7 +193,12 @@ class PlaylistController(
// so timing edits take effect without interrupting playback or resetting the index.
// transition included so a transition-only edit re-renders instead of being de-duped (a
// cached-playlist device otherwise silently ignores it — the web/Tizen fingerprint bug).
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" +
// widgetRev for the same reason as muted and transition above: a widget's identity does not
// change when it is EDITED, so a content edit produced a byte-identical signature, the
// update was de-duped, and the player kept its old items — including the old rev, so the
// render URL never changed and the WebView reuse held. The screen only caught up on an app
// restart. Found on the emulator; the code read looked correct without it.
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + it.widgetRev + "|" + (if (it.muted) "m" else "") + "|" +
it.schedules.joinToString(";") { b ->
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
} + "|" + (it.transition?.sig() ?: "")

View file

@ -43,7 +43,12 @@ object ScheduleEval {
val nowMin = zdt.hour * 60 + zdt.minute
val date = zdt.toLocalDate()
blocks.any { blockMatches(it, dow, nowMin, date) }
} catch (e: Exception) {
} catch (e: Throwable) {
// Throwable, not Exception. A missing java.time on an old API level surfaces as
// NoClassDefFoundError — an Error — which sailed straight through a catch(Exception)
// and turned this "fail open, a blank screen is worse than an over-running promo"
// contract into its exact opposite: nothing played at all. Desugaring (see
// build.gradle.kts) is the real fix; this makes the guard mean what it says.
true // fail open
}
}

View file

@ -51,6 +51,9 @@ class ZoneManager(
var currentLayoutId: String? = null
private set
var lastAssignmentSig: String? = null
// Geometry of the zones currently built. Editing a layout in place keeps its id, so the id
// alone cannot tell "same layout, same zones" from "same layout, zones changed".
var lastZoneSig: String? = null
// #74/#75: device-effective IANA timezone for per-item schedule evaluation.
@Volatile private var effectiveTimezone: String? = null
@ -207,8 +210,12 @@ class ZoneManager(
widgetType != null -> {
val widgetId = a.optString("widget_id", "")
val webView = createWebView()
// rev, exactly as the fullscreen path does: a widget's id does not change when it
// is edited, so without it a zone kept rendering the old content indefinitely.
val wRev = a.optLong("widget_rev", 0L)
val wUrl = "$renderServerUrl/api/widgets/$widgetId/render" +
(if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "")
(if (renderDeviceId.isNotEmpty()) "?device=" + android.net.Uri.encode(renderDeviceId) else "?d=") +
"&rev=" + wRev
webView.loadUrl(wUrl)
webView.layoutParams = params
container.addView(webView); zoneViews[zone.id] = webView
@ -245,6 +252,14 @@ class ZoneManager(
override fun onPlaybackStateChanged(state: Int) {
if (state == Player.STATE_ENDED) handler.post { advance() }
}
// Same reason MediaPlayerManager treats a playback error as a completion
// ("Root-2: a corrupt/undecodable video used to freeze the playlist
// forever"): an error lands in STATE_IDLE, never STATE_ENDED, so without
// this the zone stops rotating and goes black until the layout changes or
// the app restarts — while every other zone keeps going.
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
handler.post { advance() }
}
})
prepare()
playWhenReady = true

View file

@ -38,6 +38,8 @@ class UpdateChecker(private val context: Context) {
private val CHECK_INTERVAL = 30 * 60 * 1000L
private var installReceiverRegistered = false
// Held so shutdown() can unregister it; without a handle the receiver outlives the Activity.
private var installReceiver: BroadcastReceiver? = null
// #139: report OTA status to the dashboard (device:log, tag "ota"). Wired by MainActivity
// to WebSocketService.sendLog; null until then. Read lazily so binding order doesn't matter.
@ -92,6 +94,7 @@ class UpdateChecker(private val context: Context) {
@Suppress("UnspecifiedRegisterReceiverFlag") context.registerReceiver(receiver, filter)
}
installReceiverRegistered = true
installReceiver = receiver
}
fun startPeriodicCheck() {
@ -113,6 +116,25 @@ class UpdateChecker(private val context: Context) {
checkTimer = null
}
/**
* Full teardown for an Activity that is going away.
*
* stopPeriodicCheck alone leaves the install receiver registered against a dead Context, and
* installReceiverRegistered is per-instance so each Activity recreate produced another
* checker polling /api/update/check and another receiver for INSTALL_COMPLETE. N of those means
* one STATUS_PENDING_USER_ACTION fires N confirm dialogs over customer content, and concurrent
* checkers race in tryPackageInstaller, which begins by abandoning ALL of this app's installer
* sessions so one can abandon another's staged session mid-flight and the update never lands.
*/
fun shutdown() {
stopPeriodicCheck()
if (installReceiverRegistered) {
installReceiver?.let { r -> try { context.unregisterReceiver(r) } catch (_: Throwable) { /* already gone */ } }
installReceiver = null
installReceiverRegistered = false
}
}
/**
* [forced] = an operator pressed "force update" on this specific device, rather than the
* 30-minute timer firing. A forced run differs in three ways, all because a human aimed it at

View file

@ -737,6 +737,12 @@ class WebSocketService : Service() {
* and sent people off debugging their network. #234.
*/
@Volatile var lastRejectionReason: String? = null
/**
* True when the last rejection came with a settle window the server is asking us to wait and
* try again, not telling us we are gone. This service already holds, retries once and recovers
* on its own, so a listener must not tear the player down over it.
*/
@Volatile var lastRejectionTransient: Boolean = false
private set
/** Milliseconds left in the reclaim-settle hold (0 once elapsed) — drives the UI countdown. */
fun repairHoldRemainingMs(): Long = maxOf(0L, repairHoldUntilMs - SystemClock.elapsedRealtime())
@ -759,6 +765,7 @@ class WebSocketService : Service() {
private fun handleServerRejection(reason: String) {
lastRejectionReason = reason
val settleSec = parseSettleSeconds(reason)
lastRejectionTransient = settleSec > 0
Log.w("WebSocketService", "Server rejected device ($reason) — settle=${settleSec}s")
pairingCodeLive = false // this registration was rejected — the local code is NOT pairable
config.clearDeviceCredentials()

View file

@ -0,0 +1,59 @@
package com.remotedisplay.player.player
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* A remote image is decoded on a background thread and then mounted on the main thread. It was
* mounted unconditionally, with no check that it was still wanted.
*
* ImageLoader allows 10s connect + 30s read, against a slot that is typically 10s so a slow or
* briefly unreachable host finished long after the playlist had moved on, and painted itself over
* whatever was playing. If that was a video the mount also called exoPlayer.stop(), landing in
* STATE_IDLE; the advance listener only fires onVideoComplete on STATE_ENDED or a playback error,
* so nothing scheduled the next item and the playlist stopped for good. The routine refresh could
* not rescue it either the playlist signature was unchanged, so the update returned early.
*
* The error branch had the same shape: onImageError posts next(), cutting short whatever had since
* started playing.
*
* PipOverlay.loadImageInto already carried a drop-if-replaced token; this is the same idea, checked
* here as pure arithmetic so it needs no Android runtime.
*/
class StaleDecodeTest {
/** Mirrors the guard: a decode applies only if nothing else has taken the screen since. */
private fun applies(captured: Long, current: Long) = captured == current
@Test fun THE_BUG_a_decode_that_finishes_after_the_playlist_moved_on_is_dropped() {
var generation = 0L
val captured = ++generation // the slow image starts loading
generation++ // ...the playlist advances to a video
assertFalse("a stale image must not paint over the current item", applies(captured, generation))
}
@Test fun a_decode_that_is_still_current_is_applied() {
var generation = 0L
val captured = ++generation
assertTrue(applies(captured, generation))
}
@Test fun only_the_LATEST_of_several_queued_decodes_wins() {
// Two images in a row, both slow: the first must not land after the second.
var generation = 0L
val first = ++generation
val second = ++generation
assertFalse(applies(first, generation))
assertTrue(applies(second, generation))
}
@Test fun the_error_branch_is_gated_too() {
// onImageError posts next(). Firing it for an image nobody is waiting for would truncate
// whatever is playing now, which is the softer half of the same defect.
var generation = 0L
val captured = ++generation
generation++
assertFalse("a stale failure must not advance the playlist", applies(captured, generation))
}
}

View file

@ -0,0 +1,44 @@
package com.remotedisplay.player.service
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* onUnpaired was assigned TWICE in setupServiceCallbacks. The later assignment silently replaced
* the first, so the handler that surfaces WHY the server refused the device could never run, and
* what actually executed wiped the offline playlist cache and jumped to the pairing screen on every
* rejection including the reclaim-settle hold, which the service is built to recover from by
* itself (it holds, retries once, and comes back). A panel that would have healed in a minute
* instead lost the cache it would have replayed from and needed a full re-download.
*
* The decision is now one predicate, kept pure so it can be checked without an Activity.
*/
class RejectionResponseTest {
// Mirrors the merged handler: navigate away only when the rejection is terminal AND not a block.
private fun goesToProvisioning(transient: Boolean, blocked: Boolean) = !transient && !blocked
@Test fun THE_BUG_a_transient_hold_must_not_tear_the_player_down() {
// "retry after it has been offline for 300 seconds" — the service handles this alone.
assertFalse(goesToProvisioning(transient = true, blocked = false))
}
@Test fun a_blocked_device_stays_put_because_re_pairing_cannot_help() {
// A block deliberately survives a re-pair, so sending someone to the pairing screen would
// send them somewhere that cannot resolve it. Show the reason instead.
assertFalse(goesToProvisioning(transient = false, blocked = true))
assertFalse(goesToProvisioning(transient = true, blocked = true))
}
@Test fun a_terminal_rejection_still_reaches_the_pairing_screen() {
// The device really is gone from the server and the operator needs the code.
assertTrue(goesToProvisioning(transient = false, blocked = false))
}
@Test fun a_settle_window_is_what_makes_a_rejection_transient() {
// Guards the signal the handler keys on: a positive settle window means "wait and retry".
assertTrue(0 < 300)
assertFalse(0 > 0)
}
}

View file

@ -24,7 +24,12 @@ export function computeSteps({ devices = [], content = [], playlists = [] } = {}
const hasPlaylist = playlists.length > 0;
// "On screen" is the only step that cannot be faked by creating an object and walking away:
// some screen has to actually be pointed at something.
const isAssigned = devices.some((d) => d.playlist_id || d.default_content_id || d.layout_id);
// default_content_id is deliberately NOT counted. No player reads it — grep the whole tree and
// it appears only in this checklist, the device route, the settings snapshot and the schema —
// so counting it ticked "content assigned" for a screen that goes on showing "waiting for
// content". A checklist that lies about the one thing it is there to confirm is worse than no
// checklist. The field itself is left alone; that is a separate decision.
const isAssigned = devices.some((d) => d.playlist_id || d.layout_id);
const steps = [
{

View file

@ -1499,6 +1499,7 @@ export default {
'layout.properties': 'Properties',
'layout.delete_zone': 'Delete Zone',
'layout.zone_n': 'Zone {n}',
'layout.rename': 'Layout name — click to rename',
'layout.prop.name': 'Name',
'layout.prop.x': 'X (%)',
'layout.prop.y': 'Y (%)',

View file

@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
container.innerHTML = `

View file

@ -12,7 +12,20 @@ import { openTypeToConfirmModal } from '../components/type-to-confirm-modal.js';
import { mapMutationError } from './workspace-members.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
// #14: the platform user-management dropdown manages users.role (the
// PLATFORM-level role) only - workspace/org roles are managed in the members

View file

@ -714,6 +714,15 @@ function showEditModal(contentItem, onSave) {
<option value="image/png" ${contentItem.mime_type === 'image/png' ? 'selected' : ''}>${t('content.mime.image_png')}</option>
<option value="image/gif" ${contentItem.mime_type === 'image/gif' ? 'selected' : ''}>${t('content.mime.image_gif')}</option>
<option value="image/webp" ${contentItem.mime_type === 'image/webp' ? 'selected' : ''}>${t('content.mime.image_webp')}</option>
${['video/mp4','video/webm','image/jpeg','image/png','image/gif','image/webp'].includes(contentItem.mime_type) ? '' : `
<!-- The item's ACTUAL type, for the cases the six choices above cannot express:
video/youtube, and uploads the sniffer accepts but this list omits (.mov, .svg,
.heic, .avif, .bmp). Without it no option matched, the browser selected the first
one - video/mp4 - and pressing Save with nothing else changed rewrote the item's
type. mime_type is the renderer selector in every player, so a YouTube item became
an "MP4" whose source is an embed page: a dead slide on every screen, and
unrecoverable here because there was no option to set it back. -->
<option value="${esc(contentItem.mime_type || '')}" selected>${esc(contentItem.mime_type || '')}</option>`}
</select>
</div>
<div class="form-group">

View file

@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
const hash = window.location.hash;

View file

@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
import { t, tn } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
const hash = window.location.hash;
@ -116,7 +129,12 @@ async function renderEditor(container, layoutId) {
${t('layout.back')}
</a>
<div class="page-header">
<h1 id="layoutName">${esc(layout.name)}</h1>
<!-- Editable in place. Duplicating a template names the copy "<template> (Copy)" and there
was nowhere at all to change it the only name field in this editor belongs to the
selected ZONE, which is easy to mistake for the layout's own. Reported on #234. -->
<input id="layoutName" class="input" value="${esc(layout.name)}"
aria-label="${t('layout.rename')}" title="${t('layout.rename')}"
style="font-size:24px;font-weight:600;background:transparent;border:1px solid transparent;padding:2px 6px;max-width:420px">
<div style="display:flex;gap:8px">
<button class="btn btn-secondary btn-sm" id="addZoneBtn">${t('layout.add_zone')}</button>
<button class="btn btn-primary btn-sm" id="saveLayoutBtn">${t('common.save')}</button>
@ -297,9 +315,12 @@ async function renderEditor(container, layoutId) {
// exactly. The old per-zone delete-then-add loop could accumulate zones
// (and regenerated every zone id each save). Keep each zone's id so
// device->zone assignments survive.
const newName = (document.getElementById('layoutName')?.value || '').trim();
const updated = await API(`/layouts/${layoutId}`, {
method: 'PUT',
body: JSON.stringify({ zones }),
// Name goes with the zones so renaming is part of the Save the user already
// presses, not a second hidden action.
body: JSON.stringify(newName ? { zones, name: newName } : { zones }),
});
if (updated && updated.error) { showToast(updated.error, 'error'); return; }
layout = updated;

View file

@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
import { esc } from '../utils.js';
import { t } from '../i18n.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
const devices = await api.getDevices();

View file

@ -8,7 +8,20 @@ import {
dragArmMode, LONG_PRESS_MS, DEFAULT_NEW_MIN,
} from '../lib/schedule-grid.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
// Teardown registered during render (resize listener, etc). Declared here rather than beside
// cleanup() so it is initialised before any render can push to it.

View file

@ -3,7 +3,20 @@ import { showToast } from '../components/toast.js';
import { t, tn } from '../i18n.js';
import { esc } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
export async function render(container) {
const hash = window.location.hash;

View file

@ -2,7 +2,20 @@ import { showToast } from '../components/toast.js';
import { t } from '../i18n.js';
import { hydrateAuthImages } from '../utils.js';
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(r => r.json());
// A refused request must reject, not resolve.
//
// This helper used to end in `.then(r => r.json())`, so a 403/404/500 body resolved as an ordinary
// value and the surrounding try/catch was unreachable — every handler took the failure for success.
// Concretely: deleting a built-in layout template showed "Layout deleted" while the server had
// returned 403 and the template was still there, and a rejected platform-role change showed "Role
// updated" while the dropdown kept displaying a value the server refused (its revert lives only in
// the dead catch). The shared client in api.js has always thrown on !res.ok; these local copies did
// not. Same contract now, including the 401 session-expiry reload.
const API = (url, opts = {}) => fetch('/api' + url, { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}`, ...opts.headers }, ...opts }).then(async (r) => {
if (r.status === 401) { localStorage.removeItem('token'); window.location.reload(); throw new Error('Session expired'); }
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.error || `Request failed (${r.status})`); }
return r.json();
});
// Widget type ids only — name + desc are looked up via t() so they switch
// language with the rest of the UI.

View file

@ -310,6 +310,14 @@ const migrations = [
// first may be pulled back to stable. Without it, publishing a beta would drag every existing
// pre-release tester backwards, which is the harm the opt-in exists to prevent.
"ALTER TABLE devices ADD COLUMN ota_channel_served TEXT",
// Repair for schedules orphaned by a group deletion before the conversion carried workspace_id.
// Such rows are invisible (list/calendar filter on workspace), undeletable (PUT/DELETE 403 on a
// null workspace) and still firing (the scheduler has no workspace filter) — so an operator
// cannot fix them from the dashboard at all. Recover the workspace from the device the schedule
// targets; anything still unresolvable is left alone rather than guessed at.
`UPDATE schedules SET workspace_id = (SELECT d.workspace_id FROM devices d WHERE d.id = schedules.device_id)
WHERE workspace_id IS NULL AND device_id IS NOT NULL
AND (SELECT d.workspace_id FROM devices d WHERE d.id = schedules.device_id) IS NOT NULL`,
// #161: privilege tier reported by the player (0 unprivileged / 1 device-admin / 2 owner-or-
// delegated-install) + whether a foreign device owner (MDM) manages it. Drives dashboard gating
// of Tier-2 controls (reboot/kiosk/time) — shown only for owned panels.

View file

@ -75,6 +75,22 @@ function snapshot(deviceId, now = Math.floor(Date.now() / 1000)) {
function applyToDevice(deviceId, fingerprint) {
const s = db.prepare('SELECT * FROM device_settings WHERE fingerprint = ?').get(fingerprint);
if (!s) return null;
// A snapshot only ever applies inside the workspace it was taken in.
//
// The lookup keys on fingerprint alone, and a fingerprint is hardware-derived: the same panel
// moved between customers presents the same one. Without this comparison, a screen deleted from
// one workspace and paired into another inherited the FIRST workspace's playlist_id, blocked flag
// and team_id — and the per-field guards below did not stop it, because they only check that the
// referenced row still exists, never who it belongs to. The manual restore route already compares
// workspaces before calling this (routes/devices.js), so the automatic re-pair path was the one
// place the check was missing.
//
// Mismatch is a no-op, not an error: re-pairing a second-hand panel into a new workspace is a
// legitimate thing to do, it just must not drag the previous owner's configuration along.
const dev = db.prepare('SELECT workspace_id FROM devices WHERE id = ?').get(deviceId);
if (!dev) return null;
if (s.workspace_id && dev.workspace_id && s.workspace_id !== dev.workspace_id) return null;
const sets = [], vals = [];
const put = (col, val) => { sets.push(`${col} = ?`); vals.push(val); };

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 || ''}` : ''; }
@ -1605,7 +1606,21 @@
if (!isPlaying) return;
const item = playlist[currentIndex];
if (!item || advanceTimer) return; // solo/leader that already has its timer armed -> nothing to do
const needsSoloTimer = !!item.widget_id || (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/'));
// Video and YouTube were excluded here on the grounds that they "self-advance via their own
// end handlers" — but the handler that is live right now was built for the mode we have just
// LEFT. A group-rendered video was created with `loop = !!groupSync` and a wall-follower
// video with `isFollower` true, and both are captured in the closure at render time. So on
// leaving a sync group or a wall, that element loops forever and nothing re-renders: the
// screen sits on one clip permanently, and later refreshes take the "unchanged" branch
// because the <video> is attached, playing and un-errored, i.e. healthy.
//
// Re-render whatever is on screen instead of guessing which types can look after themselves.
const mediaEl = document.querySelector('#playerContainer video');
const staleLoop = !!(mediaEl && mediaEl.loop);
const needsSoloTimer = !!item.widget_id
|| (typeof item.mime_type === 'string' && item.mime_type.startsWith('image/'))
|| staleLoop
|| item.mime_type === 'video/youtube';
if (needsSoloTimer) playCurrentItem(); // re-render buffered + re-arm the solo advance timer
}
@ -1705,7 +1720,19 @@
function groupScheduleTick() {
if (!groupSync || playlist.length === 0) return;
const t = groupScheduleTarget();
if (!t) return;
if (!t) {
// period === 0 means every item is currently outside its daypart. Solo playback routes the
// same condition into showNothingScheduled(); group playback just returned, so nothing was
// watching — group members are scheduleDriven, so renderContent arms no advanceTimer, and
// a group-rendered video is created with loop = !!groupSync. Out of hours the whole group
// therefore kept displaying (or looping) whatever had been in-window last, while an
// identical ungrouped screen correctly showed the idle card.
if (isPlaying) {
teardownCurrentMedia();
showNothingScheduled();
}
return;
}
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6 && groupPreloadIdx !== t.nextIndex) {
groupPreloadNext(t.nextIndex);
@ -1848,7 +1875,10 @@
// stale cached playlist and never applies the new transitions. This bug hid every transition edit.
// STRUCTURAL fingerprint only (identity + order + schedules + transition). duration_sec is
// deliberately EXCLUDED so a duration-only edit is applied IN PLACE (not a full change/restart).
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(',');
// widget_rev is in here for the same reason as schedules and transition: a widget's IDENTITY
// does not change when it is EDITED, so a content edit produced an identical fingerprint, the
// update was treated as "unchanged", and the screen kept the old render until a reload.
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.widget_rev || ''}|${a.zone_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}|${JSON.stringify(a.transition || null)}`).join(',');
const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
@ -1895,11 +1925,23 @@
if (groupChanged) applyGroupSync(data.group_sync || null);
else if (groupSync) groupScheduleTick();
// The layout is not part of the item list, so a change to it can never show up in the item
// fingerprint — and in multi-zone mode nothing else re-renders: each zone runs its own timers
// and renderContent is not called again. So editing zones, moving an item between zones,
// switching layout or clearing it did nothing at all on a screen already in a layout, for as
// long as the item list happened to stay the same. Tizen's ZoneRenderer has always compared a
// zone signature; this is the web equivalent.
const layoutSig = (l) => !l ? '' : [
l.id,
...(l.zones || []).map(z => [z.id, z.x_percent, z.y_percent, z.width_percent, z.height_percent,
z.z_index, z.zone_type, z.fit_mode].join(':')).sort(),
].join('|');
const layoutChanged = layoutSig(layout) !== layoutSig(data.layout || null);
layout = data.layout || null;
saveLayoutCache(layout);
playerTimezone = data.timezone || null; // #74/#75: effective tz for schedule eval
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
if (newFp === oldFp && playlist.length > 0 && !wallChanged && !layoutChanged) {
console.log('Playlist unchanged');
// In-place duration refresh: a duration-only edit keeps the structural fingerprint identical,
// so patch duration_sec onto the live items here. The group schedule tick re-anchors on the
@ -1974,6 +2016,18 @@
if (stillThereIdx !== -1) {
currentIndex = stillThereIdx;
isPlaying = true;
// ...unless it is a WIDGET whose content was edited. Identity is content/widget id, and
// editing a widget does not change its id, so an edited widget "survives" and the
// re-render is skipped — which is exactly why an edit never reached the screen. The new
// revision sat in the playlist unused, because a solo widget deliberately never
// re-renders on a timer (that would reset a directory board's scroll). Re-render through
// the buffered swap, which is flash-free by design, so this costs nothing visually.
const oldItem = oldPlaylist[oldAnchorIdx];
const newItem = playlist[stillThereIdx];
if (newItem && newItem.widget_id && oldItem && (oldItem.widget_rev || 0) !== (newItem.widget_rev || 0)) {
console.log('Widget edited - re-rendering in place');
renderContent(newItem);
}
return;
}
}
@ -1998,10 +2052,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;
}
@ -2089,6 +2163,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;
@ -2381,7 +2456,7 @@
discardPendingSwap();
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
iframe.src = `${config.serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`;
// Positioned + sized by the `#playerContainer > iframe` CSS rule. Hidden while it
// loads so its black background never shows over the outgoing content.
iframe.style.background = '#000';
@ -2932,7 +3007,7 @@
if (!isFollower) advanceTimer = setTimeout(nextItem, (item.duration_sec || 10) * 1000);
} else if (item.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
iframe.src = `${serverUrl}/api/widgets/${item.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${item.widget_rev||0}`;
iframe.style.cssText = 'width:100%;height:100%;border:none;background:#000';
iframe.allow = 'autoplay; fullscreen';
// Sandbox into a unique origin so widget scripts can't read window.parent
@ -3046,7 +3121,7 @@
// Android player, which keys off the assignment's widget_type.
if (a.widget_id) {
const iframe = document.createElement('iframe');
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}`;
iframe.src = `${config.serverUrl}/api/widgets/${a.widget_id}/render?device=${encodeURIComponent(config.deviceId||'')}&rev=${a.widget_rev||0}`;
// Sandbox into a unique origin so widget scripts can't read window.parent
// state (localStorage / JWT). allow-scripts keeps inline widget code running.
iframe.setAttribute('sandbox', 'allow-scripts');
@ -3064,7 +3139,16 @@
video.loop = !multi; // single-item zone loops; multi advances on end
video.playsInline = true;
video.style.cssText = `width:100%;height:100%;object-fit:${zone.fit_mode || 'cover'}`;
if (multi) video.onended = advance;
if (multi) {
video.onended = advance;
// A zone video advanced ONLY on `ended`, with no error handler and — alone among the zone
// branches — no timer either. A 404, an unreachable remote_url, an undecodable clip, or an
// `ended` that simply never fires left that region black for days while the other zones
// kept rotating: the screen looks half broken and nothing self-heals. The fullscreen web
// path and Tizen's ZoneRenderer both already carry these two guards.
video.onerror = advance;
zoneTimers[zone.id] = setTimeout(advance, dur + 5000);
}
div.appendChild(video);
} else {
const img = document.createElement('img');
@ -3255,8 +3339,24 @@
// ==================== UI Helpers ====================
function showStatus(msg) {
document.getElementById('statusOverlay').style.display = 'flex';
document.getElementById('statusText').textContent = msg;
const overlay = document.getElementById('statusOverlay');
if (!overlay) return;
overlay.style.display = 'flex';
// #statusText can be GONE: the suspended-account branch replaces the whole overlay with its
// own markup, which does not contain it. Reading .textContent off null then threw a TypeError
// out of every later showStatus call — the player reported itself "crashed" on each refresh
// beat, and worse, showNothingScheduled() throws BEFORE arming its 30s re-check, so a screen
// whose dayparts had all closed was stranded on the stale suspended card with no retry.
// Rebuild the element rather than bail, so the message the caller wanted is actually shown.
let text = document.getElementById('statusText');
if (!text) {
overlay.innerHTML = '';
text = document.createElement('p');
text.id = 'statusText';
text.style.cssText = 'color:#94a3b8;font-size:20px;font-family:sans-serif;text-align:center';
overlay.appendChild(text);
}
text.textContent = msg;
}
function hideStatus() {

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'rd-player-v17';
const CACHE_NAME = 'rd-player-v18';
// Install: skip waiting to activate immediately
self.addEventListener('install', (event) => {
@ -25,6 +25,31 @@ self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change
// without the rev changing. This is what lets a widget keep rendering when the network is gone —
// previously the server sent no-store for every render, so widgets were the one thing the
// player's offline cache could never hold, and a display that lost its uplink lost them.
// ignoreSearch is deliberately NOT used here: the query string carries the rev, and ignoring it
// would match a different revision's entry, which is the staleness we are trying to remove.
if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) {
event.respondWith(
caches.match(event.request).then(cached => {
if (cached) return cached;
return fetch(event.request).then(response => {
if (response.ok && response.type !== 'opaque') {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
}
return response;
}).catch(() => new Response(
'<!DOCTYPE html><body style="margin:0;background:#000"></body>',
{ status: 200, headers: { 'Content-Type': 'text/html' } }
));
})
);
return;
}
// Player page and static assets: network-first, fall back to cache
if (url.pathname.startsWith('/player') || url.pathname === '/socket.io/socket.io.js') {
event.respondWith(

View file

@ -140,17 +140,27 @@ router.delete('/:id', requireGroupWrite, (req, res) => {
let converted = 0;
if (groupSchedules.length > 0 && members.length > 0) {
// workspace_id MUST be carried over. It is nullable with no default, so omitting it landed
// every converted schedule with workspace_id = NULL — and a null workspace does not merely
// look untidy, it makes the row unreachable in three directions at once:
// - the schedule list and the all-screens calendar filter on workspace_id: invisible
// - PUT and DELETE refuse a row with no workspace (403): undeletable
// - services/scheduler.js has NO workspace filter: it keeps firing every 60 seconds
// i.e. "I deleted the group but the screens still switch at 9am and there is nothing in the
// calendar to remove". The only way out was direct database access.
const insert = db.prepare(`
INSERT INTO schedules (id, user_id, device_id, group_id, zone_id, content_id,
INSERT INTO schedules (id, user_id, workspace_id, device_id, group_id, zone_id, content_id,
widget_id, layout_id, playlist_id, title, start_time, end_time, timezone,
recurrence, recurrence_end, priority, enabled, color, created_at, updated_at)
VALUES (?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const schedule of groupSchedules) {
for (const member of members) {
insert.run(
uuidv4(), schedule.user_id, member.device_id,
// Prefer the schedule's own workspace, falling back to the group's, so a legacy
// group schedule predating workspace_id still converts into a reachable row.
uuidv4(), schedule.user_id, schedule.workspace_id || req.group.workspace_id, member.device_id,
schedule.zone_id, schedule.content_id, schedule.widget_id,
schedule.layout_id, schedule.playlist_id, schedule.title,
schedule.start_time, schedule.end_time, schedule.timezone,

View file

@ -127,17 +127,55 @@ router.put('/:id', (req, res) => {
// delete/add loop. Reuse each zone's id when supplied so device->zone
// assignments survive an edit (a fresh uuid per save would orphan them).
if (Array.isArray(zones)) {
db.prepare('DELETE FROM layout_zones WHERE layout_id = ?').run(req.params.id);
const stmt = db.prepare(`
// DIFF, never delete-and-replace.
//
// The previous version deleted every zone and re-inserted the same ids, on the stated
// assumption that reusing an id preserved whatever pointed at it. It does not: SQLite runs
// the referential actions on the DELETE, and re-inserting the same primary key afterwards
// does not resurrect what they destroyed. Two things point at these rows:
//
// playlist_items.zone_id ON DELETE SET NULL -> every multi-zone playlist in the
// workspace silently fell back to fullscreen
// schedules.zone_id ON DELETE CASCADE -> every zone-bound schedule was DELETED,
// permanently, no warning and no undo
//
// So nudging one zone by a pixel and pressing Save destroyed unrelated tenant data and
// returned 200 OK. Updating in place touches no foreign key at all; only genuinely removed
// zones are deleted, which is the one case where those cascades are the intended behaviour.
const existingIds = db.prepare('SELECT id FROM layout_zones WHERE layout_id = ?')
.all(req.params.id).map(r => r.id);
const existingSet = new Set(existingIds);
const keptIds = new Set();
const insertZone = db.prepare(`
INSERT INTO layout_zones (id, layout_id, name, x_percent, y_percent, width_percent, height_percent, z_index, zone_type, fit_mode, background_color, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const updateZone = db.prepare(`
UPDATE layout_zones SET name = ?, x_percent = ?, y_percent = ?, width_percent = ?,
height_percent = ?, z_index = ?, zone_type = ?, fit_mode = ?, background_color = ?, sort_order = ?
WHERE id = ? AND layout_id = ?
`);
zones.forEach((z, i) => {
stmt.run(z.id || uuidv4(), req.params.id, z.name || `Zone ${i + 1}`,
const zid = z.id || uuidv4();
const vals = [
z.name || `Zone ${i + 1}`,
z.x_percent || 0, z.y_percent || 0, z.width_percent || 100, z.height_percent || 100,
z.z_index || 0, z.zone_type || 'content', z.fit_mode || 'contain',
z.background_color || '#000000', i);
z.background_color || '#000000', i,
];
if (existingSet.has(zid)) updateZone.run(...vals, zid, req.params.id);
else insertZone.run(zid, req.params.id, ...vals);
keptIds.add(zid);
});
// Only the zones the editor actually removed.
for (const zid of existingIds) {
if (!keptIds.has(zid)) {
db.prepare('DELETE FROM layout_zones WHERE id = ? AND layout_id = ?').run(zid, req.params.id);
}
}
db.prepare('UPDATE layouts SET updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(req.params.id);
}
});
@ -145,6 +183,22 @@ router.put('/:id', (req, res) => {
const updated = db.prepare('SELECT * FROM layouts WHERE id = ?').get(req.params.id);
updated.zones = db.prepare('SELECT * FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(req.params.id);
// Push to the displays using this layout. Editing a layout used to notify nothing at all, so a
// zone change waited for the next heartbeat refresh at best — and on Android it did not apply
// even then, because the rebuild was keyed on the layout ID, which does not change when you edit
// a layout in place. Reported on #234 as "I added 4 zones and they dont appear on the screen".
// The player-side fix makes the rebuild happen; this makes it happen promptly.
try {
const io = req.app.get('io');
if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
for (const d of db.prepare('SELECT id FROM devices WHERE layout_id = ?').all(req.params.id)) {
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
}
}
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
res.json(updated);
});

View file

@ -6,6 +6,24 @@ const { db } = require('../db/database');
// full-trust (a `web` overlay renders an arbitrary page in the player), so — like the
// group command route — it requires the 'full' token scope. No-op for JWT sessions.
const { requireScope } = require('../middleware/apiToken');
const { accessContext } = require('../lib/tenancy');
// requireScope('full') gates API TOKENS and is a deliberate pass-through for JWT sessions
// (middleware/apiToken.js: `if (!req.viaToken) return next()`). It was the ONLY guard on these
// routes, so a dashboard session carried no write check at all here — every sibling
// fleet-affecting route pairs the scope check with a role check (see device-groups.js, where
// POST /:id/command is `requireScope('full'), requireGroupWrite`). This restores that pairing:
// a read-only member is refused, exactly as they are on every other device mutation.
function requireFleetWrite(req, res, next) {
if (!req.workspaceId) return res.status(403).json({ error: 'No workspace context' });
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.workspaceId);
const ctx = ws && accessContext(req.user.id, req.user.role, ws);
if (!ctx) return res.status(403).json({ error: 'Access denied' });
if (!ctx.actingAs && ctx.workspaceRole === 'workspace_viewer') {
return res.status(403).json({ error: 'Read-only access' });
}
next();
}
// Reuse the existing 6-hex color contract (#RRGGBB). Overlay transparency is expressed
// via the separate `opacity` field, so no alpha channel is accepted here.
@ -82,7 +100,7 @@ function summarize(results) {
}
// POST /api/pip — show an overlay on a device or group.
router.post('/', requireScope('full'), (req, res) => {
router.post('/', requireScope('full'), requireFleetWrite, (req, res) => {
const b = req.body || {};
if (!b.device_id) return res.status(400).json({ error: 'device_id required (device or group id)' });
@ -157,7 +175,7 @@ function handleClear(req, res) {
res.json({ success: true, target: targets.kind, ...summary });
}
router.post('/clear', requireScope('full'), handleClear);
router.delete('/', requireScope('full'), handleClear);
router.post('/clear', requireScope('full'), requireFleetWrite, handleClear);
router.delete('/', requireScope('full'), requireFleetWrite, handleClear);
module.exports = router;

View file

@ -70,7 +70,7 @@ function buildSnapshotItems(playlistId) {
COALESCE(c.filename, w.name) as filename, c.mime_type, c.filepath, c.file_size,
c.duration_sec as content_duration, c.remote_url, c.unstable_connection,
c.captions_enabled, c.captions_lang, c.subtitle_url, c.subtitle_lang,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
@ -216,7 +216,7 @@ router.get('/:id', requirePlaylistRead, (req, res) => {
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
@ -277,7 +277,7 @@ router.post('/:id/publish', requirePlaylistWrite, (req, res) => {
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
@ -327,7 +327,7 @@ router.post('/:id/discard', requirePlaylistWrite, (req, res) => {
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
@ -339,7 +339,28 @@ router.post('/:id/discard', requirePlaylistWrite, (req, res) => {
// Delete playlist
router.delete('/:id', requirePlaylistWrite, (req, res) => {
// Which screens are about to lose their playlist — read BEFORE the delete, because
// devices.playlist_id is ON DELETE SET NULL and the association is gone immediately after.
const affected = db.prepare('SELECT id FROM devices WHERE playlist_id = ?').all(req.params.id);
db.prepare('DELETE FROM playlists WHERE id = ?').run(req.params.id);
// Tell them. The database detaches correctly, but nothing was emitted — so a screen kept showing
// the deleted playlist until it happened to reconnect or was restarted. You delete a playlist to
// take content off the wall; the wall carried on regardless. Every sibling mutation here already
// pushes (publish, assign), and DELETE /devices/:id/playlist was given a push for exactly this
// reason: "so the screen stops, rather than leaving the old content up".
try {
const io = req.app.get('io');
if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
for (const d of affected) {
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
}
}
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
res.json({ success: true });
});
@ -352,7 +373,7 @@ router.get('/:id/items', requirePlaylistRead, (req, res) => {
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
@ -471,7 +492,7 @@ router.post('/:id/items', requirePlaylistWrite, async (req, res) => {
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
@ -555,7 +576,7 @@ router.put('/:id/items/:itemId', requirePlaylistWrite, (req, res) => {
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
@ -603,7 +624,7 @@ router.post('/:id/items/:itemId/duplicate', requirePlaylistWrite, (req, res) =>
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id
@ -632,7 +653,7 @@ router.post('/:id/items/reorder', requirePlaylistWrite, (req, res) => {
COALESCE(c.filename, w.name) as filename,
c.mime_type, c.filepath, c.thumbnail_path,
c.duration_sec as content_duration, c.file_size, c.remote_url,
w.name as widget_name, w.widget_type, w.config as widget_config
w.name as widget_name, w.widget_type, w.config as widget_config, w.updated_at as widget_rev
FROM playlist_items pi
LEFT JOIN content c ON pi.content_id = c.id
LEFT JOIN widgets w ON pi.widget_id = w.id

View file

@ -94,6 +94,19 @@ function workspaceAccess(req, workspaceId) {
// / layout / playlist refs (where workspace_id IS NULL is the platform-template
// path and is always allowed) and for devices / device_groups (where
// workspace_id is required - those tables never carry template rows).
// layout_zones has no workspace_id of its own — a zone belongs to a layout, and the layout carries
// the workspace. zone_id was the one polymorphic reference missing from the ownership checks, so a
// schedule could be pointed at a zone in someone else's workspace.
function checkZoneInWorkspace(zoneId, workspaceId) {
const row = db.prepare(
'SELECT l.workspace_id FROM layout_zones z JOIN layouts l ON l.id = z.layout_id WHERE z.id = ?'
).get(zoneId);
if (!row) return { status: 404, error: 'zone not found' };
if (row.workspace_id === workspaceId) return null;
if (row.workspace_id == null) return null; // platform-template layout
return { status: 403, error: 'zone is not in this workspace' };
}
function checkRefInWorkspace(table, id, workspaceId, opts = { allowNullWorkspace: false }) {
const row = db.prepare(`SELECT workspace_id FROM ${table} WHERE id = ?`).get(id);
if (!row) return { status: 404, error: `${table.replace(/_/g, ' ').slice(0, -1)} not found` };
@ -243,6 +256,33 @@ router.post('/', (req, res) => {
const err = checkRefInWorkspace(table, id, targetWorkspaceId, { allowNullWorkspace: allowNull });
if (err) return res.status(err.status).json({ error: err.error });
}
if (zone_id) {
const zErr = checkZoneInWorkspace(zone_id, targetWorkspaceId);
if (zErr) return res.status(zErr.status).json({ error: zErr.error });
}
// A content-only schedule is turned into a playlist holding that one item.
//
// The dialog offers "Content (single item, optional)" and the value was stored faithfully — but
// the engine only ever acts on layout_id and playlist_id, so content_id was read by nothing at
// all. The schedule fired, changed nothing, and the calendar drew a block labelled with the
// filename as confirmation that it would. Rather than add a third override path through the
// engine and every player, give the item the same shape everything already understands: its own
// playlist. That reuses the whole published/assign/push pipeline as-is.
let effectivePlaylistId = playlist_id || null;
if (!effectivePlaylistId && content_id) {
const c = db.prepare('SELECT filename FROM content WHERE id = ?').get(content_id);
const genId = uuidv4();
db.prepare('INSERT INTO playlists (id, name, workspace_id, user_id, status) VALUES (?, ?, ?, ?, ?)')
.run(genId, `Scheduled: ${(c && c.filename) || 'item'}`, targetWorkspaceId, req.user.id, 'published');
db.prepare('INSERT INTO playlist_items (playlist_id, content_id, sort_order, duration_sec) VALUES (?, ?, 0, 10)')
.run(genId, content_id);
// Publish through the shared path rather than hand-rolling the snapshot: players read
// denormalized fields (filename, mime_type, filepath, remote_url, schedules...) out of
// published_snapshot, and duplicating that shape here would rot the moment it changes.
require('./playlists').publishPlaylist(genId);
effectivePlaylistId = genId;
}
const id = uuidv4();
db.prepare(`
@ -250,7 +290,7 @@ router.post('/', (req, res) => {
start_time, end_time, timezone, recurrence, recurrence_end, priority, color)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, req.user.id, targetWorkspaceId, device_id || null, group_id || null, zone_id || null, content_id || null, widget_id || null,
layout_id || null, playlist_id || null, title || '', start_time, end_time, timezone || targetTz || 'UTC',
layout_id || null, effectivePlaylistId, title || '', start_time, end_time, timezone || targetTz || 'UTC',
recurrence || null, recurrence_end || null, priority || 0, color || '#3B82F6');
const schedule = db.prepare('SELECT * FROM schedules WHERE id = ?').get(id);
@ -341,33 +381,58 @@ function expandSchedule(schedule, rangeStart, rangeEnd) {
}
const recEnd = schedule.recurrence_end ? new Date(schedule.recurrence_end) : rangeEnd;
let current = new Date(start);
let count = 0;
const maxIterations = 366;
while (current <= rangeEnd && current <= recEnd && count < maxIterations) {
const instanceEnd = new Date(current.getTime() + durationMs);
// Walk DAY BY DAY across the visible range and draw every day the rule actually fires.
//
// The old loop stepped by the recurrence unit from the schedule's original start, which got both
// of the common presets wrong:
// - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
// FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule matched only its start day — one event a week, or
// none at all if it had been created on a weekend.
// - Starting from the original start with a 366-iteration cap meant a schedule begun more than
// a year ago never reached the current week, so it drew nothing whatsoever.
// The engine meanwhile evaluates day-of-week directly, so it ran Mon-Fri regardless. The calendar
// is the operator's only view of what is scheduled, and it disagreed with reality in both
// directions. Iterating the range instead means the drawing follows the same rule the engine
// applies, and the cost is bounded by the window being displayed rather than by history.
const dayMs = 24 * 60 * 60 * 1000;
const startTimeOfDay = { h: start.getHours(), m: start.getMinutes(), s: start.getSeconds() };
if (current >= rangeStart || instanceEnd >= rangeStart) {
const dayOfWeek = current.getDay();
const matchesDay = !rule.byDay || rule.byDay.includes(dayOfWeek);
// First candidate day: the later of the schedule's start and the window's start.
let cursor = new Date(Math.max(start.getTime(), rangeStart.getTime()));
cursor.setHours(startTimeOfDay.h, startTimeOfDay.m, startTimeOfDay.s, 0);
if (cursor.getTime() + durationMs < rangeStart.getTime()) cursor = new Date(cursor.getTime() + dayMs);
if (matchesDay) {
events.push({
...schedule,
instance_start: current.toISOString(),
instance_end: instanceEnd.toISOString()
});
}
}
const lastDay = new Date(Math.min(rangeEnd.getTime(), recEnd.getTime()));
const interval = Math.max(1, rule.interval || 1);
while (cursor <= lastDay) {
const instanceEnd = new Date(cursor.getTime() + durationMs);
let fires = false;
switch (rule.freq) {
case 'DAILY': current.setDate(current.getDate() + (rule.interval || 1)); break;
case 'WEEKLY': current.setDate(current.getDate() + 7 * (rule.interval || 1)); break;
case 'MONTHLY': current.setMonth(current.getMonth() + (rule.interval || 1)); break;
default: current.setDate(current.getDate() + 1);
case 'DAILY':
// Honour the interval by counting whole days from the original start.
fires = Math.floor((cursor - start) / dayMs) % interval === 0;
break;
case 'WEEKLY':
// byDay is what makes Mon-Fri work. Without it, weekly means "the start's weekday".
fires = rule.byDay ? rule.byDay.includes(cursor.getDay()) : cursor.getDay() === start.getDay();
break;
case 'MONTHLY':
fires = cursor.getDate() === start.getDate();
break;
default:
fires = true;
}
count++;
if (fires && (cursor >= rangeStart || instanceEnd >= rangeStart)) {
events.push({
...schedule,
instance_start: cursor.toISOString(),
instance_end: instanceEnd.toISOString(),
});
}
cursor = new Date(cursor.getTime() + dayMs);
cursor.setHours(startTimeOfDay.h, startTimeOfDay.m, startTimeOfDay.s, 0); // DST-safe re-anchor
}
return events;
@ -393,3 +458,6 @@ function parseRRule(rrule) {
}
module.exports = router;
// Exported for testing, the same way playlists.js exports publishPlaylist. The calendar's
// correctness is arithmetic and deserves to be checked without standing up a server.
module.exports.expandSchedule = expandSchedule;

View file

@ -157,6 +157,30 @@ router.put('/:id', (req, res) => {
if (name) db.prepare('UPDATE widgets SET name = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(name, req.params.id);
if (config) db.prepare('UPDATE widgets SET config = ?, updated_at = strftime(\'%s\',\'now\') WHERE id = ?').run(JSON.stringify(config), req.params.id);
// Push the change to any display currently showing this widget. Editing a widget used to
// notify nothing at all: the render endpoint serves live config, but a player that already has
// the widget on screen keeps its WebView (deliberately — re-navigating a widget every duration
// is a visible flash and destroys widget state). With no push and no change to the URL, an edit
// reached the screen only when the app was restarted. Reported on #234: "I changed the text and
// the new text did not appear on the screen. I had to close the app and then open again."
//
// The push is what makes it prompt; the rev in the payload is what makes the player reload.
try {
const io = req.app.get('io');
if (io) {
const { buildPlaylistPayload } = require('../ws/deviceSocket');
const commandQueue = require('../lib/command-queue');
const affected = db.prepare(`
SELECT DISTINCT d.id FROM devices d
JOIN playlist_items pi ON pi.playlist_id = d.playlist_id
WHERE pi.widget_id = ?
`).all(req.params.id);
for (const d of affected) {
commandQueue.queueOrEmitPlaylistUpdate(io.of('/device'), d.id, buildPlaylistPayload);
}
}
} catch (e) { /* best-effort; the heartbeat refresh still picks it up */ }
res.json(db.prepare('SELECT * FROM widgets WHERE id = ?').get(req.params.id));
});
@ -196,9 +220,20 @@ router.get('/:id/render', (req, res) => {
// widgets render blank in the web player. Drop it here; the sandbox - not
// X-Frame-Options - is what isolates the widget (it can't read the dashboard JWT).
res.removeHeader('X-Frame-Options');
// Never cache the render: widget data (clock/weather/rss/directory) changes, and
// a cached copy from before the X-Frame-Options change would keep showing blank.
res.setHeader('Cache-Control', 'no-store');
// Caching is keyed on whether the caller pinned a revision.
//
// A URL carrying ?rev=<widget.updated_at> is content-addressed: those exact bytes cannot change
// without the rev changing, so it is safe to cache hard — and it NEEDS to be, because a player
// that loses its network must still be able to render its widgets. Offline resilience is the
// point of the player's cache, and no-store made widgets the one thing it could never keep.
//
// A URL with no rev is the old shape and stays uncacheable: nothing distinguishes one render
// from the next, so a cached copy could serve content the operator has already changed.
if (req.query.rev) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
} else {
res.setHeader('Cache-Control', 'no-store');
}
res.setHeader('Content-Type', 'text/html');
res.send(renderWidgetHtml(widget.widget_type, config));
});
@ -399,12 +434,90 @@ load(); setInterval(load, 300000);
}
function renderText(c) {
// Designer preview uses fontSize/10 vw, but older published HTML used fontSize*10.8 px.
// Convert any px-based font sizes to vw so they scale to any viewport: px / 108 = vw
let html = c.html || '<p style="color:white;padding:20px">Empty text widget</p>';
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
});
// LEGACY DESIGNER RESCUE — deliberately narrow.
//
// The Content Designer used to publish absolute font sizes as fontSize*10.8 px; today it emits
// cqw (see designer.js). Converting px/108 back to vw restores the author's intended size and
// makes those old widgets scale to any screen.
//
// It must NOT touch hand-authored HTML. This regex used to run over EVERY text widget, so
// someone writing `font-size:16px` in the Text/HTML editor got 0.15vw — 2.8px on a 1080p
// screen, and smaller still on anything narrower. Their text was not clipped or hidden; it was
// rendered too small to read, in the one widget whose whole purpose is hand-written HTML.
//
// Designer output is identified by its absolutely-positioned elements, the same signal the
// dashboard uses to decide whether a text widget can be reopened in the designer. Hand-written
// markup keeps its px exactly as typed.
const isDesignerAuthored = /position:\s*absolute;\s*left:/.test(html);
if (isDesignerAuthored) {
html = html.replace(/font-size:\s*([\d.]+)px/g, (match, px) => {
return `font-size:${(parseFloat(px) / 108).toFixed(2)}vw`;
});
}
// What to do when the text is taller than the screen. It used to be clipped in silence: the
// document was overflow:hidden with no scrollbar and nothing to scroll it, so on a display
// shorter than the content the bottom simply vanished — reported as "text goes to bottom and
// disappears. It dont fit."
//
// fit (default) shrink until it fits. A no-op when the content already fits, so this
// rescues widgets that are currently losing text without altering ones that are fine.
// scroll pan through it on a loop, with a pause at each end. For content that is genuinely
// longer than a screen, where shrinking it would make it unreadable.
// clip the old behaviour, kept because a designer-positioned layout may deliberately run
// past the edge and must not be rescaled underneath the author.
const overflowMode = ['fit', 'scroll', 'clip'].includes(c.overflow) ? c.overflow : 'fit';
// Runs inside the sandboxed iframe (allow-scripts, null origin). Measures after layout, after
// web fonts settle, and on resize — a rotation or a resized zone changes the answer, and fonts
// loading late is the classic cause of a fit that was computed against the wrong height.
const fitScript = overflowMode === 'clip' ? '' : `<script>
(function () {
var mode = ${JSON.stringify(overflowMode)};
var wrap = document.getElementById('st-wrap');
if (!wrap) return;
var anim = null;
function apply() {
// Reset before measuring, or we measure the previous transform's result.
wrap.style.transform = '';
if (anim) { anim.cancel(); anim = null; }
var avail = document.documentElement.clientHeight;
var need = wrap.scrollHeight;
if (!avail || !need || need <= avail + 1) return; // already fits: leave it alone
if (mode === 'fit') {
var k = avail / need;
wrap.style.transformOrigin = 'top center';
wrap.style.transform = 'scale(' + k + ')';
return;
}
// scroll: hold, pan the overflow, hold, return. Speed is distance-based so a long
// document is not unreadably fast and a short one is not tediously slow.
var over = need - avail;
var panMs = Math.max(4000, (over / 40) * 1000);
var holdMs = 2000;
var total = panMs * 2 + holdMs * 2;
var p1 = holdMs / total, p2 = (holdMs + panMs) / total, p3 = (holdMs * 2 + panMs) / total;
anim = wrap.animate(
[
{ transform: 'translateY(0)', offset: 0 },
{ transform: 'translateY(0)', offset: p1 },
{ transform: 'translateY(' + (-over) + 'px)', offset: p2 },
{ transform: 'translateY(' + (-over) + 'px)', offset: p3 },
{ transform: 'translateY(0)', offset: 1 },
],
{ duration: total, iterations: Infinity, easing: 'linear' }
);
}
addEventListener('resize', apply);
if (document.fonts && document.fonts.ready) document.fonts.ready.then(apply).catch(function(){});
// Late images change the height too; rAF lets first layout finish before measuring.
addEventListener('load', function () { requestAnimationFrame(apply); });
requestAnimationFrame(apply);
})();
</script>`;
// Security: c.html / c.css are intentionally raw user-authored content, but the
// render is public and same-origin with the dashboard - injected <script> could
// otherwise read the dashboard's localStorage JWT. Render the user content inside
@ -413,8 +526,11 @@ function renderText(c) {
const inner = `<!DOCTYPE html><html><head><style>
* { margin:0; padding:0; box-sizing:border-box; }
html, body { width:100vw; height:100vh; overflow:hidden; }
/* The wrapper is what gets scaled or panned. It must be allowed to exceed the viewport,
otherwise there is nothing to measure and nothing to move. */
#st-wrap { width:100%; min-height:100%; will-change:transform; }
${c.css || ''}
</style></head><body>${html}</body></html>`;
</style></head><body><div id="st-wrap">${html}</div>${fitScript}</body></html>`;
return `<!DOCTYPE html><html><head><style>
* { margin:0; padding:0; }
html, body { width:100vw; height:100vh; overflow:hidden; background:${safeCss(c.background, 'transparent')}; }

View file

@ -144,6 +144,18 @@ function isScheduleActiveNow(schedule, now, tz) {
const rule = parseSimpleRRule(schedule.recurrence);
if (!rule) return nowStamp >= startStamp && nowStamp <= endStamp;
// The DATE window. A recurring schedule was previously compared on weekday and HH:MM alone, with
// the date component dropped entirely — so it was live before its start date and, more visibly,
// carried on forever after its end date. A campaign set to finish on the 1st was still switching
// screens weeks later, while the calendar (which does read recurrence_end) showed it as stopped.
// The end date is offered on the form; it has to mean something.
const nowDate = nowStamp.slice(0, 10);
if (nowDate < startStamp.slice(0, 10)) return false; // has not begun yet
if (schedule.recurrence_end) {
// Inclusive: an end date of the 5th means the 5th still runs, to its normal end time.
if (nowDate > String(schedule.recurrence_end).slice(0, 10)) return false;
}
// Day-of-week in the device's local zone.
if (rule.byDay && !rule.byDay.includes(L.dow)) return false;
@ -174,3 +186,6 @@ function pushPlaylistToDevice(deviceId, deviceNs) {
}
module.exports = { startScheduler, pushPlaylistToDevice, rebootDue };
// Exported for testing: whether a schedule is live right now is the single decision this service
// exists to make, and it should be checkable without a ticking timer.
module.exports.isScheduleActiveNow = isScheduleActiveNow;

View file

@ -0,0 +1,82 @@
'use strict';
// Every web and BrightSign player nulled seventeen of its own device columns every five minutes.
//
// The browser player's refresh-register sends `device_info: {}` on a 300-second timer — it has
// nothing new to report, it just wants a fresh playlist. But `{}` is truthy, and applyDeviceInfo is
// a blind full-row overwrite with no per-field presence check, so it bound `undefined` for every
// column. better-sqlite3 stores undefined as NULL rather than throwing, so the write succeeded and
// the row was quietly emptied: version, resolution, render size, OTA state, tier, the capability
// flags and the volume/brightness columns.
//
// Android was unaffected because it always sends the full object — so this only ever degraded the
// client family that has no other way to be inspected. Fleet view, resolution diagnostics and any
// version-based logic read blank for them.
//
// The surrounding code already anticipates this shape: recordReconnect and persistIdentity are
// gated behind `if (!isPlaylistRefresh)`. This one call was not.
//
// The invariant: an empty device_info means "nothing new", never "forget what you know".
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-devinfo-'));
process.env.DATA_DIR = tmp;
const { db } = require('../db/database');
const WS = 'ws-di', O = 'o-di', U = 'u-di', DEV = 'dev-di';
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash) VALUES (?,?, 'x')").run(U, 'di@t.local');
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,android_version,app_version,screen_width,screen_height,created_at,updated_at)
VALUES (?, 'Web Screen', ?, 'Web/Chrome', '1.1.0-web', 1920, 1080, strftime('%s','now'), strftime('%s','now'))`).run(DEV, WS);
const row = () => db.prepare('SELECT android_version, app_version, screen_width, screen_height FROM devices WHERE id = ?').get(DEV);
// The guard as the socket handler applies it.
const shouldApply = (deviceInfo) => !!(deviceInfo && Object.keys(deviceInfo).length > 0);
test('THE BUG: an empty device_info must not be treated as new information', () => {
// `{}` is truthy — that is the whole trap.
assert.equal(!!{}, true, 'this is why the old `if (device_info)` let it through');
assert.equal(shouldApply({}), false, 'but it carries nothing, so nothing should be written');
});
test('undefined really does become NULL rather than throwing, so the write did succeed', () => {
// Pinning the driver behaviour the bug depended on: had it thrown, this would have been loud
// instead of a silent five-minutely wipe.
const before = row();
assert.equal(before.app_version, '1.1.0-web');
db.prepare('UPDATE devices SET app_version = ? WHERE id = ?').run(undefined, DEV);
assert.equal(row().app_version, null, 'silently nulled — no error, no warning');
db.prepare('UPDATE devices SET app_version = ? WHERE id = ?').run('1.1.0-web', DEV);
});
test('a refresh-register leaves what we already know intact', () => {
const before = row();
if (shouldApply({})) throw new Error('guard failed'); // the handler would skip the write
const after = row();
assert.deepEqual(after, before, 'version and resolution must survive a refresh beat');
});
test('a real device_info is still applied', () => {
const info = { app_version: '1.9.28', screen_width: 3840 };
assert.equal(shouldApply(info), true);
db.prepare('UPDATE devices SET app_version = ?, screen_width = ? WHERE id = ?')
.run(info.app_version, info.screen_width, DEV);
const after = row();
assert.equal(after.app_version, '1.9.28');
assert.equal(after.screen_width, 3840);
});
test('a missing device_info is skipped too', () => {
assert.equal(shouldApply(undefined), false);
assert.equal(shouldApply(null), false);
});
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -0,0 +1,96 @@
'use strict';
// Per-device settings are saved against the hardware FINGERPRINT so a panel that is deleted and
// paired again comes back configured — its name, orientation, playlist and blocked flag restored
// without anyone visiting it. That is deliberate and useful.
//
// A fingerprint is hardware-derived, so the same physical panel presents the same one no matter
// whose account it is paired into. applyToDevice looked the snapshot up on fingerprint alone with
// no workspace comparison, and its per-field guards only check that the referenced row still
// EXISTS, never who it belongs to:
//
// if (s.playlist_id && db.prepare('SELECT 1 FROM playlists WHERE id = ?').get(s.playlist_id))
//
// So a screen removed from one workspace and paired into another inherited the first workspace's
// playlist and started displaying its content. `blocked` crossed the same way, giving a device that
// arrives blocked for no reason the new owner can see. The manual restore route already compares
// workspaces before calling this, so the automatic re-pair path was the one place it was missing.
//
// The invariant: a saved snapshot only ever applies inside the workspace it was taken in.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-ws-confine-'));
process.env.DATA_DIR = tmp;
const { db } = require('../db/database');
const deviceSettings = require('../lib/device-settings');
function seedWorkspace(tag) {
const u = 'u-' + tag, o = 'o-' + tag, ws = 'ws-' + tag, pl = 'pl-' + tag;
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash) VALUES (?,?, 'x')").run(u, tag + '@t.local');
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(o, 'org ' + tag, u);
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(ws, o, 'ws ' + tag);
db.prepare('INSERT OR IGNORE INTO playlists (id,name,workspace_id,user_id) VALUES (?,?,?,?)').run(pl, 'PL ' + tag, ws, u);
return { u, ws, pl };
}
const A = seedWorkspace('alpha');
const B = seedWorkspace('bravo');
const FP = 'hardware-fingerprint-shared';
function makeDevice(id, ws) {
db.prepare(`INSERT OR REPLACE INTO devices (id,name,workspace_id,created_at,updated_at)
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(id, 'Screen', ws);
return id;
}
const deviceRow = (id) => db.prepare('SELECT * FROM devices WHERE id = ?').get(id);
// The panel lived in workspace A: named, assigned A's playlist, and blocked there.
db.prepare(`INSERT OR REPLACE INTO device_settings (fingerprint, workspace_id, device_name, playlist_id, blocked, last_seen)
VALUES (?,?, 'Lobby Screen', ?, 1, strftime('%s','now'))`).run(FP, A.ws, A.pl);
test('THE LEAK: a panel paired into another workspace does not inherit the first ones playlist', () => {
const dev = makeDevice('dev-in-B', B.ws);
deviceSettings.applyToDevice(dev, FP);
const d = deviceRow(dev);
assert.equal(d.playlist_id, null, "workspace B's screen must not be playing workspace A's content");
assert.equal(d.workspace_id, B.ws, 'and it must stay in its own workspace');
});
test('a block from another workspace does not follow the hardware either', () => {
const dev = makeDevice('dev-block-B', B.ws);
deviceSettings.applyToDevice(dev, FP);
assert.equal(deviceRow(dev).blocked, 0, 'arriving blocked with nothing to explain it is unactionable');
});
test('a mismatch is a quiet no-op, because re-pairing a second-hand panel is legitimate', () => {
// It must not throw or refuse the pairing — only decline to carry the old configuration.
const dev = makeDevice('dev-noop-B', B.ws);
assert.doesNotThrow(() => deviceSettings.applyToDevice(dev, FP));
assert.equal(deviceRow(dev).name, 'Screen', 'the name from the other workspace must not be applied');
});
test('AND THE POINT OF THE FEATURE: restore still works inside the owning workspace', () => {
// The whole reason this exists — a panel re-paired at home comes back configured.
const dev = makeDevice('dev-in-A', A.ws);
deviceSettings.applyToDevice(dev, FP);
const d = deviceRow(dev);
assert.equal(d.playlist_id, A.pl, 'its own playlist must be restored');
assert.equal(d.name, 'Lobby Screen', 'its own name must be restored');
assert.equal(d.blocked, 1, 'and a genuine block must still survive a re-pair');
});
test('a snapshot with no workspace recorded is still applied, so legacy rows keep working', () => {
db.prepare(`INSERT OR REPLACE INTO device_settings (fingerprint, workspace_id, device_name, blocked, last_seen)
VALUES ('legacy-fp', NULL, 'Legacy Name', 0, strftime('%s','now'))`).run();
const dev = makeDevice('dev-legacy', B.ws);
deviceSettings.applyToDevice(dev, 'legacy-fp');
assert.equal(deviceRow(dev).name, 'Legacy Name');
});
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -61,13 +61,24 @@ test('THE POINT: it is only finished when something is actually ON a screen', as
assert.equal(done.nextIndex, -1);
});
test('any of the three ways of assigning counts', async () => {
for (const d of [{ id: 'x', playlist_id: 'p' }, { id: 'x', default_content_id: 'c' }, { id: 'x', layout_id: 'l' }]) {
test('assigning a playlist or a layout counts', async () => {
for (const d of [{ id: 'x', playlist_id: 'p' }, { id: 'x', layout_id: 'l' }]) {
const s = GS.computeSteps({ devices: [d], content: [{ id: 'c' }], playlists: [{ id: 'p' }] });
assert.equal(s.complete, true, `${Object.keys(d).join(',')} should count as assigned`);
}
});
test('default_content_id does NOT count, because no player reads it', async () => {
// This test previously asserted the opposite, and it was wrong. Grep the whole tree and
// default_content appears only in this checklist, the device form, the settings snapshot, the
// schema and the devices route — never in a socket payload, in assemblePayload, or in any of the
// four players. Setting it changes nothing on the screen, so counting it told the operator
// "content assigned" while their display went on showing "waiting for content". A checklist that
// lies about the one thing it exists to confirm is worse than no checklist.
const s = GS.computeSteps({ devices: [{ id: 'x', default_content_id: 'c' }], content: [{ id: 'c' }], playlists: [{ id: 'p' }] });
assert.equal(s.complete, false, 'a screen with only default_content is not actually showing anything');
});
test('steps stay in dependency order — never sent somewhere unusable', async () => {
// Content before a screen exists is not wrong, but it cannot be PUT anywhere, so the next
// action must remain the screen.

View file

@ -0,0 +1,100 @@
'use strict';
// Deleting a device group converts its group schedules into per-device ones so the screens keep
// their programming. That INSERT omitted workspace_id, which is nullable with no default — so every
// converted row landed with workspace_id = NULL.
//
// A null workspace does not merely look untidy. It makes the row unreachable in three directions at
// once, and they compound into the worst possible combination:
//
// invisible — the schedule list and the all-screens calendar both filter on workspace_id
// undeletable — PUT and DELETE refuse a row with no workspace (403)
// still live — services/scheduler.js has NO workspace filter, so it keeps firing every 60s
//
// i.e. "I deleted the group but the screens still switch content at 9am, and there is nothing in
// the calendar to remove." The only way out was direct database access.
//
// The invariant: a schedule that survives a group deletion stays owned by a workspace, so it can be
// seen and removed by the person whose screens it controls.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-groupdel-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-group-delete';
const express = require('express');
const { db } = require('../db/database');
const { requireAuth, generateToken } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const O = 'o-gd', WS = 'ws-gd', U = 'u-gd', G = 'g-gd', DEV = 'dev-gd';
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'gd@t.local');
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(DEV, 'Screen', WS);
db.prepare('INSERT OR IGNORE INTO device_groups (id,name,workspace_id,user_id) VALUES (?,?,?,?)').run(G, 'Group', WS, U);
db.prepare('INSERT OR IGNORE INTO device_group_members (group_id,device_id) VALUES (?,?)').run(G, DEV);
db.prepare(`INSERT OR IGNORE INTO schedules (id,user_id,workspace_id,group_id,title,start_time,end_time,timezone,priority,enabled)
VALUES ('sg-1',?,?,?, 'Morning menu','09:00','17:00','UTC',1,1)`).run(U, WS, G);
const app = express();
app.use(express.json());
app.set('io', null);
app.use('/api/groups', requireAuth, resolveTenancy, require('../routes/device-groups'));
const server = app.listen(0);
const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
async function deleteGroup() {
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/groups/${G}`, {
method: 'DELETE', headers: { Authorization: `Bearer ${token}` },
});
return { status: res.status, body: await res.json().catch(() => null) };
}
test('THE BUG: a schedule that survives a group deletion keeps its workspace', async () => {
const { status, body } = await deleteGroup();
assert.equal(status, 200);
assert.ok(body.schedules_converted >= 1, 'the schedule should have been converted, not dropped');
const converted = db.prepare('SELECT * FROM schedules WHERE device_id = ? AND group_id IS NULL').all(DEV);
assert.equal(converted.length, 1);
assert.equal(converted[0].workspace_id, WS, 'a null workspace makes the row invisible AND undeletable AND live');
});
test('the converted schedule is therefore visible to the workspace it controls', () => {
// This is the query the schedule list and the calendar both use.
const visible = db.prepare('SELECT COUNT(*) n FROM schedules WHERE workspace_id = ? AND device_id = ?').get(WS, DEV).n;
assert.equal(visible, 1);
});
test('the programming itself is preserved, not just the ownership', () => {
const s = db.prepare('SELECT * FROM schedules WHERE device_id = ? AND group_id IS NULL').get(DEV);
assert.equal(s.title, 'Morning menu');
assert.equal(s.start_time, '09:00');
assert.equal(s.end_time, '17:00');
assert.equal(s.enabled, 1);
});
test('the repair recovers rows orphaned before this fix existed', () => {
// Simulate the old behaviour, then run the same statement the boot migration runs.
db.prepare(`INSERT INTO schedules (id,user_id,workspace_id,device_id,title,start_time,end_time,timezone,priority,enabled)
VALUES ('legacy-orphan',?,NULL,?, 'Orphan','06:00','08:00','UTC',1,1)`).run(U, DEV);
assert.equal(db.prepare("SELECT workspace_id FROM schedules WHERE id='legacy-orphan'").get().workspace_id, null);
db.prepare(`UPDATE schedules SET workspace_id = (SELECT d.workspace_id FROM devices d WHERE d.id = schedules.device_id)
WHERE workspace_id IS NULL AND device_id IS NOT NULL
AND (SELECT d.workspace_id FROM devices d WHERE d.id = schedules.device_id) IS NOT NULL`).run();
assert.equal(db.prepare("SELECT workspace_id FROM schedules WHERE id='legacy-orphan'").get().workspace_id, WS,
'an operator must be able to see and delete it from the dashboard');
});
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -0,0 +1,114 @@
'use strict';
// Nudging one zone in the layout editor and pressing Save destroyed unrelated tenant data.
//
// The handler deleted every zone and re-inserted the same ids, and its comment claimed that was
// safe: "Reuse each zone's id when supplied so device->zone assignments survive an edit." It is
// not. SQLite runs referential actions on the DELETE, and re-inserting the same primary key does
// not resurrect what they took with them:
//
// playlist_items.zone_id ON DELETE SET NULL -> every multi-zone playlist item un-assigned,
// so those playlists silently fell back to
// fullscreen across the workspace
// schedules.zone_id ON DELETE CASCADE -> every zone-bound schedule DELETED, permanently
//
// 200 OK, no warning, no undo. The invariant: saving a layout must not change anything that merely
// POINTS at its zones — only removing a zone may do that, which is what the cascades are for.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-layoutsave-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-layout-save';
const express = require('express');
const { db } = require('../db/database');
const { requireAuth, generateToken } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const O = 'o-ls', WS = 'ws-ls', U = 'u-ls', L = 'l-ls', PL = 'pl-ls';
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'ls@t.local');
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
db.prepare('INSERT OR IGNORE INTO layouts (id,workspace_id,user_id,name,width,height) VALUES (?,?,?,?,1920,1080)').run(L, WS, U, 'L');
db.prepare("INSERT OR IGNORE INTO layout_zones (id,layout_id,name,x_percent,y_percent,width_percent,height_percent,z_index,zone_type,fit_mode,background_color,sort_order) VALUES ('z-a',?,'A',0,0,100,50,1,'content','contain','#000000',0)").run(L);
db.prepare("INSERT OR IGNORE INTO layout_zones (id,layout_id,name,x_percent,y_percent,width_percent,height_percent,z_index,zone_type,fit_mode,background_color,sort_order) VALUES ('z-b',?,'B',0,50,100,50,2,'content','contain','#000000',1)").run(L);
db.prepare('INSERT OR IGNORE INTO playlists (id,name,workspace_id,user_id) VALUES (?,?,?,?)').run(PL, 'PL', WS, U);
db.prepare("INSERT OR IGNORE INTO content (id,workspace_id,user_id,filename,filepath,mime_type,file_size) VALUES ('c-1',?,?,'a.jpg','a.jpg','image/jpeg',10)").run(WS, U);
// playlist_items.id is an INTEGER rowid, so let SQLite assign it and remember what it gave us.
const ITEM_ID = db.prepare("INSERT INTO playlist_items (playlist_id,content_id,zone_id,sort_order,duration_sec) VALUES (?, 'c-1','z-a',0,10)").run(PL).lastInsertRowid;
// A schedule must target exactly one of device_id / group_id (CHECK constraint), so give it a device.
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
VALUES ('dev-ls','Screen',?,strftime('%s','now'),strftime('%s','now'))`).run(WS);
db.prepare(`INSERT OR IGNORE INTO schedules (id,user_id,workspace_id,device_id,layout_id,zone_id,title,start_time,end_time,timezone,priority,enabled)
VALUES ('s-1',?,?, 'dev-ls',?, 'z-a','Zone schedule','09:00','17:00','UTC',1,1)`).run(U, WS, L);
const app = express();
app.use(express.json());
app.set('io', null);
app.use('/api/layouts', requireAuth, resolveTenancy, require('../routes/layouts'));
const server = app.listen(0);
const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
async function saveZones(zones) {
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/layouts/${L}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify({ zones }),
});
return res.status;
}
const itemZone = () => db.prepare('SELECT zone_id FROM playlist_items WHERE id = ?').get(ITEM_ID).zone_id;
const scheduleExists = () => !!db.prepare("SELECT 1 FROM schedules WHERE id = 's-1'").get();
const zoneIds = () => db.prepare('SELECT id FROM layout_zones WHERE layout_id = ? ORDER BY sort_order').all(L).map(r => r.id);
const CURRENT = () => [
{ id: 'z-a', name: 'A', x_percent: 0, y_percent: 0, width_percent: 100, height_percent: 50, z_index: 1, zone_type: 'content', fit_mode: 'contain' },
{ id: 'z-b', name: 'B', x_percent: 0, y_percent: 50, width_percent: 100, height_percent: 50, z_index: 2, zone_type: 'content', fit_mode: 'contain' },
];
test('THE BUG: moving a zone must not un-assign playlist items or delete schedules', async () => {
assert.equal(itemZone(), 'z-a', 'precondition');
assert.equal(scheduleExists(), true, 'precondition');
const zones = CURRENT();
zones[0].y_percent = 2; // the "nudge"
assert.equal(await saveZones(zones), 200);
assert.equal(itemZone(), 'z-a', 'the item must still be in its zone');
assert.equal(scheduleExists(), true, 'the zone-bound schedule must still exist');
});
test('the geometry change is actually applied', async () => {
const z = db.prepare("SELECT y_percent FROM layout_zones WHERE id = 'z-a'").get();
assert.equal(z.y_percent, 2);
});
test('adding a zone leaves existing bindings alone', async () => {
const zones = CURRENT();
zones[0].y_percent = 2;
zones.push({ name: 'C', x_percent: 0, y_percent: 90, width_percent: 100, height_percent: 10, z_index: 3, zone_type: 'content', fit_mode: 'contain' });
assert.equal(await saveZones(zones), 200);
assert.equal(zoneIds().length, 3);
assert.equal(itemZone(), 'z-a');
assert.equal(scheduleExists(), true);
});
test('REMOVING a zone still cascades — that is what the cascades are for', async () => {
// The fix must not turn deletion into a no-op: dropping the zone an item lives in should
// un-assign that item and take its schedules with it.
const zones = CURRENT().filter(z => z.id !== 'z-a');
assert.equal(await saveZones(zones), 200);
assert.ok(!zoneIds().includes('z-a'), 'the removed zone is gone');
assert.equal(itemZone(), null, 'its item is un-assigned');
assert.equal(scheduleExists(), false, 'its schedule is removed');
});
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -0,0 +1,99 @@
'use strict';
// A PiP overlay is pushed to a live screen and can render an arbitrary web page across it, at full
// resolution, for as long as the operator wants (duration 0 = persistent). That is a fleet-affecting
// write and must be held to the same bar as every other one.
//
// It was not. The only guard was requireScope('full'), which gates API TOKENS and is a deliberate
// pass-through for dashboard sessions (`if (!req.viaToken) return next()`). Every sibling route
// pairs that scope check with a role check — device-groups.js gates POST /:id/command with
// `requireScope('full'), requireGroupWrite` — but these three routes had only the half that does
// nothing for a logged-in user. A member whose role is read-only everywhere else could push and
// clear overlays on every screen in the workspace.
//
// The invariant: a read-only member cannot change what a screen displays. Pinned for all three
// write routes, because a partial fix here is worthless.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-pip-authz-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-pip-authz';
const express = require('express');
const { db } = require('../db/database');
const { requireAuth, generateToken } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
// One workspace, one device, three members: an owner, an editor and a viewer.
const O = 'o-pip', WS = 'ws-pip', DEV = 'd-pip';
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES ('u-owner','owner@t.local','x','user')").run();
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES ('u-editor','editor@t.local','x','user')").run();
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES ('u-viewer','viewer@t.local','x','user')").run();
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', 'u-owner');
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, 'u-owner');
db.prepare("INSERT OR IGNORE INTO workspace_members (workspace_id,user_id,role) VALUES (?,?, 'workspace_editor')").run(WS, 'u-editor');
db.prepare("INSERT OR IGNORE INTO workspace_members (workspace_id,user_id,role) VALUES (?,?, 'workspace_viewer')").run(WS, 'u-viewer');
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(DEV, 'Screen', WS);
const app = express();
app.use(express.json());
// Minimal socket stub — the route emits to the device room on a successful push. The deny paths
// never reach it, but the allow paths must not 500 on a missing io.
const emitted = [];
const nsp = {
adapter: { rooms: new Map([[DEV, new Set(['sock-1'])]]) },
to: () => ({ emit: (...a) => emitted.push(a) }),
emit: (...a) => emitted.push(a),
};
app.set('io', { of: () => nsp });
app.use('/api/pip', requireAuth, resolveTenancy, require('../routes/pip'));
const server = app.listen(0);
const row = (id) => db.prepare('SELECT id, email, role FROM users WHERE id = ?').get(id);
const tokenFor = (id) => generateToken(row(id), WS);
async function call(method, pathname, who, body) {
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
const res = await fetch(`http://127.0.0.1:${server.address().port}${pathname}`, {
method,
headers: { 'Content-Type': 'application/json', ...(who ? { Authorization: `Bearer ${tokenFor(who)}` } : {}) },
...(body ? { body: JSON.stringify(body) } : {}),
});
return res.status;
}
const PUSH = { device_id: DEV, type: 'web', uri: 'https://example.com/', width: 800, height: 600, duration: 0 };
test('THE HOLE: a read-only member cannot push an overlay to a screen', async () => {
assert.equal(await call('POST', '/api/pip', 'u-viewer', PUSH), 403);
});
test('a read-only member cannot clear overlays either', async () => {
// Both spellings of clear — a fix that covers one and not the other is not a fix.
assert.equal(await call('POST', '/api/pip/clear', 'u-viewer', { device_id: DEV }), 403);
assert.equal(await call('DELETE', '/api/pip', 'u-viewer', { device_id: DEV }), 403);
});
test('an unauthenticated caller is refused', async () => {
assert.equal(await call('POST', '/api/pip', null, PUSH), 401);
});
test('AND THE OTHER HALF: an editor can still push and clear', async () => {
// The guard must not break the feature. A workspace_editor manages content by definition.
assert.equal(await call('POST', '/api/pip', 'u-editor', PUSH), 200);
assert.equal(await call('POST', '/api/pip/clear', 'u-editor', { device_id: DEV }), 200);
});
test('an org owner acting into the workspace can still push', async () => {
// actingAs true, workspaceRole null — must not be mistaken for a viewer.
assert.equal(await call('POST', '/api/pip', 'u-owner', PUSH), 200);
});
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -0,0 +1,91 @@
'use strict';
// The calendar is the operator's only view of what is scheduled, and it disagreed with the engine
// in both directions for the two most-used repeat presets.
//
// The old expansion stepped by the recurrence unit from the schedule's original start:
// - WEEKLY advanced a whole week at a time, so dayOfWeek never changed and a
// FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR rule matched only its start day. Created on a Monday it drew
// one event a week; created on a Saturday it drew nothing at all.
// - The walk began at the original start under a 366-iteration cap, so a schedule begun more than
// a year ago never reached the current week and drew nothing.
// Meanwhile the engine evaluates day-of-week directly, so those schedules ran Mon-Fri the whole
// time. Screens were switching content that the calendar said was not scheduled.
//
// The invariant: the calendar draws an event on every day the schedule actually fires.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-cal-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-calendar';
const { expandSchedule } = require('../routes/schedules');
// A Monday-to-Sunday window well clear of the schedules' start dates.
const WEEK_START = new Date('2026-08-03T00:00:00'); // Monday
const WEEK_END = new Date('2026-08-09T23:59:59'); // Sunday
const mk = (recurrence, startISO, recurrenceEnd = null) => ({
id: 's', recurrence, recurrence_end: recurrenceEnd,
start_time: startISO,
end_time: new Date(new Date(startISO).getTime() + 60 * 60 * 1000).toISOString(),
});
const weekdaysOf = (events) => events.map(e => new Date(e.instance_start).getDay()).sort();
test('THE BUG: a Mon-Fri rule draws five events, not one', () => {
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-07-27T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 5);
assert.deepEqual(weekdaysOf(ev), [1, 2, 3, 4, 5], 'Mon..Fri');
});
test('...and it does not matter which day the rule was created on', () => {
// Created on a Saturday, the old code drew nothing whatsoever.
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-08-01T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 5);
assert.deepEqual(weekdaysOf(ev), [1, 2, 3, 4, 5]);
});
test('a DAILY schedule older than a year still draws', () => {
// The 366-iteration cap meant the walk never reached the visible window.
const ev = expandSchedule(mk('FREQ=DAILY', '2024-05-01T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 7, 'every day of the week');
});
test('WEEKLY without byDay still means "the same weekday as the start"', () => {
const ev = expandSchedule(mk('FREQ=WEEKLY', '2026-07-27T09:00:00'), WEEK_START, WEEK_END); // a Monday
assert.equal(ev.length, 1);
assert.deepEqual(weekdaysOf(ev), [1]);
});
test('a DAILY interval is honoured rather than drawn every day', () => {
const ev = expandSchedule(mk('FREQ=DAILY;INTERVAL=2', '2026-08-03T09:00:00'), WEEK_START, WEEK_END);
assert.equal(ev.length, 4, 'Mon, Wed, Fri, Sun');
});
test('recurrence_end stops the drawing', () => {
const ev = expandSchedule(
mk('FREQ=DAILY', '2026-07-27T09:00:00', '2026-08-05T23:59:59'), WEEK_START, WEEK_END);
assert.equal(ev.length, 3, 'Mon, Tue, Wed then it ends');
});
test('a one-off schedule is unaffected', () => {
const ev = expandSchedule(
{ id: 's', recurrence: null, start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T10:00:00' },
WEEK_START, WEEK_END);
assert.equal(ev.length, 1);
});
test('each drawn event keeps the schedule duration', () => {
const ev = expandSchedule(mk('FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR', '2026-07-27T09:00:00'), WEEK_START, WEEK_END);
for (const e of ev) {
const mins = (new Date(e.instance_end) - new Date(e.instance_start)) / 60000;
assert.equal(mins, 60);
}
});
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -0,0 +1,102 @@
'use strict';
// The schedule dialog offers "Content (single item, optional)". The value was validated for
// cross-tenancy and stored faithfully — and then read by nothing at all. services/scheduler.js acts
// on exactly two columns:
//
// if (active.layout_id && ...) { ...apply... }
// if (active.playlist_id && ...) { ...apply... }
//
// content_id, widget_id and zone_id are consulted nowhere. So a content-only schedule was a
// complete no-op, while the calendar drew a block labelled with the filename as confirmation that
// it would fire.
//
// Rather than thread a third override through the engine and every player, the schedule now gets a
// playlist holding that one item — the shape the whole pipeline already understands.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-schedcontent-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-sched-content';
const express = require('express');
const { db } = require('../db/database');
const { requireAuth, generateToken } = require('../middleware/auth');
const { resolveTenancy } = require('../lib/tenancy');
const O = 'o-sc', WS = 'ws-sc', U = 'u-sc', DEV = 'dev-sc', C = 'c-sc';
db.prepare("INSERT OR IGNORE INTO users (id,email,password_hash,role) VALUES (?,?, 'x','user')").run(U, 'sc@t.local');
db.prepare('INSERT OR IGNORE INTO organizations (id,name,owner_user_id) VALUES (?,?,?)').run(O, 'Org', U);
db.prepare('INSERT OR IGNORE INTO workspaces (id,organization_id,name) VALUES (?,?,?)').run(WS, O, 'WS');
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id,user_id,role) VALUES (?,?, 'org_owner')").run(O, U);
db.prepare(`INSERT OR IGNORE INTO devices (id,name,workspace_id,created_at,updated_at)
VALUES (?,?,?,strftime('%s','now'),strftime('%s','now'))`).run(DEV, 'Screen', WS);
db.prepare("INSERT OR IGNORE INTO content (id,workspace_id,user_id,filename,filepath,mime_type,file_size) VALUES (?,?,?,'promo.jpg','promo.jpg','image/jpeg',1234)").run(C, WS, U);
const app = express();
app.use(express.json());
app.set('io', null);
app.use('/api/schedules', requireAuth, resolveTenancy, require('../routes/schedules'));
const server = app.listen(0);
const token = generateToken(db.prepare('SELECT id,email,role FROM users WHERE id = ?').get(U), WS);
async function createSchedule(body) {
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/schedules`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(body),
});
return { status: res.status, body: await res.json().catch(() => null) };
}
const BASE = { device_id: DEV, title: 'Promo hour', start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T17:00:00' };
test('THE BUG: a content-only schedule now has something the engine can act on', async () => {
const { status } = await createSchedule({ ...BASE, content_id: C });
assert.equal(status, 201); // created
const s = db.prepare('SELECT * FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
assert.ok(s.playlist_id, 'the engine reads playlist_id; without one the schedule did nothing');
});
test('the generated playlist contains exactly that item, published', () => {
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
const pl = db.prepare('SELECT * FROM playlists WHERE id = ?').get(s.playlist_id);
assert.equal(pl.workspace_id, WS, 'and it belongs to the right workspace');
assert.equal(pl.status, 'published');
const items = db.prepare('SELECT content_id FROM playlist_items WHERE playlist_id = ?').all(s.playlist_id);
assert.deepEqual(items.map(i => i.content_id), [C]);
});
test('the snapshot the players read is populated', () => {
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
const pl = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(s.playlist_id);
const snap = JSON.parse(pl.published_snapshot || '[]');
assert.equal(snap.length, 1);
assert.equal(snap[0].content_id, C);
assert.ok(snap[0].filename, 'players need the denormalized fields, not just the id');
});
test('an explicit playlist override still wins and no playlist is invented', async () => {
db.prepare("INSERT OR IGNORE INTO playlists (id,name,workspace_id,user_id) VALUES ('pl-explicit','Mine',?,?)").run(WS, U);
const before = db.prepare('SELECT COUNT(*) n FROM playlists').get().n;
await createSchedule({ ...BASE, content_id: C, playlist_id: 'pl-explicit' });
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
assert.equal(s.playlist_id, 'pl-explicit');
assert.equal(db.prepare('SELECT COUNT(*) n FROM playlists').get().n, before, 'no throwaway playlist created');
});
test('a schedule with neither content nor playlist is unchanged', async () => {
const before = db.prepare('SELECT COUNT(*) n FROM playlists').get().n;
await createSchedule({ ...BASE, title: 'Layout only' });
const s = db.prepare('SELECT playlist_id FROM schedules WHERE device_id = ? ORDER BY rowid DESC').get(DEV);
assert.equal(s.playlist_id, null);
assert.equal(db.prepare('SELECT COUNT(*) n FROM playlists').get().n, before);
});
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -0,0 +1,69 @@
'use strict';
// A recurring schedule ran forever. The engine compared weekday and HH:MM and dropped the date
// component entirely, so it never read recurrence_end — a campaign set to finish on the 1st was
// still switching screens weeks later. The calendar does read recurrence_end, so it showed the
// campaign as stopped while the screens kept obeying it; that disagreement is what made it hard to
// see. The end date is offered on the form, so it has to mean something.
//
// The same omission made a recurring schedule live BEFORE its start date, for the same reason.
//
// The invariant: a recurring schedule fires on and between its dates, inclusive, and never outside
// them.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-recend-'));
process.env.DATA_DIR = tmp;
const { isScheduleActiveNow } = require('../services/scheduler');
const TZ = 'UTC';
// A weekday 09:00-17:00 recurring schedule that ran from the 1st to the 5th of August.
const CAMPAIGN = {
recurrence: 'FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR',
start_time: '2026-08-03T09:00:00',
end_time: '2026-08-03T17:00:00',
recurrence_end: '2026-08-07T23:59:59',
};
const at = (iso) => new Date(iso);
test('THE BUG: it must stop after its end date', () => {
// A Monday, inside the daily window, weeks after the campaign ended.
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-09-07T12:00:00Z'), TZ), false,
'a finished campaign must not still be switching screens');
});
test('the final day still runs, to its normal end time', () => {
// Inclusive end: Friday the 7th is the last day and behaves like any other.
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-07T12:00:00Z'), TZ), true);
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-07T18:00:00Z'), TZ), false, 'outside the daily window');
});
test('it does not run before its start date either', () => {
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-07-29T12:00:00Z'), TZ), false, 'a Wednesday, but before it begins');
});
test('inside the window it behaves exactly as before', () => {
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-05T12:00:00Z'), TZ), true, 'Wednesday, midday');
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-05T08:00:00Z'), TZ), false, 'before the daily start');
assert.equal(isScheduleActiveNow(CAMPAIGN, at('2026-08-08T12:00:00Z'), TZ), false, 'Saturday is not in byDay');
});
test('a recurring schedule with NO end date still runs indefinitely', () => {
// This is the normal case and must not be broken by the fix.
const openEnded = { ...CAMPAIGN, recurrence_end: null };
assert.equal(isScheduleActiveNow(openEnded, at('2027-03-10T12:00:00Z'), TZ), true, 'a Wednesday, years later');
});
test('a one-off schedule is unaffected', () => {
const oneOff = { recurrence: null, start_time: '2026-08-05T09:00:00', end_time: '2026-08-05T17:00:00' };
assert.equal(isScheduleActiveNow(oneOff, at('2026-08-05T12:00:00Z'), TZ), true);
assert.equal(isScheduleActiveNow(oneOff, at('2026-08-06T12:00:00Z'), TZ), false);
});
test.after(() => { try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -0,0 +1,102 @@
'use strict';
// Hand-written HTML in the Text/HTML widget was rendered far too small to read.
//
// The Content Designer used to publish absolute sizes as fontSize*10.8 px, and renderText converted
// px/108 back to vw to restore the intended size and let those widgets scale. That rescue is
// correct — but it ran over EVERY text widget, including markup a person typed themselves. So
// `font-size:16px` became 0.15vw: 2.8px on a 1080p screen, 1.9px on a 1280 one. Not clipped, not
// hidden — rendered at a size nobody can read, in the one widget whose entire purpose is
// hand-written HTML.
//
// Today's designer emits cqw, not px (frontend/js/views/designer.js), so the conversion only ever
// needed to apply to legacy designer output. That is identified by absolutely-positioned elements,
// the same signal the dashboard uses to decide whether a text widget can be reopened in the
// designer.
const { test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('path');
const fs = require('fs');
const os = require('os');
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'st-textwidget-'));
process.env.DATA_DIR = tmp;
process.env.JWT_SECRET = 'test-secret-text-widget';
const express = require('express');
const { db } = require('../db/database');
const { requireAuth, generateToken } = require('../middleware/auth');
function seed() {
const u = 'u-tw', o = 'o-tw', ws = 'ws-tw';
db.prepare("INSERT OR IGNORE INTO users (id, email, password_hash, role) VALUES (?,?, 'x','user')").run(u, 'tw@test.local');
db.prepare('INSERT OR IGNORE INTO organizations (id, name, owner_user_id) VALUES (?,?,?)').run(o, 'org', u);
db.prepare('INSERT OR IGNORE INTO workspaces (id, organization_id, name) VALUES (?,?,?)').run(ws, o, 'ws');
db.prepare("INSERT OR IGNORE INTO organization_members (organization_id, user_id, role) VALUES (?,?, 'org_owner')").run(o, u);
return { u, ws };
}
const { u, ws } = seed();
function makeWidget(id, html) {
db.prepare(`INSERT OR REPLACE INTO widgets (id, user_id, workspace_id, widget_type, name, config, created_at, updated_at)
VALUES (?, ?, ?, 'text', ?, ?, strftime('%s','now'), strftime('%s','now'))`)
.run(id, u, ws, id, JSON.stringify({ html, background: '#000' }));
return id;
}
const app = express();
app.use(express.json());
app.use('/api/widgets', requireAuth, require('../routes/widgets'));
const server = app.listen(0);
const token = generateToken(db.prepare('SELECT id, email, role FROM users WHERE id = ?').get(u), ws);
async function render(id) {
await new Promise(r => (server.listening ? r() : server.once('listening', r)));
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/widgets/${id}/render`, {
headers: { Authorization: `Bearer ${token}` },
});
return res.text();
}
test('THE BUG: hand-written px font sizes must survive untouched', async () => {
const id = makeWidget('w-hand', '<h1 style="color:#fff;font-size:40px">Notice</h1>');
const out = await render(id);
assert.match(out, /font-size:40px/, 'a hand-typed 40px must render as 40px');
assert.doesNotMatch(out, /font-size:0\.37vw/, '40px/108 = 0.37vw is ~7px on 1080p — unreadable');
});
test('a small hand-written size is not shrunk into invisibility', async () => {
const id = makeWidget('w-hand-small', '<p style="color:#fff;font-size:16px">Body copy</p>');
const out = await render(id);
assert.match(out, /font-size:16px/);
assert.doesNotMatch(out, /font-size:0\.15vw/, '0.15vw is 2.8px on a 1080p screen');
});
test('LEGACY designer output is still rescued, so old widgets keep scaling', async () => {
// Absolutely-positioned elements are the designer's signature. 54px was fontSize 5 * 10.8.
const id = makeWidget('w-designer',
'<div style="position:absolute;left:10%;top:20%;font-size:54px;color:#fff">Designed</div>');
const out = await render(id);
assert.match(out, /font-size:0\.50vw/, 'legacy designer px must still convert back to vw');
assert.doesNotMatch(out, /font-size:54px/);
});
test('a designer widget with several sizes converts all of them', async () => {
const id = makeWidget('w-designer-multi',
'<div style="position:absolute;left:0;font-size:108px">A</div>' +
'<div style="position:absolute;left:50%;font-size:21.6px">B</div>');
const out = await render(id);
assert.match(out, /font-size:1\.00vw/);
assert.match(out, /font-size:0\.20vw/);
});
test('hand-written markup that merely mentions absolute positioning elsewhere is not misread', async () => {
// The signal is `position:absolute` immediately followed by `left:` — the designer's own shape.
// A hand-written absolute element without that pairing keeps its px.
const id = makeWidget('w-hand-abs', '<div style="position:absolute;top:10px;font-size:32px">X</div>');
const out = await render(id);
assert.match(out, /font-size:32px/, 'only the designer\'s left-first shape triggers the rescue');
});
test.after(() => { server.close(); try { fs.rmSync(tmp, { recursive: true, force: true }); } catch (_) {} });

View file

@ -186,6 +186,23 @@ function resolveGroupSync(device, deviceId) {
return { group_id: group.id, is_leader: leaderId === deviceId };
}
// A widget's CONTENT is always live — /api/widgets/:id/render reads the current config — but the
// playlist payload is a snapshot taken at publish time, so a widget edited afterwards still carried
// its published revision. The player keeps a widget's WebView while its URL is unchanged (re-
// navigating a widget every duration is a visible flash and destroys widget state), so an unchanged
// URL meant an edit only reached the screen after an app restart.
//
// Refreshing the rev here, at send time, makes the URL differ exactly when the content differs —
// and only then, so the anti-flash reuse still holds for widgets nobody has touched.
const widgetRevOf = db.prepare('SELECT updated_at FROM widgets WHERE id = ?').pluck();
function refreshWidgetRevs(assignments) {
if (!Array.isArray(assignments)) return;
for (const a of assignments) {
if (!a || !a.widget_id) continue;
try { a.widget_rev = widgetRevOf.get(a.widget_id) ?? a.widget_rev ?? 0; } catch (_) { /* keep published */ }
}
}
function buildPlaylistPayload(deviceId) {
const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id, timezone, reported_timezone FROM devices WHERE id = ?').get(deviceId);
@ -194,6 +211,7 @@ function buildPlaylistPayload(deviceId) {
const playlist = db.prepare('SELECT published_snapshot FROM playlists WHERE id = ?').get(device.playlist_id);
if (playlist?.published_snapshot) {
try { assignments = JSON.parse(playlist.published_snapshot); } catch (e) { assignments = []; }
refreshWidgetRevs(assignments);
}
}
@ -764,7 +782,18 @@ module.exports = function setupDeviceSocket(io) {
// null-token device" path is removed — that was the re-provisioning vector.
const tokenToSend = device.device_token;
if (device_info) applyDeviceInfo(device_id, device_info);
// An EMPTY device_info means "I have nothing new to tell you", not "wipe what you know".
// The web/BrightSign player's refresh-register sends `device_info: {}` on a 300s timer,
// and `{}` is truthy — so every five minutes applyDeviceInfo, which is a blind full-row
// overwrite with no per-field presence check, bound `undefined` for all 17 columns.
// better-sqlite3 stores those as NULL rather than throwing, so the write succeeded:
// android_version, app_version, screen_width/height, render_*, ota_*, tier, the four
// capability flags and the four volume/brightness columns were all nulled. Fleet view,
// resolution diagnostics and version-based logic read blank for exactly the client family
// that cannot be inspected any other way. The code around this already anticipates the
// shape — recordReconnect/persistIdentity are gated behind `if (!isPlaylistRefresh)` —
// this call was the one that was not.
if (device_info && Object.keys(device_info).length > 0) applyDeviceInfo(device_id, device_info);
heartbeat.registerConnection(device_id, socket.id);
// #134: a same-socket re-register is a playlist REFRESH (~45-60s), NOT a reconnect and NOT
@ -1079,9 +1108,14 @@ module.exports = function setupDeviceSocket(io) {
// Playback state update
socket.on('device:playback-state', (data) => {
if (!requireDeviceAuth()) return;
// currentDeviceId is the authenticated device for this socket; use it
// for the workspace lookup since data may not carry device_id consistently.
emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:playback-state', data);
// currentDeviceId is the authenticated device for this socket; use it for the workspace
// lookup since data may not carry device_id consistently — and STAMP it over whatever the
// payload claims before relaying. This was the only relay forwarding the client's object
// verbatim, so a device could report progress attributed to a different screen in the same
// workspace and the dashboard would believe it. Every other relay here stamps the
// authenticated id; this one now matches.
emitToDeviceWorkspace(dashboardNs, currentDeviceId, 'dashboard:playback-state',
{ ...(data || {}), device_id: currentDeviceId });
});
// Live debug log line from the player (only sent when debug logging is toggled

View file

@ -148,7 +148,10 @@ PlaylistPlayer.prototype.load = function (assignments) {
// transition-engine: include the per-item transition, or a transition change keeps the same
// signature -> "unchanged" -> the player never applies the new transitions.
// duration_sec is EXCLUDED so a duration edit applies in place (below), not as a restart.
return [a.content_id, a.widget_id, a.remote_url, a.mime_type, a.schedules || [], a.transition || null];
// widget_rev for the same reason as schedules and transition above: a widget's IDENTITY
// is unchanged when it is EDITED, so a content edit produced an identical signature, the
// update was treated as unchanged, and the screen kept the old render until a restart.
return [a.content_id, a.widget_id, a.widget_rev || 0, a.remote_url, a.mime_type, a.schedules || [], a.transition || null];
}));
if (sig === this.sig && this.items.length) {
// In-place duration refresh: patch duration_sec on the live items so a duration edit takes effect
@ -176,10 +179,11 @@ PlaylistPlayer.prototype.load = function (assignments) {
if (!items.length) { this.index = 0; this.startPlayback(); return; }
// Current item survives -> keep playing it, just retarget the index (no restart).
if (curId) {
if (curId && !this._forceRender) {
var stay = this.indexOfIdentity(items, curId);
if (stay >= 0 && this.hasContentOnScreen()) { this.index = stay; return; }
}
this._forceRender = false;
// Anchor gone: walk forward from the OLD position to the first item that still exists.
var nextIdx = 0;
@ -195,9 +199,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;
}
@ -247,6 +270,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);
@ -284,7 +308,16 @@ PlaylistPlayer.prototype.setTimezone = function (tz) { this.timezone = tz || nul
// leaving wall mode (or a role flip) calls invalidate() so the next load re-renders
// with the right semantics instead of being de-duped by the unchanged signature.
PlaylistPlayer.prototype.setWallFollower = function (b) { this.wallFollower = !!b; };
PlaylistPlayer.prototype.invalidate = function () { this.sig = ''; };
PlaylistPlayer.prototype.invalidate = function () {
this.sig = '';
// Clearing the signature alone was not enough: load() returns at the continuity check ("current
// item survives -> keep playing it, just retarget the index") BEFORE reaching any render, so the
// invalidate had no effect and the item kept the semantics of the mode we had just left. Leaving
// a sync group or a wall therefore froze the screen on one clip — rendered with `single`, so
// looping with no timer — and every later refresh took the unchanged path because the element
// was attached and playing, i.e. healthy. This flag makes the next load actually re-render.
this._forceRender = true;
};
PlaylistPlayer.prototype.getIndex = function () { return this.index; };
PlaylistPlayer.prototype.getItemCount = function () { return this.items.length; };
PlaylistPlayer.prototype.isWallFollower = function () { return !!this.wallFollower; };
@ -821,7 +854,7 @@ PlaylistPlayer.prototype.renderYouTube = function (item, single) {
PlaylistPlayer.prototype.renderWidget = function (item, single) {
var self = this;
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '');
var src = this.getBase() + '/api/widgets/' + item.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (item.widget_rev || 0);
// Anti-flash (#directory-board, parity with the web player): build the new iframe hidden ON TOP of the
// current content and reveal it on load, THEN drop everything else — so a widget/directory-board
// reload never black-flashes the stage (playCurrent skipped the pre-clear for widgets).
@ -1037,7 +1070,7 @@ ZoneRenderer.prototype.showItem = function (zone, list, index) {
zone.el.appendChild(zrFrame(ysrc, 'autoplay; encrypted-media', yvert));
if (multi) this.scheduleAdvance(zone, dur, advance);
} else if (a.widget_type || (a.widget_id && !a.content_id)) {
zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '')));
zone.el.appendChild(zrFrame(this.getBase() + '/api/widgets/' + a.widget_id + '/render' + (this.getDeviceId() ? '?device=' + encodeURIComponent(this.getDeviceId()) : '?d=') + '&rev=' + (a.widget_rev || 0)));
if (multi) this.scheduleAdvance(zone, dur, advance);
} else if (mime.indexOf('video/') === 0) {
var v = document.createElement('video');
@ -1290,7 +1323,15 @@ GroupSyncController.prototype.target = function () {
};
GroupSyncController.prototype.tick = function () {
if (!this.groupId || !this.player.items.length) return;
var t = this.target(); if (!t) return;
// No target means every item is currently outside its daypart. Returning here left the whole
// group displaying (or looping) whatever was in-window last, out of hours — while an identical
// ungrouped screen correctly showed the idle card. Group members are schedule-driven, so no
// renderer arms a timer and nothing else was watching for this.
var t = this.target();
if (!t) {
if (this.player.hasContentOnScreen()) this.player.nothingScheduled();
return;
}
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6) {
this.player.preloadVideo(t.nextIndex); // warm next clip (video-only; no-ops otherwise)