diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aa73080..03c1ff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,23 @@ jobs: - run: npm ci - run: npm test + android-test: + name: Android unit tests (Kotlin schedule evaluator vectors) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + - uses: android-actions/setup-android@v3 + # ScheduleEvalTest reads the SHARED shared/schedule-vectors.json (wired via + # the test task in app/build.gradle.kts), so a ScheduleEval.kt change that + # breaks the contract fails here. + - name: Kotlin evaluator vector conformance + working-directory: android + run: ./gradlew :app:testDebugUnitTest --no-daemon + smoke: name: Boot smoke + version check runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8497c34 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,50 @@ +# Changelog + +## Unreleased — Per-item scheduling (#74 dayparting + #75 auto-expire) + +### Added +- **Per-playlist-item schedules.** Each playlist item can carry one or more schedule + blocks — active days, a start/end time-of-day, and optional start/end dates. An item + plays when the screen's local "now" matches at least one block; an item with no + blocks always plays. Edit per item via the clock icon in the playlist editor (a badge + summarises the schedule on each row). + - **#74 dayparting:** time-of-day + day-of-week windows, including overnight windows + that cross midnight (a Fri 22:00–02:00 block is active Sat 01:00). + - **#75 auto-expire:** inclusive start/end dates; an item past its end date stops + showing automatically — even on offline screens, because evaluation is on-device. +- All three players (web, Android, Tizen) evaluate schedules client-side against their + own clock, so dayparting and expiry work offline. They share one evaluator contract, + `shared/schedule-vectors.json` — 39 conformance vectors covering DST (US + AU), + overnight-wrap day anchoring, timezone correctness, and date boundaries. CI runs the + vectors against the JS evaluator (node) and the Kotlin port (Gradle/JUnit); the Tizen + copy is byte-identical to the JS source and checked under node. +- Device detail now shows the screen's reported timezone and clock, with a **clock-skew + warning** when the device clock differs from the server by more than 2 minutes (a bad + device clock makes schedules fire at the wrong local time). + +### Changed — device-level schedule timezone (behaviour change) +- Device/group **schedule overrides** (the existing calendar feature) are now evaluated + in each device's effective timezone instead of the server's local time. Previously the + `schedules.timezone` field was never applied and "07:00" meant the *server's* 07:00. + Now "07:00" means the *screen's* 07:00 — which is what was intended. + - **Who is affected:** self-hosters whose server timezone differs from their screens' + timezone — their existing device schedules will shift to fire at the screens' local + time. Single-timezone deployments (server and screens in the same zone) are + unaffected. A device with no timezone set and not reporting one falls back to the + server clock (unchanged from before). + +### Notes +- **Scheduling fails open.** If the on-device evaluator ever errors (bad timezone id, + malformed block), the item **plays** rather than being hidden. A blank screen is worse + than an over-running promo — this is a guarantee, enforced in all three players. +- Windows are enforced at **item boundaries**: a long item finishes before the schedule + is re-checked, so it can overshoot its window by up to its own duration. +- **A single video *with a schedule* now re-renders at each loop boundary** so its window + can be re-evaluated; seamless native looping still applies to unscheduled single videos. + Deliberate tradeoff — a brief seam each loop for a scheduled lone video, in exchange for + its daypart/expiry actually being honoured. +- **Re-publish required:** editing a schedule puts the playlist into draft; publish to + push schedules to devices. Existing published playlists keep playing unchanged until + re-published. +- Players that predate this release ignore the new fields and keep playing everything + (graceful degradation) — update players to honour schedules. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index d36c4af..8554df6 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -75,4 +75,15 @@ dependencies { // Coroutines implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") + + // #74/#75: unit tests for the Kotlin schedule evaluator (vector drift guard) + testImplementation("junit:junit:4.13.2") +} + +// #74/#75: point the evaluator drift-guard test at the SHARED vector contract +// (shared/schedule-vectors.json, the single source - no snapshot). rootProject is +// the android/ Gradle root; its parent is the repo root. Any ScheduleEval.kt edit +// that breaks a vector fails ScheduleEvalTest in CI. +tasks.withType { + systemProperty("scheduleVectors", File(rootProject.projectDir.parentFile, "shared/schedule-vectors.json").absolutePath) } diff --git a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt index 8da5f5e..7a26dc3 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -137,8 +137,10 @@ class MainActivity : AppCompatActivity() { // Setup playlist controller playlistController = PlaylistController( onItemChanged = { item -> item?.let { playItem(it) } }, - onPlaylistEmpty = { showStatus("Waiting for content...") }, - onRequestRefresh = { wsService?.requestPlaylistRefresh() } + // #74/#75: clear the last frame when going idle (else a now-filtered item lingers on screen) + onPlaylistEmpty = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.waiting_for_content)) }, + onRequestRefresh = { wsService?.requestPlaylistRefresh() }, + onNothingScheduled = { if (::mediaPlayer.isInitialized) mediaPlayer.stop(); showStatus(getString(R.string.nothing_scheduled)) } ) // Setup media player @@ -166,6 +168,8 @@ class MainActivity : AppCompatActivity() { val assignments = cached.getJSONArray("assignments") if (assignments.length() > 0) { Log.i("MainActivity", "Restoring cached playlist: ${assignments.length()} items") + // #74/#75: restore the cached effective timezone too (offline schedules) + playlistController.setTimezone(if (cached.isNull("timezone")) null else cached.optString("timezone", "").ifEmpty { null }) playlistController.updatePlaylist(assignments) playlistController.startIfNeeded() } @@ -245,6 +249,11 @@ class MainActivity : AppCompatActivity() { val assignments = data.getJSONArray("assignments") + // #74/#75: device-effective IANA timezone for per-item schedule evaluation + val effectiveTz = if (data.isNull("timezone")) null else data.optString("timezone", "").ifEmpty { null } + playlistController.setTimezone(effectiveTz) + zoneManager?.setTimezone(effectiveTz) + // Cache playlist JSON for offline cold-start config.cachedPlaylist = data.toString() diff --git a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt index 4bea5a9..fb8c275 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/PlaylistController.kt @@ -19,7 +19,8 @@ data class PlaylistItem( val remoteUrl: String? = null, val muted: Boolean = false, val widgetId: String? = null, - val widgetType: String? = null + val widgetType: String? = null, + val schedules: List = emptyList() ) { val isRemote: Boolean get() = !remoteUrl.isNullOrEmpty() // Widget assignments have a widget_id and no downloadable content file. @@ -29,16 +30,23 @@ data class PlaylistItem( class PlaylistController( private val onItemChanged: (PlaylistItem?) -> Unit, private val onPlaylistEmpty: () -> Unit, - private val onRequestRefresh: (() -> Unit)? = null + private val onRequestRefresh: (() -> Unit)? = null, + private val onNothingScheduled: (() -> Unit)? = null ) { private val items = mutableListOf() private var currentIndex = -1 private val handler = Handler(Looper.getMainLooper()) private var advanceRunnable: Runnable? = null private var isRunning = false + // #74/#75: per-item scheduling state + @Volatile private var effectiveTimezone: String? = null + private var retryRunnable: Runnable? = null val isPlaying: Boolean get() = isRunning && currentIndex >= 0 + /** #74/#75: device-effective IANA timezone for per-item schedule evaluation. */ + fun setTimezone(tz: String?) { effectiveTimezone = tz } + val currentItem: PlaylistItem? get() = if (currentIndex in items.indices) items[currentIndex] else null @@ -67,15 +75,22 @@ 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 }, - widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null } + widgetType = if (obj.isNull("widget_type")) null else obj.optString("widget_type", "").ifEmpty { null }, + schedules = parseSchedules(obj.optJSONArray("schedules")) ) ) } // Check if playlist actually changed (key on content OR widget id, since // widget items share an empty contentId). - val oldContentIds = items.map { it.contentId + "|" + (it.widgetId ?: "") } - val newContentIds = newItems.map { it.contentId + "|" + (it.widgetId ?: "") } + // #74/#75: a schedule edit changes playback even when content is identical, so + // the change signature must include schedules (else updated blocks are dropped). + fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + + it.schedules.joinToString(";") { b -> + b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "") + } + val oldContentIds = items.map(::sig) + val newContentIds = newItems.map(::sig) val playlistChanged = oldContentIds != newContentIds if (!playlistChanged && items.isNotEmpty()) { @@ -106,9 +121,10 @@ class PlaylistController( return } } - // Current item was removed or nothing was playing - start from beginning - currentIndex = 0 - playCurrentItem() + // Current item was removed or nothing was playing - start from the first + // schedule-active item; idle if none are active right now. + val idx = firstActiveIndex() + if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled() } else { currentIndex = 0 } @@ -130,12 +146,12 @@ class PlaylistController( fun start() { isRunning = true - if (items.isNotEmpty()) { - if (currentIndex < 0) currentIndex = 0 - playCurrentItem() - } else { - onPlaylistEmpty() - } + if (items.isEmpty()) { onPlaylistEmpty(); return } + // #74/#75: begin on the first schedule-active item; idle if none. + val idx = firstActiveIndex() + if (idx < 0) { showNothingScheduled(); return } + currentIndex = idx + playCurrentItem() } fun startIfNeeded() { @@ -156,13 +172,17 @@ class PlaylistController( fun stop() { isRunning = false cancelAdvance() + cancelRetry() } fun next() { if (items.isEmpty()) return - currentIndex = (currentIndex + 1) % items.size // Request a playlist refresh between plays so new content gets picked up onRequestRefresh?.invoke() + // #74/#75: advance to the next item the schedule allows now; idle if none. + val idx = nextActiveIndex(currentIndex) + if (idx < 0) { showNothingScheduled(); return } + currentIndex = idx playCurrentItem() } @@ -173,6 +193,7 @@ class PlaylistController( private fun playCurrentItem() { cancelAdvance() + cancelRetry() val item = currentItem ?: return Log.i("PlaylistController", "Playing: ${item.filename} (index $currentIndex)") onItemChanged(item) @@ -194,4 +215,64 @@ class PlaylistController( advanceRunnable?.let { handler.removeCallbacks(it) } advanceRunnable = null } + + private fun cancelRetry() { + retryRunnable?.let { handler.removeCallbacks(it) } + retryRunnable = null + } + + // #74/#75 schedule helpers --------------------------------------------------- + private fun scheduleAllows(item: PlaylistItem): Boolean = + item.schedules.isEmpty() || + ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone) + + private fun firstActiveIndex(): Int { + for (i in items.indices) if (scheduleAllows(items[i])) return i + return -1 + } + + private fun nextActiveIndex(from: Int): Int { + if (items.isEmpty()) return -1 + for (i in 1..items.size) { + val idx = (from + i) % items.size + if (scheduleAllows(items[idx])) return idx + } + return -1 + } + + // Every item filtered out: show the idle screen and re-check shortly, since a + // daypart may open. (Boundary re-evaluation otherwise happens on advance.) + private fun showNothingScheduled() { + cancelAdvance() + (onNothingScheduled ?: onPlaylistEmpty)() + cancelRetry() + retryRunnable = Runnable { + if (isRunning && items.isNotEmpty()) { + val idx = firstActiveIndex() + if (idx >= 0) { currentIndex = idx; playCurrentItem() } else showNothingScheduled() + } + } + handler.postDelayed(retryRunnable!!, 30_000L) + } + + private fun parseSchedules(arr: JSONArray?): List { + if (arr == null) return emptyList() + val out = ArrayList(arr.length()) + for (j in 0 until arr.length()) { + val s = arr.getJSONObject(j) + val d = s.getJSONArray("days") + val days = HashSet(d.length()) + for (k in 0 until d.length()) days.add(d.getInt(k)) + out.add( + ScheduleEval.Block( + days = days, + start = s.getString("start"), + end = s.getString("end"), + startDate = if (s.isNull("start_date")) null else s.optString("start_date").ifEmpty { null }, + endDate = if (s.isNull("end_date")) null else s.optString("end_date").ifEmpty { null } + ) + ) + } + return out + } } diff --git a/android/app/src/main/java/com/remotedisplay/player/player/ScheduleEval.kt b/android/app/src/main/java/com/remotedisplay/player/player/ScheduleEval.kt new file mode 100644 index 0000000..e9cdc4d --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/player/ScheduleEval.kt @@ -0,0 +1,80 @@ +package com.remotedisplay.player.player + +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId + +/** + * Canonical per-playlist-item schedule evaluator (#74 dayparting + #75 expiry) - + * Kotlin port of server/lib/schedule-eval.js. + * + * CONTRACT: shared/schedule-vectors.json. This must agree with the JS evaluator + * (server/web/Tizen) on every vector. If it disagrees with a vector, this is wrong. + * + * Time model: instants are UTC; blocks are LOCAL wall-clock rules interpreted in + * the device's effective IANA timezone (DST handled by java.time). Blocks are never + * converted to UTC. + * + * Block semantics: + * - within a block, day AND date AND time must all pass; blocks OR together + * - zero blocks = always on ("no schedule = always plays") + * - time window is [start, end): start inclusive, end exclusive ("24:00" = end of day) + * - start > end crosses midnight; the day/date test anchors to the day the window STARTED + * + * FAILS OPEN: any error (bad timezone, malformed block) returns true so the item + * PLAYS. A blank screen is worse than an over-running promo. + */ +object ScheduleEval { + + data class Block( + val days: Set, // 0=Sun .. 6=Sat + val start: String, // "HH:MM" + val end: String, // "HH:MM" or "24:00" + val startDate: String?, // "YYYY-MM-DD" or null = no lower bound + val endDate: String? // "YYYY-MM-DD" or null = no upper bound + ) + + fun isItemActiveNow(blocks: List?, utcNowMs: Long, ianaTz: String?): Boolean { + if (blocks.isNullOrEmpty()) return true + return try { + val zone = if (ianaTz.isNullOrBlank()) ZoneId.systemDefault() else ZoneId.of(ianaTz) + val zdt = Instant.ofEpochMilli(utcNowMs).atZone(zone) + val dow = zdt.dayOfWeek.value % 7 // java Mon=1..Sun=7 -> Sun=0..Sat=6 + val nowMin = zdt.hour * 60 + zdt.minute + val date = zdt.toLocalDate() + blocks.any { blockMatches(it, dow, nowMin, date) } + } catch (e: Exception) { + true // fail open + } + } + + private fun hm(s: String): Int { val p = s.split(":"); return p[0].toInt() * 60 + p[1].toInt() } // "24:00" -> 1440 + + private fun dayOk(dow: Int, days: Set): Boolean = days.contains(dow) + + private fun dateOk(date: LocalDate, startDate: String?, endDate: String?): Boolean { + if (startDate != null && date.isBefore(LocalDate.parse(startDate))) return false + if (endDate != null && date.isAfter(LocalDate.parse(endDate))) return false // inclusive + return true + } + + private fun blockMatches(b: Block, dow: Int, nowMin: Int, date: LocalDate): Boolean { + val s = hm(b.start); val e = hm(b.end) + if (s <= e) { + // same-day window [s, e), anchored to today + if (nowMin < s || nowMin >= e) return false + return dayOk(dow, b.days) && dateOk(date, b.startDate, b.endDate) + } + // overnight wrap + if (nowMin >= s) { + // before-midnight portion: anchor = today + return dayOk(dow, b.days) && dateOk(date, b.startDate, b.endDate) + } + if (nowMin < e) { + // after-midnight portion: anchor = the day it started = yesterday + val y = date.minusDays(1) + return dayOk((dow + 6) % 7, b.days) && dateOk(y, b.startDate, b.endDate) + } + return false + } +} diff --git a/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt index a769f43..346339b 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/ZoneManager.kt @@ -51,6 +51,10 @@ class ZoneManager( private set var lastAssignmentSig: String? = null + // #74/#75: device-effective IANA timezone for per-item schedule evaluation. + @Volatile private var effectiveTimezone: String? = null + fun setTimezone(tz: String?) { effectiveTimezone = tz } + fun hasZones(): Boolean = zones.isNotEmpty() fun setupZones(zonesJson: JSONArray, layoutId: String? = null) { @@ -120,6 +124,35 @@ class ZoneManager( Log.i(TAG, "Rendered ${zoneViews.size} zone views") } + // #74/#75 zone schedule helpers. + private fun assignmentAllows(a: JSONObject): Boolean { + val arr = a.optJSONArray("schedules") ?: return true + if (arr.length() == 0) return true + val blocks = ArrayList(arr.length()) + for (j in 0 until arr.length()) { + val s = arr.getJSONObject(j) + val d = s.getJSONArray("days") + val days = HashSet(d.length()) + for (k in 0 until d.length()) days.add(d.getInt(k)) + blocks.add( + ScheduleEval.Block( + days, s.getString("start"), s.getString("end"), + if (s.isNull("start_date")) null else s.optString("start_date").ifEmpty { null }, + if (s.isNull("end_date")) null else s.optString("end_date").ifEmpty { null } + ) + ) + } + return ScheduleEval.isItemActiveNow(blocks, System.currentTimeMillis(), effectiveTimezone) + } + + private fun zoneNextActive(assignments: List, from: Int): Int { + for (i in assignments.indices) { + val idx = (from + i) % assignments.size + if (assignmentAllows(assignments[idx])) return idx + } + return -1 + } + // Render assignment[index] in a zone, replacing its current view. If the zone // has more than one assignment it rotates: images/widgets advance on a duration // timer; videos advance when they end (single-item zones loop the video). @@ -128,9 +161,17 @@ class ZoneManager( zoneViews.remove(zone.id)?.let { container.removeView(it) } zoneExoPlayers.remove(zone.id)?.release() - val a = assignments[index % assignments.size] - val multi = assignments.size > 1 - val advance: () -> Unit = { showZoneItem(zone, assignments, index + 1, params) } + // #74/#75: skip items whose schedule excludes them now; blank-idle the zone + // and re-check shortly (a daypart may open) if none are active. + val activeIdx = zoneNextActive(assignments, index) + if (activeIdx < 0) { + scheduleZoneAdvance(zone.id, 30_000L) { showZoneItem(zone, assignments, 0, params) } + return + } + val a = assignments[activeIdx] + // Scheduled zones cycle even with one active item so windows re-evaluate. + val multi = assignments.size > 1 || assignments.any { (it.optJSONArray("schedules")?.length() ?: 0) > 0 } + val advance: () -> Unit = { showZoneItem(zone, assignments, activeIdx + 1, params) } val mimeType = a.optString("mime_type", "") val remoteUrl = if (a.isNull("remote_url")) null else a.optString("remote_url", null) @@ -143,7 +184,7 @@ class ZoneManager( // Per-zone content switch log (fires on initial render AND each rotation), so // the live debug panel shows each zone advancing on its own interval. val label = a.optString("filename", "").ifEmpty { widgetType?.let { "widget:$it" } ?: mimeType.ifEmpty { "item" } } - com.remotedisplay.player.util.DebugLog.i("Zone", "'${zone.name}' [${(index % assignments.size) + 1}/${assignments.size}] -> $label (${durationMs / 1000}s)") + com.remotedisplay.player.util.DebugLog.i("Zone", "'${zone.name}' [${activeIdx + 1}/${assignments.size}] -> $label (${durationMs / 1000}s)") when { // Widget - render in WebView diff --git a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt index 4ad04b6..4f6ab97 100644 --- a/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt +++ b/android/app/src/main/java/com/remotedisplay/player/telemetry/DeviceInfo.kt @@ -30,6 +30,9 @@ class DeviceInfo(private val context: Context) { put("wifi_ssid", getWifiSSID()) put("wifi_rssi", getWifiRSSI()) put("uptime_seconds", getUptimeSeconds()) + // #74/#75: OS timezone + UTC clock (effective-tz resolution + dashboard skew indicator) + put("timezone", java.util.TimeZone.getDefault().id) + put("device_utc", System.currentTimeMillis()) } } diff --git a/android/app/src/main/res/values-de/strings.xml b/android/app/src/main/res/values-de/strings.xml index 32fd402..e9a1135 100644 --- a/android/app/src/main/res/values-de/strings.xml +++ b/android/app/src/main/res/values-de/strings.xml @@ -2,4 +2,6 @@ RemoteDisplay RemoteDisplay nutzt die Bedienungshilfen, um Fernsteuerung der Stromzufuhr und Systemnavigation zu ermöglichen. + Derzeit ist nichts geplant + Warte auf Inhalte… diff --git a/android/app/src/main/res/values-es/strings.xml b/android/app/src/main/res/values-es/strings.xml index 7c44f65..aaba213 100644 --- a/android/app/src/main/res/values-es/strings.xml +++ b/android/app/src/main/res/values-es/strings.xml @@ -2,4 +2,6 @@ RemoteDisplay RemoteDisplay usa accesibilidad para habilitar el control remoto de encendido y la navegación del sistema. + No hay nada programado en este momento + Esperando contenido… diff --git a/android/app/src/main/res/values-fr/strings.xml b/android/app/src/main/res/values-fr/strings.xml index 26d805c..7418c2a 100644 --- a/android/app/src/main/res/values-fr/strings.xml +++ b/android/app/src/main/res/values-fr/strings.xml @@ -2,4 +2,6 @@ RemoteDisplay RemoteDisplay utilise l\'accessibilité pour activer les contrôles d\'alimentation à distance et la navigation système. + Rien de programmé pour le moment + En attente de contenu… diff --git a/android/app/src/main/res/values-pt/strings.xml b/android/app/src/main/res/values-pt/strings.xml index c8c76f5..816276a 100644 --- a/android/app/src/main/res/values-pt/strings.xml +++ b/android/app/src/main/res/values-pt/strings.xml @@ -2,4 +2,6 @@ RemoteDisplay RemoteDisplay usa acessibilidade para habilitar controles remotos de energia e navegação do sistema. + Nada programado no momento + Aguardando conteúdo… diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 35daea1..b0a5222 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,4 +2,6 @@ RemoteDisplay RemoteDisplay uses accessibility to enable remote power controls and system navigation. + Nothing scheduled right now + Waiting for content… diff --git a/android/app/src/test/java/com/remotedisplay/player/player/ScheduleEvalTest.kt b/android/app/src/test/java/com/remotedisplay/player/player/ScheduleEvalTest.kt new file mode 100644 index 0000000..3bbc6cc --- /dev/null +++ b/android/app/src/test/java/com/remotedisplay/player/player/ScheduleEvalTest.kt @@ -0,0 +1,50 @@ +package com.remotedisplay.player.player + +import com.google.gson.JsonParser +import org.junit.Assert.assertEquals +import org.junit.Test +import java.io.File +import java.time.Instant + +/** + * Drift guard (#74/#75): the Kotlin evaluator must agree with the SHARED contract + * at shared/schedule-vectors.json - the SAME file the JS server, web player, and + * Tizen player are held to. No snapshot is taken: the test task points + * `scheduleVectors` at the single source (see app/build.gradle.kts), so any future + * ScheduleEval.kt change that breaks a vector fails CI. + */ +class ScheduleEvalTest { + + @Test + fun conformsToSharedVectors() { + val path = System.getProperty("scheduleVectors") + ?: error("scheduleVectors system property not set (configured in app/build.gradle.kts)") + val vectors = JsonParser.parseString(File(path).readText()).asJsonObject.getAsJsonArray("vectors") + + val failures = StringBuilder() + var count = 0 + for (el in vectors) { + val v = el.asJsonObject + val blocks = v.getAsJsonArray("blocks").map { b -> + val o = b.asJsonObject + ScheduleEval.Block( + days = o.getAsJsonArray("days").map { it.asInt }.toSet(), + start = o.get("start").asString, + end = o.get("end").asString, + startDate = o.get("start_date").let { if (it.isJsonNull) null else it.asString }, + endDate = o.get("end_date").let { if (it.isJsonNull) null else it.asString } + ) + } + val utcMs = Instant.parse(v.get("utc_now").asString).toEpochMilli() + val got = ScheduleEval.isItemActiveNow(blocks, utcMs, v.get("timezone").asString) + val expected = v.get("expected").asBoolean + count++ + if (got != expected) { + failures.append("\n[${v.get("utc_now").asString} ${v.get("timezone").asString}] " + + "expected $expected got $got :: ${v.get("description").asString}") + } + } + println("Kotlin JUnit schedule vectors: ${count - failures.count { it == '\n' }}/$count passed") + assertEquals("schedule vectors failed:$failures", 0, failures.length) + } +} diff --git a/frontend/js/api.js b/frontend/js/api.js index bad8ae5..baa27fc 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -146,6 +146,9 @@ export const api = { updatePlaylistItem: (id, itemId, data) => request(`/playlists/${id}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }), deletePlaylistItem: (id, itemId) => request(`/playlists/${id}/items/${itemId}`, { method: 'DELETE' }), reorderPlaylistItems: (id, order) => request(`/playlists/${id}/items/reorder`, { method: 'POST', body: JSON.stringify({ order }) }), + // #74/#75 per-item schedule blocks + getItemSchedules: (id, itemId) => request(`/playlists/${id}/items/${itemId}/schedules`), + setItemSchedules: (id, itemId, blocks) => request(`/playlists/${id}/items/${itemId}/schedules`, { method: 'PUT', body: JSON.stringify({ blocks }) }), assignPlaylistToDevice: (playlistId, device_id) => request(`/playlists/${playlistId}/assign`, { method: 'POST', body: JSON.stringify({ device_id }) }), publishPlaylist: (id) => request(`/playlists/${id}/publish`, { method: 'POST' }), discardPlaylistDraft: (id) => request(`/playlists/${id}/discard`, { method: 'POST' }), diff --git a/frontend/js/i18n/de.js b/frontend/js/i18n/de.js index 8c7617e..e40a9c2 100644 --- a/frontend/js/i18n/de.js +++ b/frontend/js/i18n/de.js @@ -691,6 +691,36 @@ export default { 'playlist.adding': 'Wird hinzugefügt...', 'playlist.added': 'Hinzugefügt', + // Per-item schedule editor (#74/#75) + 'itemsched.title': 'Zeitplan', + 'itemsched.hint': 'Elemente ohne Zeitplan werden immer abgespielt. Fügen Sie Blöcke hinzu, um einzuschränken, wann dieses Element erscheint — ausgewertet in der lokalen Zeit jedes Bildschirms.', + 'itemsched.none': 'Kein Zeitplan — dieses Element wird immer abgespielt.', + 'itemsched.block': 'Block {n}', + 'itemsched.remove_block': 'Block entfernen', + 'itemsched.from': 'Von', + 'itemsched.to': 'Bis', + 'itemsched.end_of_day': 'Tagesende', + 'itemsched.starts': 'Beginnt', + 'itemsched.ends': 'Endet', + 'itemsched.dates_hint': '(Daten optional, einschließlich)', + 'itemsched.add_block': '+ Zeitplanblock hinzufügen', + 'itemsched.cancel': 'Abbrechen', + 'itemsched.save': 'Zeitplan speichern', + 'itemsched.toast.saved': 'Zeitplan gespeichert — veröffentlichen Sie die Playlist, um sie an die Geräte zu senden', + 'itemsched.toast.cleared': 'Zeitplan gelöscht — veröffentlichen, um die Geräte zu aktualisieren', + 'itemsched.every_day': 'Täglich', + 'itemsched.mon_fri': 'Mo-Fr', + 'itemsched.sat_sun': 'Sa-So', + 'itemsched.dow_short': 'So,Mo,Di,Mi,Do,Fr,Sa', + 'itemsched.err.days': 'Jeder Zeitplanblock benötigt mindestens einen aktiven Tag', + 'itemsched.err.start': 'Startzeit muss HH:MM sein', + 'itemsched.err.end': 'Endzeit muss HH:MM sein (oder Tagesende)', + 'itemsched.err.start_date': 'Startdatum muss JJJJ-MM-TT sein', + 'itemsched.err.end_date': 'Enddatum muss JJJJ-MM-TT sein', + 'device.clock.label': 'Geräteuhr', + 'device.clock.reported': '{time} gemeldet', + 'device.clock.skew': '⚠ Uhr weicht um {amount} ab — Zeitpläne können zur falschen lokalen Zeit auslösen', + // Onboarding 'onboarding.back': 'Zurück', 'onboarding.next': 'Weiter', diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 08947fd..6dbae19 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -775,6 +775,36 @@ export default { 'playlist.adding': 'Adding...', 'playlist.added': 'Added', + // Per-item schedule editor (#74/#75) + 'itemsched.title': 'Schedule', + 'itemsched.hint': "Items without a schedule always play. Add blocks to limit when this item appears — evaluated in each screen's local time.", + 'itemsched.none': 'No schedule — this item always plays.', + 'itemsched.block': 'Block {n}', + 'itemsched.remove_block': 'Remove block', + 'itemsched.from': 'From', + 'itemsched.to': 'To', + 'itemsched.end_of_day': 'end of day', + 'itemsched.starts': 'Starts', + 'itemsched.ends': 'Ends', + 'itemsched.dates_hint': '(dates optional, inclusive)', + 'itemsched.add_block': '+ Add schedule block', + 'itemsched.cancel': 'Cancel', + 'itemsched.save': 'Save schedule', + 'itemsched.toast.saved': 'Schedule saved — publish the playlist to push it to devices', + 'itemsched.toast.cleared': 'Schedule cleared — publish to update devices', + 'itemsched.every_day': 'Every day', + 'itemsched.mon_fri': 'Mon-Fri', + 'itemsched.sat_sun': 'Sat-Sun', + 'itemsched.dow_short': 'Sun,Mon,Tue,Wed,Thu,Fri,Sat', + 'itemsched.err.days': 'Each schedule block needs at least one active day', + 'itemsched.err.start': 'Start time must be HH:MM', + 'itemsched.err.end': 'End time must be HH:MM (or end of day)', + 'itemsched.err.start_date': 'Start date must be YYYY-MM-DD', + 'itemsched.err.end_date': 'End date must be YYYY-MM-DD', + 'device.clock.label': 'Device clock', + 'device.clock.reported': '{time} reported', + 'device.clock.skew': '⚠ clock off by {amount} — schedules may fire at the wrong local time', + // Onboarding 'onboarding.back': 'Back', 'onboarding.next': 'Next', diff --git a/frontend/js/i18n/es.js b/frontend/js/i18n/es.js index cd13f81..0661bd5 100644 --- a/frontend/js/i18n/es.js +++ b/frontend/js/i18n/es.js @@ -690,6 +690,36 @@ export default { 'playlist.adding': 'Agregando...', 'playlist.added': 'Agregado', + // Per-item schedule editor (#74/#75) + 'itemsched.title': 'Programación', + 'itemsched.hint': 'Los elementos sin programación siempre se reproducen. Agrega bloques para limitar cuándo aparece este elemento — se evalúa en la hora local de cada pantalla.', + 'itemsched.none': 'Sin programación — este elemento siempre se reproduce.', + 'itemsched.block': 'Bloque {n}', + 'itemsched.remove_block': 'Eliminar bloque', + 'itemsched.from': 'Desde', + 'itemsched.to': 'Hasta', + 'itemsched.end_of_day': 'fin del día', + 'itemsched.starts': 'Empieza', + 'itemsched.ends': 'Termina', + 'itemsched.dates_hint': '(fechas opcionales, inclusivas)', + 'itemsched.add_block': '+ Agregar bloque de programación', + 'itemsched.cancel': 'Cancelar', + 'itemsched.save': 'Guardar programación', + 'itemsched.toast.saved': 'Programación guardada — publica la lista de reproducción para enviarla a los dispositivos', + 'itemsched.toast.cleared': 'Programación borrada — publica para actualizar los dispositivos', + 'itemsched.every_day': 'Todos los días', + 'itemsched.mon_fri': 'Lun-Vie', + 'itemsched.sat_sun': 'Sáb-Dom', + 'itemsched.dow_short': 'Dom,Lun,Mar,Mié,Jue,Vie,Sáb', + 'itemsched.err.days': 'Cada bloque de programación necesita al menos un día activo', + 'itemsched.err.start': 'La hora de inicio debe ser HH:MM', + 'itemsched.err.end': 'La hora de fin debe ser HH:MM (o fin del día)', + 'itemsched.err.start_date': 'La fecha de inicio debe ser AAAA-MM-DD', + 'itemsched.err.end_date': 'La fecha de fin debe ser AAAA-MM-DD', + 'device.clock.label': 'Reloj del dispositivo', + 'device.clock.reported': '{time} reportado', + 'device.clock.skew': '⚠ reloj desfasado en {amount} — las programaciones pueden activarse a la hora local incorrecta', + // Onboarding 'onboarding.back': 'Atrás', 'onboarding.next': 'Siguiente', diff --git a/frontend/js/i18n/fr.js b/frontend/js/i18n/fr.js index 173518b..3f0cf57 100644 --- a/frontend/js/i18n/fr.js +++ b/frontend/js/i18n/fr.js @@ -691,6 +691,36 @@ export default { 'playlist.adding': 'Ajout...', 'playlist.added': 'Ajouté', + // Per-item schedule editor (#74/#75) + 'itemsched.title': 'Programmation', + 'itemsched.hint': 'Les éléments sans programmation sont toujours diffusés. Ajoutez des blocs pour limiter quand cet élément apparaît — évalué dans l’heure locale de chaque écran.', + 'itemsched.none': 'Aucune programmation — cet élément est toujours diffusé.', + 'itemsched.block': 'Bloc {n}', + 'itemsched.remove_block': 'Supprimer le bloc', + 'itemsched.from': 'De', + 'itemsched.to': 'À', + 'itemsched.end_of_day': 'fin de journée', + 'itemsched.starts': 'Début', + 'itemsched.ends': 'Fin', + 'itemsched.dates_hint': '(dates facultatives, incluses)', + 'itemsched.add_block': '+ Ajouter un bloc de programmation', + 'itemsched.cancel': 'Annuler', + 'itemsched.save': 'Enregistrer la programmation', + 'itemsched.toast.saved': 'Programmation enregistrée — publiez la playlist pour l’envoyer aux appareils', + 'itemsched.toast.cleared': 'Programmation effacée — publiez pour mettre à jour les appareils', + 'itemsched.every_day': 'Tous les jours', + 'itemsched.mon_fri': 'Lun-Ven', + 'itemsched.sat_sun': 'Sam-Dim', + 'itemsched.dow_short': 'Dim,Lun,Mar,Mer,Jeu,Ven,Sam', + 'itemsched.err.days': 'Chaque bloc de programmation nécessite au moins un jour actif', + 'itemsched.err.start': 'L’heure de début doit être HH:MM', + 'itemsched.err.end': 'L’heure de fin doit être HH:MM (ou fin de journée)', + 'itemsched.err.start_date': 'La date de début doit être AAAA-MM-JJ', + 'itemsched.err.end_date': 'La date de fin doit être AAAA-MM-JJ', + 'device.clock.label': 'Horloge de l’appareil', + 'device.clock.reported': '{time} signalé', + 'device.clock.skew': '⚠ horloge décalée de {amount} — les programmations peuvent se déclencher à la mauvaise heure locale', + // Onboarding 'onboarding.back': 'Retour', 'onboarding.next': 'Suivant', diff --git a/frontend/js/i18n/pt.js b/frontend/js/i18n/pt.js index 64ee1ca..c08d463 100644 --- a/frontend/js/i18n/pt.js +++ b/frontend/js/i18n/pt.js @@ -691,6 +691,36 @@ export default { 'playlist.adding': 'Adicionando...', 'playlist.added': 'Adicionado', + // Per-item schedule editor (#74/#75) + 'itemsched.title': 'Programação', + 'itemsched.hint': 'Itens sem programação sempre são reproduzidos. Adicione blocos para limitar quando este item aparece — avaliado no horário local de cada tela.', + 'itemsched.none': 'Sem programação — este item sempre é reproduzido.', + 'itemsched.block': 'Bloco {n}', + 'itemsched.remove_block': 'Remover bloco', + 'itemsched.from': 'De', + 'itemsched.to': 'Até', + 'itemsched.end_of_day': 'fim do dia', + 'itemsched.starts': 'Início', + 'itemsched.ends': 'Fim', + 'itemsched.dates_hint': '(datas opcionais, inclusivas)', + 'itemsched.add_block': '+ Adicionar bloco de programação', + 'itemsched.cancel': 'Cancelar', + 'itemsched.save': 'Salvar programação', + 'itemsched.toast.saved': 'Programação salva — publique a playlist para enviá-la aos dispositivos', + 'itemsched.toast.cleared': 'Programação limpa — publique para atualizar os dispositivos', + 'itemsched.every_day': 'Todos os dias', + 'itemsched.mon_fri': 'Seg-Sex', + 'itemsched.sat_sun': 'Sáb-Dom', + 'itemsched.dow_short': 'Dom,Seg,Ter,Qua,Qui,Sex,Sáb', + 'itemsched.err.days': 'Cada bloco de programação precisa de pelo menos um dia ativo', + 'itemsched.err.start': 'A hora de início deve ser HH:MM', + 'itemsched.err.end': 'A hora de fim deve ser HH:MM (ou fim do dia)', + 'itemsched.err.start_date': 'A data de início deve ser AAAA-MM-DD', + 'itemsched.err.end_date': 'A data de fim deve ser AAAA-MM-DD', + 'device.clock.label': 'Relógio do dispositivo', + 'device.clock.reported': '{time} reportado', + 'device.clock.skew': '⚠ relógio defasado em {amount} — as programações podem disparar no horário local errado', + // Onboarding 'onboarding.back': 'Voltar', 'onboarding.next': 'Próximo', diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index b79a93c..f57a46f 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -28,6 +28,24 @@ function formatUptime(seconds) { return `${m}m`; } +// #74/#75: device clock + skew indicator. Compares the device's reported UTC to the +// server's receipt time; a gap > 2 min means the device clock is wrong, so per-item +// schedules will fire at the wrong local time — surface it instead of a support mystery. +function renderDeviceClock(device) { + const tz = device.reported_timezone || device.timezone || '--'; + if (!device.reported_utc || !device.reported_at) return tz; + const skewSec = Math.abs(Math.round(device.reported_utc / 1000) - device.reported_at); + let local = ''; + try { + local = new Date(device.reported_utc).toLocaleString(undefined, + { timeZone: device.reported_timezone || undefined, hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }); + } catch (e) { /* bad tz id -> skip local render */ } + const warn = skewSec > 120 + ? `
${t('device.clock.skew', { amount: skewSec >= 3600 ? Math.round(skewSec / 3600) + 'h' : Math.round(skewSec / 60) + 'm' })}
` + : ''; + return `${tz}${local ? `
${t('device.clock.reported', { time: local })}
` : ''}${warn}`; +} + export function render(container, deviceId) { container.innerHTML = `
@@ -288,6 +306,10 @@ async function loadDevice(deviceId, activeTab = null) {
${t('device.info.screen_resolution')}
${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}
+
+
${t('device.clock.label')}
+
${renderDeviceClock(device)}
+
${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.ram')}
diff --git a/frontend/js/views/playlists.js b/frontend/js/views/playlists.js index db38c18..b040213 100644 --- a/frontend/js/views/playlists.js +++ b/frontend/js/views/playlists.js @@ -14,6 +14,39 @@ function getTypeIcon(item) { return ''; } +// #74/#75 per-item schedule editor helpers. Client validation MIRRORS the server +// (server/routes/playlists.js validateBlocks): same time/date regexes, non-empty days. +const SCHED_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/; +const SCHED_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function daysSummary(days) { + const labels = t('itemsched.dow_short').split(','); + const s = [...days].sort((a, b) => a - b); + if (s.length === 7) return t('itemsched.every_day'); + if (s.length === 5 && [1, 2, 3, 4, 5].every(d => s.includes(d))) return t('itemsched.mon_fri'); + if (s.length === 2 && s.includes(0) && s.includes(6)) return t('itemsched.sat_sun'); + return s.map(d => labels[d]).join(' '); +} +function blockSummary(b) { + let s = `${daysSummary(b.days)} ${b.start}-${b.end}`; + if (b.start_date || b.end_date) s += ` · ${b.start_date || '…'}→${b.end_date || '…'}`; + return s; +} +function scheduleSummary(schedules) { + if (!schedules || !schedules.length) return ''; + return schedules.length === 1 ? blockSummary(schedules[0]) : `${blockSummary(schedules[0])} +${schedules.length - 1}`; +} +function validateScheduleBlocks(blocks) { + for (const b of blocks) { + if (!b.days || !b.days.length) return t('itemsched.err.days'); + if (!SCHED_TIME_RE.test(b.start)) return t('itemsched.err.start'); + if (!(SCHED_TIME_RE.test(b.end) || b.end === '24:00')) return t('itemsched.err.end'); + if (b.start_date && !SCHED_DATE_RE.test(b.start_date)) return t('itemsched.err.start_date'); + if (b.end_date && !SCHED_DATE_RE.test(b.end_date)) return t('itemsched.err.end_date'); + } + return null; +} + let currentPlaylistId = null; export function render(container) { @@ -296,7 +329,10 @@ function renderItems(items) {
${esc(item.filename || item.widget_name || t('common.unknown'))}
-
${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))}
+
+ ${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))} + ${item.schedules && item.schedules.length ? `🕐 ${esc(scheduleSummary(item.schedules))}` : ''} +
@@ -304,6 +340,9 @@ function renderItems(items) { ${t('playlist.sec')}
+ @@ -346,6 +385,14 @@ function renderItems(items) { }); }); + itemsEl.querySelectorAll('.item-schedule').forEach(btn => { + btn.addEventListener('click', (e) => { + const itemId = e.currentTarget.dataset.itemId; + const item = items.find(it => String(it.id) === String(itemId)); + if (item) showScheduleModal(item); + }); + }); + itemsEl.querySelectorAll('.item-move').forEach(btn => { btn.addEventListener('click', async (e) => { if (btn.disabled) return; @@ -597,3 +644,110 @@ async function showAddItemModal(playlistId) { renderTab(); } + +// #74/#75: per-item schedule editor. Multiple blocks (days + time window + optional +// date range) OR together; an item with no blocks always plays. Client validation +// mirrors the server; saving marks the playlist DRAFT (must re-publish to reach devices). +function showScheduleModal(item) { + let blocks = (item.schedules || []).map(b => ({ + days: Array.isArray(b.days) ? [...b.days] : [], + start: b.start || '00:00', + end: b.end || '24:00', + start_date: b.start_date || '', + end_date: b.end_date || '' + })); + + const modal = document.createElement('div'); + modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000'; + document.body.appendChild(modal); + + function blockRow(b, idx) { + const eod = b.end === '24:00'; + const dayLabels = t('itemsched.dow_short').split(','); + return ` +
+
+ ${t('itemsched.block', { n: idx + 1 })} + +
+
+ ${dayLabels.map((lbl, d) => ``).join('')} +
+
+ + + +
+
+ + + ${t('itemsched.dates_hint')} +
+
`; + } + + function render() { + modal.innerHTML = ` +
+

${t('itemsched.title')}

+

${esc(item.filename || item.widget_name || 'item')}

+

${t('itemsched.hint')}

+
${blocks.length ? blocks.map(blockRow).join('') : `

${t('itemsched.none')}

`}
+ +
+ + +
+
`; + wire(); + } + + function wire() { + modal.querySelectorAll('.sched-day').forEach(btn => btn.addEventListener('click', () => { + const i = +btn.dataset.idx, d = +btn.dataset.day; + const set = new Set(blocks[i].days); + if (set.has(d)) set.delete(d); else set.add(d); + blocks[i].days = [...set]; + render(); + })); + modal.querySelectorAll('.sched-start').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start = el.value; })); + modal.querySelectorAll('.sched-end').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end = el.value; })); + modal.querySelectorAll('.sched-eod').forEach(el => el.addEventListener('change', () => { + blocks[+el.dataset.idx].end = el.checked ? '24:00' : '17:00'; + render(); + })); + modal.querySelectorAll('.sched-sd').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start_date = el.value; })); + modal.querySelectorAll('.sched-ed').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end_date = el.value; })); + modal.querySelectorAll('.sched-remove').forEach(btn => btn.addEventListener('click', () => { blocks.splice(+btn.dataset.idx, 1); render(); })); + document.getElementById('schedAddBlock').addEventListener('click', () => { + blocks.push({ days: [0, 1, 2, 3, 4, 5, 6], start: '09:00', end: '17:00', start_date: '', end_date: '' }); + render(); + }); + document.getElementById('schedCancel').addEventListener('click', () => modal.remove()); + document.getElementById('schedSave').addEventListener('click', doSave); + } + + async function doSave() { + const payload = blocks.map(b => ({ + days: b.days, start: b.start, end: b.end, + start_date: b.start_date || null, end_date: b.end_date || null + })); + const err = validateScheduleBlocks(payload); + if (err) { showToast(err, 'error'); return; } + try { + const saved = await api.setItemSchedules(currentPlaylistId, item.id, payload); + item.schedules = saved; + modal.remove(); + // Saving makes the playlist a DRAFT — surface the re-publish step explicitly. + showToast(payload.length ? t('itemsched.toast.saved') : t('itemsched.toast.cleared')); + const playlist = await api.getPlaylist(currentPlaylistId); + renderItems(playlist.items || []); + refreshAfterMutation(); + } catch (e) { + showToast(e.message, 'error'); + } + } + + modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); }); + render(); +} diff --git a/server/db/database.js b/server/db/database.js index fa65db6..47d4861 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -73,6 +73,12 @@ const migrations = [ // Layout & zone support on devices and assignments 'ALTER TABLE devices ADD COLUMN layout_id TEXT', 'ALTER TABLE devices ADD COLUMN timezone TEXT DEFAULT \'UTC\'', + // #74/#75: player-reported clock, for effective-timezone resolution + the + // dashboard clock-skew indicator. reported_timezone = player OS IANA zone; + // reported_utc = device's claimed UTC (ms); reported_at = server receipt (s). + 'ALTER TABLE devices ADD COLUMN reported_timezone TEXT', + 'ALTER TABLE devices ADD COLUMN reported_utc INTEGER', + 'ALTER TABLE devices ADD COLUMN reported_at INTEGER', 'ALTER TABLE devices ADD COLUMN wall_id TEXT', 'ALTER TABLE devices ADD COLUMN team_id TEXT', 'ALTER TABLE assignments ADD COLUMN zone_id TEXT', @@ -205,6 +211,11 @@ for (const sql of migrations) { } if (_migApplied > 0) console.log(`[migrate] applied ${_migApplied} new column migration(s)`); +// #74/#75 per-item schedules: the playlist_item_schedules table is created +// idempotently by schema.sql (CREATE TABLE IF NOT EXISTS, run every boot, so it +// self-applies on upgrade). Record it in schema_migrations for observability. +try { db.prepare("INSERT OR IGNORE INTO schema_migrations (id) VALUES ('phase7_playlist_item_schedules')").run(); } catch { /* schema_migrations not ready yet */ } + // Fix assignments table: make content_id nullable (SQLite requires table rebuild) try { const colInfo = db.prepare("PRAGMA table_info(assignments)").all(); diff --git a/server/db/schema.sql b/server/db/schema.sql index d295581..1082214 100644 --- a/server/db/schema.sql +++ b/server/db/schema.sql @@ -354,6 +354,26 @@ CREATE TABLE IF NOT EXISTS playlist_items ( updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) ); +-- Per-playlist-item schedule blocks (#74 dayparting + #75 expiry). 1-to-many: +-- an item with ZERO rows here is always on; otherwise it shows when device-local +-- "now" matches at least one block. Wall-clock rules (local HH:MM + local dates), +-- evaluated on the device via the shared evaluator (server/lib/schedule-eval.js). +-- Pure child of playlist_items: cascade-deleted, and tenant isolation flows +-- through the parent item/playlist, so no workspace_id is needed here. +CREATE TABLE IF NOT EXISTS playlist_item_schedules ( + id TEXT PRIMARY KEY, + playlist_item_id INTEGER NOT NULL REFERENCES playlist_items(id) ON DELETE CASCADE, + active_days TEXT NOT NULL DEFAULT '0,1,2,3,4,5,6', -- comma-separated 0(Sun)-6(Sat) + start_time TEXT NOT NULL DEFAULT '00:00', -- local HH:MM + end_time TEXT NOT NULL DEFAULT '24:00', -- local HH:MM ("24:00" = end of day) + start_date TEXT, -- local YYYY-MM-DD, nullable = no lower bound + end_date TEXT, -- local YYYY-MM-DD, nullable = no upper bound + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')), + updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now')) +); +CREATE INDEX IF NOT EXISTS idx_playlist_item_schedules_item ON playlist_item_schedules(playlist_item_id); + -- ===================== ACTIVITY LOG ===================== CREATE TABLE IF NOT EXISTS activity_log ( diff --git a/server/lib/schedule-eval.js b/server/lib/schedule-eval.js new file mode 100644 index 0000000..79b76fa --- /dev/null +++ b/server/lib/schedule-eval.js @@ -0,0 +1,100 @@ +// Canonical per-playlist-item schedule evaluator (#74 dayparting + #75 expiry). +// +// CONTRACT: shared/schedule-vectors.json. The JS server, the web player, and the +// Tizen player all consume this exact module; the Android (Kotlin) port must agree +// with the same vectors. If an implementation disagrees with a vector, the +// implementation is wrong. +// +// Time model: instants are UTC; schedule blocks are LOCAL wall-clock rules. We take +// utc_now, convert to device-local wall-clock via the device's IANA timezone (DST +// handled by Intl), then test the block(s). Blocks are never stored/transmitted in +// UTC - that would break across DST and zone changes. +// +// Block = { days:[0-6 (0=Sun)], start:"HH:MM", end:"HH:MM"|"24:00", +// start_date:"YYYY-MM-DD"|null, end_date:"YYYY-MM-DD"|null } +// - within a block: day AND date AND time must all pass +// - blocks OR together; >=1 match = active +// - zero blocks = always active (this is the "no schedule = always plays" fallback) +// - time window is [start, end): start inclusive, end exclusive ("24:00" = end of day) +// - start > end means the window crosses midnight; the day/date test anchors to the +// day the window STARTED (a Fri 22:00-02:00 block is active Sat 01:00). +// +// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval). + +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.ScheduleEval = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + var DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; + + // UTC instant -> device-local {y, mo(1-12), day, dow(0-6), min(0-1439)}. + // ianaTz falsy -> trust the runtime's own local clock as-is (the device's OS time). + function localParts(utcNow, ianaTz) { + var d = (utcNow instanceof Date) ? utcNow : new Date(utcNow); + if (!ianaTz) { + return { y: d.getFullYear(), mo: d.getMonth() + 1, day: d.getDate(), dow: d.getDay(), min: d.getHours() * 60 + d.getMinutes() }; + } + var fmt = new Intl.DateTimeFormat('en-US', { + timeZone: ianaTz, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hourCycle: 'h23', weekday: 'short' + }); + var p = {}, parts = fmt.formatToParts(d); + for (var i = 0; i < parts.length; i++) p[parts[i].type] = parts[i].value; + var hh = parseInt(p.hour, 10) % 24; // h23 yields 00-23; guard against env quirks + return { y: +p.year, mo: +p.month, day: +p.day, dow: DOW[p.weekday], min: hh * 60 + (+p.minute) }; + } + + function hm(s) { var a = String(s).split(':'); return (+a[0]) * 60 + (+a[1]); } // "24:00" -> 1440 + + function ymd(y, mo, day) { function p2(n) { return (n < 10 ? '0' : '') + n; } return y + '-' + p2(mo) + '-' + p2(day); } + + // Pure calendar arithmetic (UTC Date used only for date math, never time/DST). + function addDays(y, mo, day, delta) { + var d = new Date(Date.UTC(y, mo - 1, day)); + d.setUTCDate(d.getUTCDate() + delta); + return { y: d.getUTCFullYear(), mo: d.getUTCMonth() + 1, day: d.getUTCDate() }; + } + + function dayOk(dow, days) { + if (!days || !days.length) return false; + for (var i = 0; i < days.length; i++) if (days[i] === dow) return true; + return false; + } + + function dateOk(dateStr, startDate, endDate) { + if (startDate && dateStr < startDate) return false; // ISO YYYY-MM-DD sorts lexicographically + if (endDate && dateStr > endDate) return false; // inclusive on both ends + return true; + } + + function blockMatches(b, L) { + var s = hm(b.start), e = hm(b.end), now = L.min; + if (s <= e) { + // same-day window [s, e), anchored to today + if (now < s || now >= e) return false; + return dayOk(L.dow, b.days) && dateOk(ymd(L.y, L.mo, L.day), b.start_date, b.end_date); + } + // overnight wrap + if (now >= s) { + // before-midnight portion: anchor = today + return dayOk(L.dow, b.days) && dateOk(ymd(L.y, L.mo, L.day), b.start_date, b.end_date); + } + if (now < e) { + // after-midnight portion: anchor = the day it started = yesterday (device-local) + var y = addDays(L.y, L.mo, L.day, -1); + return dayOk((L.dow + 6) % 7, b.days) && dateOk(ymd(y.y, y.mo, y.day), b.start_date, b.end_date); + } + return false; + } + + function isItemActiveNow(blocks, utcNow, ianaTz) { + if (!blocks || blocks.length === 0) return true; // no schedule = always on + var L = localParts(utcNow, ianaTz); + for (var i = 0; i < blocks.length; i++) if (blockMatches(blocks[i], L)) return true; + return false; + } + + return { isItemActiveNow: isItemActiveNow, _localParts: localParts, _blockMatches: blockMatches }; +}); diff --git a/server/player/index.html b/server/player/index.html index 1b42d86..c745a4d 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -218,6 +218,7 @@
+ + diff --git a/tizen/js/app.js b/tizen/js/app.js index a1c62ac..40b0565 100644 --- a/tizen/js/app.js +++ b/tizen/js/app.js @@ -95,6 +95,9 @@ function telemetry() { var t = { uptime_seconds: Math.floor(performance.now() / 1000) }; + // #74/#75: OS timezone + UTC clock (effective-tz resolution + skew indicator) + try { t.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || null; } catch (e) { t.timezone = null; } + t.device_utc = Date.now(); try { tizen.systeminfo.getPropertyValue('BATTERY', function (b) { t.battery_level = Math.round((b.level || 0) * 100); @@ -227,6 +230,7 @@ // If we have content + we're paired, make sure we're on the stage. if (elPairing.classList.contains('hidden') === false) show(elStage); else if (elStage.classList.contains('hidden')) show(elStage); + player.setTimezone(payload.timezone || null); // #74/#75: effective tz for schedule eval player.load(payload.assignments || []); } diff --git a/tizen/js/player.js b/tizen/js/player.js index 9683465..2621b14 100644 --- a/tizen/js/player.js +++ b/tizen/js/player.js @@ -7,6 +7,17 @@ * widget -> iframe of {server}/api/widgets/{id}/render for duration_sec * Content file URL: {server}/api/content/{content_id}/file (public) */ +// Minimal i18n for the Tizen player (no shared i18n module here). Falls back to en. +var TIZEN_I18N = { + en: { nothing_scheduled: 'Nothing scheduled right now', no_content: 'No content assigned yet' }, + es: { nothing_scheduled: 'No hay nada programado en este momento', no_content: 'Aún no hay contenido asignado' }, + fr: { nothing_scheduled: 'Rien de programmé pour le moment', no_content: 'Aucun contenu attribué pour l’instant' }, + de: { nothing_scheduled: 'Derzeit ist nichts geplant', no_content: 'Noch kein Inhalt zugewiesen' }, + pt: { nothing_scheduled: 'Nada programado no momento', no_content: 'Nenhum conteúdo atribuído ainda' } +}; +var TZ_LANG = (function () { try { return (localStorage.getItem('rd_lang') || navigator.language || 'en').split('-')[0]; } catch (e) { return 'en'; } })(); +function tzt(k) { return (TIZEN_I18N[TZ_LANG] && TIZEN_I18N[TZ_LANG][k]) || TIZEN_I18N.en[k] || k; } + function PlaylistPlayer(stageEl, getBase) { this.stage = stageEl; this.getBase = getBase; @@ -14,6 +25,7 @@ function PlaylistPlayer(stageEl, getBase) { this.index = 0; this.timer = null; this.sig = ''; + this.timezone = null; // #74/#75: device-effective IANA tz for schedule eval this.DEFAULT_DURATION = 10; this.MIN_DURATION = 3; } @@ -26,14 +38,15 @@ PlaylistPlayer.prototype.load = function (assignments) { items.sort(function (a, b) { return (a.sort_order || 0) - (b.sort_order || 0); }); var sig = JSON.stringify(items.map(function (a) { - return [a.content_id, a.widget_id, a.remote_url, a.duration_sec, a.mime_type]; + // #74/#75: include schedules so a schedule edit (same content) re-renders. + return [a.content_id, a.widget_id, a.remote_url, a.duration_sec, a.mime_type, a.schedules || []]; })); if (sig === this.sig && this.items.length) return; // unchanged, keep playing this.sig = sig; this.items = items; this.index = 0; - this.playCurrent(); + this.startPlayback(); }; PlaylistPlayer.prototype.stop = function () { @@ -52,7 +65,7 @@ PlaylistPlayer.prototype.idle = function () { this.clearStage(); this.stage.innerHTML = '

ScreenTinker

' + - '

No content assigned yet

'; + '

' + tzt('no_content') + '

'; }; PlaylistPlayer.prototype.durationMs = function (item) { @@ -69,7 +82,10 @@ PlaylistPlayer.prototype.contentUrl = function (item) { PlaylistPlayer.prototype.advance = function () { if (!this.items.length) return; - this.index = (this.index + 1) % this.items.length; + // #74/#75: advance to the next schedule-active item; idle if none. + var idx = this.nextActiveIndex(this.index); + if (idx < 0) { this.nothingScheduled(); return; } + this.index = idx; this.playCurrent(); }; @@ -79,12 +95,65 @@ PlaylistPlayer.prototype.schedule = function (ms) { this.timer = setTimeout(function () { self.advance(); }, ms); }; +// #74/#75: per-item schedule gating (mirrors the web/Android players). No blocks = +// always on. Fails open: any evaluator error means the item plays. +PlaylistPlayer.prototype.setTimezone = function (tz) { this.timezone = tz || null; }; + +PlaylistPlayer.prototype.scheduleAllows = function (item) { + if (!item || !item.schedules || !item.schedules.length) return true; + try { + return (typeof ScheduleEval !== 'undefined') + ? ScheduleEval.isItemActiveNow(item.schedules, Date.now(), this.timezone) : true; + } catch (e) { return true; } +}; + +PlaylistPlayer.prototype.anyScheduled = function () { + for (var i = 0; i < this.items.length; i++) { + if (this.items[i].schedules && this.items[i].schedules.length) return true; + } + return false; +}; + +PlaylistPlayer.prototype.firstActiveIndex = function () { + for (var i = 0; i < this.items.length; i++) if (this.scheduleAllows(this.items[i])) return i; + return -1; +}; + +PlaylistPlayer.prototype.nextActiveIndex = function (from) { + if (!this.items.length) return -1; + for (var i = 1; i <= this.items.length; i++) { + var idx = (from + i) % this.items.length; + if (this.scheduleAllows(this.items[idx])) return idx; + } + return -1; +}; + +PlaylistPlayer.prototype.startPlayback = function () { + if (!this.items.length) { this.idle(); return; } + var idx = this.firstActiveIndex(); + if (idx < 0) { this.nothingScheduled(); return; } + this.index = idx; + this.playCurrent(); +}; + +// Every item filtered out: idle and re-check shortly (a daypart may open). +PlaylistPlayer.prototype.nothingScheduled = function () { + if (this.timer) { clearTimeout(this.timer); this.timer = null; } + this.clearStage(); + this.stage.innerHTML = + '

ScreenTinker

' + + '

' + tzt('nothing_scheduled') + '

'; + var self = this; + this.timer = setTimeout(function () { self.startPlayback(); }, 30000); +}; + PlaylistPlayer.prototype.playCurrent = function () { if (this.timer) { clearTimeout(this.timer); this.timer = null; } if (!this.items.length) { this.idle(); return; } var item = this.items[this.index]; - var single = this.items.length === 1; + // Scheduled playlists cycle even with one active item so windows re-evaluate. + var single = this.items.length === 1 && !this.anyScheduled(); var mime = item.mime_type || ''; this.clearStage(); diff --git a/tizen/js/schedule-eval.js b/tizen/js/schedule-eval.js new file mode 100644 index 0000000..79b76fa --- /dev/null +++ b/tizen/js/schedule-eval.js @@ -0,0 +1,100 @@ +// Canonical per-playlist-item schedule evaluator (#74 dayparting + #75 expiry). +// +// CONTRACT: shared/schedule-vectors.json. The JS server, the web player, and the +// Tizen player all consume this exact module; the Android (Kotlin) port must agree +// with the same vectors. If an implementation disagrees with a vector, the +// implementation is wrong. +// +// Time model: instants are UTC; schedule blocks are LOCAL wall-clock rules. We take +// utc_now, convert to device-local wall-clock via the device's IANA timezone (DST +// handled by Intl), then test the block(s). Blocks are never stored/transmitted in +// UTC - that would break across DST and zone changes. +// +// Block = { days:[0-6 (0=Sun)], start:"HH:MM", end:"HH:MM"|"24:00", +// start_date:"YYYY-MM-DD"|null, end_date:"YYYY-MM-DD"|null } +// - within a block: day AND date AND time must all pass +// - blocks OR together; >=1 match = active +// - zero blocks = always active (this is the "no schedule = always plays" fallback) +// - time window is [start, end): start inclusive, end exclusive ("24:00" = end of day) +// - start > end means the window crosses midnight; the day/date test anchors to the +// day the window STARTED (a Fri 22:00-02:00 block is active Sat 01:00). +// +// Dependency-free UMD: Node (require) + browser/Tizen (window.ScheduleEval). + +(function (root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory(); + else root.ScheduleEval = factory(); +})(typeof self !== 'undefined' ? self : this, function () { + 'use strict'; + + var DOW = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }; + + // UTC instant -> device-local {y, mo(1-12), day, dow(0-6), min(0-1439)}. + // ianaTz falsy -> trust the runtime's own local clock as-is (the device's OS time). + function localParts(utcNow, ianaTz) { + var d = (utcNow instanceof Date) ? utcNow : new Date(utcNow); + if (!ianaTz) { + return { y: d.getFullYear(), mo: d.getMonth() + 1, day: d.getDate(), dow: d.getDay(), min: d.getHours() * 60 + d.getMinutes() }; + } + var fmt = new Intl.DateTimeFormat('en-US', { + timeZone: ianaTz, year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hourCycle: 'h23', weekday: 'short' + }); + var p = {}, parts = fmt.formatToParts(d); + for (var i = 0; i < parts.length; i++) p[parts[i].type] = parts[i].value; + var hh = parseInt(p.hour, 10) % 24; // h23 yields 00-23; guard against env quirks + return { y: +p.year, mo: +p.month, day: +p.day, dow: DOW[p.weekday], min: hh * 60 + (+p.minute) }; + } + + function hm(s) { var a = String(s).split(':'); return (+a[0]) * 60 + (+a[1]); } // "24:00" -> 1440 + + function ymd(y, mo, day) { function p2(n) { return (n < 10 ? '0' : '') + n; } return y + '-' + p2(mo) + '-' + p2(day); } + + // Pure calendar arithmetic (UTC Date used only for date math, never time/DST). + function addDays(y, mo, day, delta) { + var d = new Date(Date.UTC(y, mo - 1, day)); + d.setUTCDate(d.getUTCDate() + delta); + return { y: d.getUTCFullYear(), mo: d.getUTCMonth() + 1, day: d.getUTCDate() }; + } + + function dayOk(dow, days) { + if (!days || !days.length) return false; + for (var i = 0; i < days.length; i++) if (days[i] === dow) return true; + return false; + } + + function dateOk(dateStr, startDate, endDate) { + if (startDate && dateStr < startDate) return false; // ISO YYYY-MM-DD sorts lexicographically + if (endDate && dateStr > endDate) return false; // inclusive on both ends + return true; + } + + function blockMatches(b, L) { + var s = hm(b.start), e = hm(b.end), now = L.min; + if (s <= e) { + // same-day window [s, e), anchored to today + if (now < s || now >= e) return false; + return dayOk(L.dow, b.days) && dateOk(ymd(L.y, L.mo, L.day), b.start_date, b.end_date); + } + // overnight wrap + if (now >= s) { + // before-midnight portion: anchor = today + return dayOk(L.dow, b.days) && dateOk(ymd(L.y, L.mo, L.day), b.start_date, b.end_date); + } + if (now < e) { + // after-midnight portion: anchor = the day it started = yesterday (device-local) + var y = addDays(L.y, L.mo, L.day, -1); + return dayOk((L.dow + 6) % 7, b.days) && dateOk(ymd(y.y, y.mo, y.day), b.start_date, b.end_date); + } + return false; + } + + function isItemActiveNow(blocks, utcNow, ianaTz) { + if (!blocks || blocks.length === 0) return true; // no schedule = always on + var L = localParts(utcNow, ianaTz); + for (var i = 0; i < blocks.length; i++) if (blockMatches(blocks[i], L)) return true; + return false; + } + + return { isItemActiveNow: isItemActiveNow, _localParts: localParts, _blockMatches: blockMatches }; +});