From 938a43a466e84da1906f086f9946bb744fdba53f Mon Sep 17 00:00:00 2001 From: screentinker Date: Sat, 11 Jul 2026 14:24:31 -0500 Subject: [PATCH] Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(group-sync): synchronized playback per group (server + Android) [stage 1] Play a group's shared playlist in lockstep across its displays — start/end items together — by reusing the video-wall sync primitive with the spatial transform removed and keyed on group_id instead of wall_id. Server: - device_groups += sync_enabled + leader_device_id (optional pin). - buildPlaylistPayload emits a group_sync:{group_id, is_leader} block ONLY for a member whose playlist matches the group's shared playlist (playlist-match guard — a mismatched member is ignored, never synced). Membership via device_group_members. - Leader auto-election: pinned-if-online -> first online matching member -> stable fallback; computed (never persisted, so the operator's pin is preserved). - group:sync / group:sync-request relay among eligible members (mirrors wall:sync, guarded on membership + playlist match). - Self-heal: re-push payloads to group members on (re)connect so is_leader refreshes. - PUT /api/device-groups/:id accepts sync_enabled + leader_device_id and re-pushes. Android: - WallController is now mode-aware. WALL = transform + object-fit:fill + forced follower-mute (UNCHANGED — every wall branch is byte-equivalent when isGroup=false). GROUP = same leader/follower timing incl. the full video drift controller, but full-screen (no transform), normal fit, and per-item mute honored (no forced mute). - WebSocketService: emitGroupSync/emitGroupSyncRequest + onGroupSync/onGroupSyncRequest. - MainActivity: dispatch emit by mode, parseGroupConfig, onPlaylistUpdate group hook. Wall path is provably unchanged (additive mode, defaults to WALL). Kotlin compiles; server suite 407/407. Stage 2 (follow-up): web + Tizen parity, dashboard sync toggle + leader picker, offline-sweep leader promotion. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(group-sync): web + Tizen player parity [stage 2] Mirror the Android group-sync generalization in the two web-based players so a group with a shared playlist syncs across mixed player types, not just Android. Web player (server/player/index.html): - group:sync / group:sync-request handlers (index jump + latency-compensated video drift, same maths as wall:sync) — no audio policy here, per-item mute honored. - emitGroupSync() + applyGroupSync() (4Hz leader broadcast; follower sync-request). NO CSS transform, NO forced mute (unlike applyWallMode). - isFollower now also true for a group follower (suppresses self-advance). - handlePlaylistUpdate handles data.group_sync (mutually exclusive with wall). Tizen (tizen/js/player.js WallController + app.js): - WallController is mode-aware: WALL styles the slice + forced follower semantics (UNCHANGED when mode !== 'group'); GROUP clears any wall styling (full-screen) and drives leader/follower timing only. syncId()/clearStageStyle() helpers; emitSync/ onSync/onSyncRequest key the event + id off the mode. - app.js: group:sync/request socket handlers; onPlaylist enters group sync on a group_sync block, else exits — content renders through the normal single-zone path. Realigned the v4-exit-signal-phase3 TIZEN slice (682->696) shifted by the app.js edits. Wall path is provably unchanged in both (additive group mode). Server suite 407/407; both players' JS parse. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(group-sync): dashboard UI — sync toggle + leader picker [stage 3] On each group's header row: - "Sync" checkbox -> PUT /api/groups/:id { sync_enabled } enables synchronized playback; server re-pushes to members so they enter/exit sync mode. A hint notes it needs a group playlist and that a display on a different playlist is ignored. - When on, a "Leader: Auto / " picker -> { leader_device_id } (null = auto- elect, which self-heals; or pin a specific member to always lead when online). Adds api.updateGroup(id, data) and the en i18n strings (en-only, mirroring the existing per-group UI keys; the i18n parity test is apitoken-scoped). Frontend parses (ESM); server suite 407/407. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(group-sync): rework to clock/schedule sync + double-buffer + polish Replace the leader/follower relay model with clock/schedule sync. Every same-playlist member derives the identical (index, position) locally from a server-disciplined clock + the deterministic playlist schedule, so sync: - needs no server at play-time (offline-native), and - has no leader role to double-elect (kills the split-brain class the leaked WallController tick produced). Server - heartbeat-ack now carries server_ms + echoes client_ms for NTP-style clock discipline; the client caches the offset (survives an outage). - POST /groups/:id/resync -> group:resync (manual "Resync now"). - (kept: group_sync payload; leader machinery is now vestigial/ignored.) Clients (web / Tizen / Android) - Clock offset disciplined over the heartbeat, cached (localStorage / prefs). - Schedule engine: pos = (syncedNow mod Sum(duration_sec)) with a CANONICAL slot formula identical across platforms so mixed-platform groups can't drift. - Snap-on-load: a fresh clip hard-seeks ONCE to the exact position (was ~5s of gentle nudge to eat a ~0.3s load offset); steady-state keeps the gentle nudge. - Double buffer: warm the next clip a few s before the boundary -> instant switch, no black hold. Android pre-decodes on a throwaway surface so the swap doesn't flash one wrong-aspect (landscape-stretched) frame. - In-place duration edits: duration_sec dropped from the change signature and applied in place, so a duration edit re-anchors the schedule WITHOUT a restart. - Live-log shows discrete corrections (jump/align/seek) immediately; only the steady-state line is throttled. Android - Fix leaked WallController leader tick: onDestroy() now stops it (Handler on the main looper outlived the Activity -> zombie broadcaster / split-brain). Dashboard - Group leader picker -> "Resync now" button. Tests: heartbeat-ack clock fields + resync route; exit-signal .wgt slice realigned. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../com/remotedisplay/player/MainActivity.kt | 59 ++++- .../player/player/GroupScheduleController.kt | 143 ++++++++++++ .../player/player/MediaPlayerManager.kt | 107 +++++++-- .../player/player/PlaylistController.kt | 46 +++- .../player/player/WallController.kt | 57 +++-- .../player/service/WebSocketService.kt | 68 +++++- frontend/js/api.js | 2 + frontend/js/i18n/en.js | 7 + frontend/js/views/dashboard.js | 36 +++ server/db/database.js | 6 + server/player/index.html | 221 ++++++++++++++++-- server/routes/device-groups.js | 31 ++- server/test/group-sync-clock.test.js | 69 ++++++ server/test/v4-exit-signal-phase3.test.js | 10 +- server/ws/deviceSocket.js | 114 ++++++++- tizen/js/app.js | 43 +++- tizen/js/player.js | 200 ++++++++++++++-- 17 files changed, 1134 insertions(+), 85 deletions(-) create mode 100644 android/app/src/main/java/com/remotedisplay/player/player/GroupScheduleController.kt create mode 100644 server/test/group-sync-clock.test.js 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 bcd5202..4ccefd2 100644 --- a/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt +++ b/android/app/src/main/java/com/remotedisplay/player/MainActivity.kt @@ -34,6 +34,7 @@ import com.remotedisplay.player.player.PlaylistController import com.remotedisplay.player.player.PlaylistItem import com.remotedisplay.player.player.PipOverlay import com.remotedisplay.player.player.WallController +import com.remotedisplay.player.player.GroupScheduleController import com.remotedisplay.player.player.ZoneManager import com.remotedisplay.player.remote.ScreenshotCapture import com.remotedisplay.player.remote.TouchInjector @@ -57,6 +58,7 @@ class MainActivity : AppCompatActivity() { private lateinit var updateChecker: UpdateChecker private var zoneManager: ZoneManager? = null private lateinit var wallController: WallController + private lateinit var groupSchedule: GroupScheduleController private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer private lateinit var playerView: PlayerView @@ -235,11 +237,33 @@ class MainActivity : AppCompatActivity() { media = mediaPlayer, playlist = playlistController, deviceId = { config.deviceId }, - emitSync = { wallId, idx, contentId, posSec -> wsService?.emitWallSync(wallId, idx, contentId, posSec) }, - emitSyncRequest = { wallId -> wsService?.emitWallSyncRequest(wallId) }, + emitSync = { isGroup, id, idx, contentId, posSec -> + if (isGroup) wsService?.emitGroupSync(id, idx, contentId, posSec) + else wsService?.emitWallSync(id, idx, contentId, posSec) + }, + emitSyncRequest = { isGroup, id -> + if (isGroup) wsService?.emitGroupSyncRequest(id) else wsService?.emitWallSyncRequest(id) + }, applyTransform = { cfg -> applyWallTransform(cfg) } ) + // #group-sync: clock/schedule group sync (no leader, offline-native). Reads the disciplined + // clock from the bound service; streams diagnostics to the dashboard live-log (tag 'sync'). + groupSchedule = GroupScheduleController( + playlist = playlistController, + media = mediaPlayer, + syncedNow = { wsService?.syncedNowMs() ?: System.currentTimeMillis() }, + report = { msg -> wsService?.sendLog("sync", "info", msg) }, + // Double buffer: warm the NEXT clip's second player if it's a locally-cached video, so the + // boundary switch is a warm swap (no black hold). Non-video / uncached items just skip it. + onPreloadNext = { idx -> + val next = playlistController.itemAt(idx) + if (next != null && !next.isRemote && next.mimeType.startsWith("video/")) { + contentCache.getCachedFile(next.contentId)?.let { mediaPlayer.preloadVideo(it) } + } + } + ) + // Restore cached playlist for offline cold-start (play immediately from disk cache). // Catch Throwable (not just Exception) so an OOM or corrupt entry can't kill the app // before the WebSocket connects — that's the crash-loop scenario. If the cache is @@ -255,6 +279,10 @@ class MainActivity : AppCompatActivity() { playlistController.setTimezone(if (cached.isNull("timezone")) null else cached.optString("timezone", "").ifEmpty { null }) playlistController.updatePlaylist(assignments) playlistController.startIfNeeded() + // #group-sync: if this device was in a sync group, resume the schedule immediately + // from the cached clock offset — a reboot mid-outage comes back aligned, no server. + val cg = if (cached.isNull("group_sync")) null else cached.optJSONObject("group_sync") + if (cg != null) groupSchedule.apply(cg.optString("group_id")) } } catch (e: Throwable) { Log.w("MainActivity", "Failed to restore cached playlist, clearing cache: ${e.message}") @@ -372,6 +400,17 @@ class MainActivity : AppCompatActivity() { ) } + // #group-sync: fullscreen synchronized playback — no tile geometry, just id + leader role. + private fun parseGroupConfig(gs: JSONObject): WallController.WallConfig { + val zero = WallController.Rect(0f, 0f, 0f, 0f) + return WallController.WallConfig( + wallId = gs.optString("group_id", ""), + screen = zero, player = zero, rotation = 0, + isLeader = gs.optBoolean("is_leader", false), + mode = WallController.Mode.GROUP + ) + } + // Video-wall slice transform. The content view represents the whole wall (player_rect); // size + offset rootView so this screen's screen_rect fills the device viewport, content // stretched to fill (object-fit:fill parity, set on the views via MediaPlayerManager). @@ -452,10 +491,16 @@ class MainActivity : AppCompatActivity() { if (wallObj != null) { com.remotedisplay.player.util.DebugLog.i("Player", "Layout: VIDEO-WALL (${assignments.length()} assignments)") if (zoneManager?.hasZones() == true) zoneManager?.cleanup() + groupSchedule.exit() // wall and group are mutually exclusive wallController.apply(parseWallConfig(wallObj)) playlistController.updatePlaylist(assignments) } else { - wallController.exit() + // #group-sync: not a wall — enter clock/schedule group sync if the payload carries a + // group_sync block, else leave it. No leader/relay: the schedule tick drives index + + // position locally (offline-native). Content renders through the normal path below. + wallController.exit() // never in wall mode here + val groupObj = if (data.isNull("group_sync")) null else data.optJSONObject("group_sync") + if (groupObj != null) groupSchedule.apply(groupObj.optString("group_id")) else groupSchedule.exit() applyOrientation(data.optString("orientation", "landscape")) // Check for multi-zone layout @@ -663,6 +708,9 @@ class MainActivity : AppCompatActivity() { wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) } wsService?.onWallSyncRequest = { data -> if (::wallController.isInitialized) wallController.onSyncRequest(data) } + // #group-sync is clock/schedule now (no leader relay). The server only nudges an immediate + // re-align (dashboard "Resync now"); the schedule tick otherwise runs entirely locally. + wsService?.onGroupResync = { if (::groupSchedule.isInitialized) groupSchedule.resync() } // #109: PiP overlay show/clear (posted to the main thread by the service). wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) } @@ -1065,6 +1113,11 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { remoteStreaming = false + // 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. + if (::wallController.isInitialized) wallController.shutdown() + if (::groupSchedule.isInitialized) groupSchedule.shutdown() if (::downloadCoordinator.isInitialized) downloadCoordinator.shutdown() // cancel in-flight downloads (no orphan/leak) zoneManager?.cleanup() if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView diff --git a/android/app/src/main/java/com/remotedisplay/player/player/GroupScheduleController.kt b/android/app/src/main/java/com/remotedisplay/player/player/GroupScheduleController.kt new file mode 100644 index 0000000..f7f01a4 --- /dev/null +++ b/android/app/src/main/java/com/remotedisplay/player/player/GroupScheduleController.kt @@ -0,0 +1,143 @@ +package com.remotedisplay.player.player + +import android.os.Handler +import android.os.Looper +import android.util.Log +import kotlin.math.abs + +/** + * #group-sync — clock/schedule group synchronization. Native Kotlin port of the web player's + * groupScheduleTick (server/player/index.html) and the Tizen GroupSyncController. + * + * Unlike a video wall there is NO leader and NO server relay of positions. Every same-playlist + * member lays the deterministic playlist schedule (PlaylistController.groupScheduleTarget) on a + * server-DISCIPLINED clock ([syncedNow]) and derives the identical (index, position) locally. That + * makes group sync: + * - offline-native: it needs no server at play-time (the clock offset is cached), and + * - split-brain-proof: there is no leader role to double-elect (the class of bug that the leaked + * WallController tick produced). + * + * The 4Hz tick runs on the main looper. It reuses PlaylistController.wallFollower (loop + no local + * auto-advance) so the schedule alone drives index transitions, and for video applies the same + * seek/nudge drift maths the wall uses — but toward the SCHEDULE target, not a leader broadcast. + */ +class GroupScheduleController( + private val playlist: PlaylistController, + private val media: MediaPlayerManager, + private val syncedNow: () -> Long, + private val report: (String) -> Unit, + // #group-sync double buffer: called ~PRELOAD_LEAD_SEC before a boundary with the NEXT item's index + // so the host can resolve its file and warm the second player. No-op host is fine (falls back to a + // cold prepare = the old brief hold). + private val onPreloadNext: (Int) -> Unit = {} +) { + private val handler = Handler(Looper.getMainLooper()) + private var tick: Runnable? = null + private var groupId: String? = null + private var dbgLast = 0L + private var preloadedForIndex = -1 + private val PRELOAD_LEAD_SEC = 6f + // Set whenever the schedule moves us to a new item (or on first entry). The FIRST video correction + // after a load is an unconditional hard-seek to the exact schedule position — "load and hold" — + // instead of the gentle ±3% nudge, which would take ~10s to eat the ~0.3s load offset (the "5s to + // sync" symptom). After that one snap, steady-state drift rides the gentle nudge as before. + private var alignPending = true + private var lastAlignedIndex = -1 + + val isActive: Boolean get() = groupId != null + + /** Enter/refresh group sync for [gid]. Idempotent. */ + fun apply(gid: String) { + val first = groupId == null + groupId = gid + // Group member: loop + no local auto-advance (the schedule tick owns the index); full-screen; + // per-item mute honored (displays are spread out, so no forced follower mute like a wall). + playlist.setWallFollower(true) + media.setVideoLooping(true) + media.setWallMode(false) + media.setWallMute(false) + alignPending = true; lastAlignedIndex = -1 // snap the first item into sync on entry + stopTimer() + tick = object : Runnable { + override fun run() { doTick(); handler.postDelayed(this, 250) } + } + handler.post(tick!!) // align immediately, then 4Hz + report("group-sync ${if (first) "entered" else "refresh"} group=${gid.take(8)}") + Log.i("GroupSchedule", "apply group=$gid") + } + + /** Leave group sync and restore normal (self-advancing) playback. */ + fun exit() { + if (groupId == null && tick == null) return + stopTimer() + groupId = null + playlist.setWallFollower(false) + media.setVideoLooping(false) + report("group-sync exited") + Log.i("GroupSchedule", "exit") + } + + /** Server-nudged immediate re-align (dashboard "Resync now"). */ + fun resync() { if (groupId != null) { report("manual resync"); doTick() } } + + /** + * Hard teardown for Activity destruction — kills the tick so it can't outlive the Activity on the + * main looper (the same leak the WallController.shutdown() fix addresses). Called from onDestroy. + */ + fun shutdown() { stopTimer(); groupId = null } + + private fun stopTimer() { + tick?.let { handler.removeCallbacks(it) } + tick = null + } + + private fun doTick() { + if (groupId == null) return + val sn = syncedNow() + val t = playlist.groupScheduleTarget(sn) ?: return + var action = "hold" + // Double buffer: warm the next clip ~PRELOAD_LEAD_SEC before the boundary (once per boundary). + if (t.nextIndex != t.index && t.secToBoundary in 0f..PRELOAD_LEAD_SEC && preloadedForIndex != t.nextIndex) { + onPreloadNext(t.nextIndex); preloadedForIndex = t.nextIndex + } + if (t.index != playlist.getIndex()) { + playlist.gotoIndex(t.index) + preloadedForIndex = -1 // re-arm preload for the next boundary + action = "jump>${t.index}" + } else if (media.isPlayingVideo()) { + val durMs = media.durationMs() + if (durMs > 0) { + val dur = durMs / 1000f + val target = t.posSec % dur // loop-safe when the slot > clip length + val drift = media.currentPositionMs() / 1000f - target + val ad = abs(drift) + // A fresh item (index changed since our last align) snaps ONCE to the exact position — + // load-and-hold — so it doesn't spend ~10s nudging away a ~0.3s load offset. + if (playlist.getIndex() != lastAlignedIndex) alignPending = true + when { + alignPending -> { + if (ad > 0.05f) media.seekExact((target * 1000).toLong()) + media.setSpeed(1.0f); alignPending = false; lastAlignedIndex = playlist.getIndex() + action = "align ${fmt(drift)}" + } + ad > 0.3f -> { media.seekExact((target * 1000).toLong()); media.setSpeed(1.0f); action = "seek ${fmt(drift)}" } + ad > 0.05f -> { media.setSpeed(if (drift > 0) 0.97f else 1.03f); action = "nudge ${fmt(drift)}" } + else -> media.setSpeed(1.0f) + } + } + } + // Log discrete corrections (jump/align/seek) the instant they happen so the transition is + // visible; only the routine steady-state line (hold/nudge) is throttled to ~1Hz. Otherwise the + // one-tick "align" on load gets sampled over by a later "hold"/"nudge" and reads misleadingly. + val now = System.currentTimeMillis() + val discrete = action.startsWith("jump") || action.startsWith("align") || action.startsWith("seek") + if (discrete || now - dbgLast > 1000) { + dbgLast = now + val line = "idx=${playlist.getIndex()} tgt=${t.index} pos=${fmt(t.posSec)} $action" + Log.i("GroupSchedule", line) + report(line) + } + } + + private fun fmt(f: Float): String = String.format("%.2f", f) +} diff --git a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt index d954ec6..2daae9a 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/MediaPlayerManager.kt @@ -1,8 +1,10 @@ package com.remotedisplay.player.player import android.content.Context +import android.graphics.SurfaceTexture import android.net.Uri import android.util.Log +import android.view.Surface import android.webkit.WebChromeClient import android.webkit.WebView import android.webkit.WebViewClient @@ -30,6 +32,19 @@ class MediaPlayerManager( // Wall mode: followers must stay muted even as the leader's sync switches them // to a new (possibly unmuted) item, so the mute has to survive each playVideo. private var wallMute = false + // #group-sync loop state, tracked so it can be applied to a freshly-swapped double-buffer player. + private var videoLooping = false + // #group-sync double buffer: a second ExoPlayer that pre-opens/pre-buffers the NEXT clip so the + // boundary switch is a warm swap (~100-300ms) instead of a cold prepare (~1-2s black hold). Only + // engaged when preloadVideo() is called ahead of a boundary (group sync); the wall/solo paths are + // untouched (they never preload, so playVideo takes the normal cold path). + private var preloadPlayer: ExoPlayer? = null + private var preloadedFile: File? = null + // Throwaway offscreen surface for the preload player: it forces the preload clip to decode frame 0 + // and populate its video size BEFORE the swap, so PlayerView doesn't reset the aspect to "fill" + // (a one-frame landscape stretch) while it waits for the new player's first video-size report. + private var warmTexture: SurfaceTexture? = null + private var warmSurface: Surface? = null enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE, WIDGET } @@ -37,25 +52,31 @@ class MediaPlayerManager( setupExoPlayer() } + // Build a player with the shared end/error listener so BOTH the active and the preload player + // advance/self-heal identically once either is the visible one. + private fun buildPlayer(): ExoPlayer = ExoPlayer.Builder(context).build().also { player -> + player.addListener(object : Player.Listener { + override fun onPlaybackStateChanged(playbackState: Int) { + // Only the ACTIVE (view-attached) player drives advance; ignore the preload player's + // own state changes (it's parked with playWhenReady=false and never ENDs while parked). + if (playbackState == Player.STATE_ENDED && player === exoPlayer) onVideoComplete() + } + // Root-2: a corrupt/undecodable video used to freeze the playlist forever — only + // STATE_ENDED advanced, and an error goes to STATE_IDLE, so onVideoComplete never + // fired. Treat a playback error like a completion so the loop moves on instead of + // wedging on the broken item (mirrors the web/.wgt onerror -> advance). + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + Log.e("MediaPlayerManager", "Playback error (${error.errorCodeName}) — advancing: ${error.message}") + if (player === exoPlayer) onVideoComplete() + } + }) + } + private fun setupExoPlayer() { - exoPlayer = ExoPlayer.Builder(context).build().also { player -> - playerView.player = player - player.addListener(object : Player.Listener { - override fun onPlaybackStateChanged(playbackState: Int) { - if (playbackState == Player.STATE_ENDED) { - onVideoComplete() - } - } - // Root-2: a corrupt/undecodable video used to freeze the playlist forever — only - // STATE_ENDED advanced, and an error goes to STATE_IDLE, so onVideoComplete never - // fired. Treat a playback error like a completion so the loop moves on instead of - // wedging on the broken item (mirrors the web/.wgt onerror -> advance). - override fun onPlayerError(error: androidx.media3.common.PlaybackException) { - Log.e("MediaPlayerManager", "Playback error (${error.errorCodeName}) — advancing: ${error.message}") - onVideoComplete() - } - }) - } + // Hold the last frame instead of flashing black during a reset/prepare — turns any residual + // switch gap into a brief freeze-frame rather than a black hold. + try { playerView.setKeepContentOnPlayerReset(true) } catch (e: Throwable) {} + exoPlayer = buildPlayer().also { playerView.player = it } } // #129: remembered so the live device:mute-changed toggle knows YouTube's current @@ -154,8 +175,28 @@ class MediaPlayerManager( }.start() } + /** + * #group-sync double buffer: pre-open/pre-buffer the NEXT clip on the parked second player so the + * upcoming boundary switch (playVideo of the same file) is a warm swap instead of a cold prepare. + * Cheap to call every tick — it no-ops if this file is already the preloaded one. Main thread only. + */ + fun preloadVideo(file: File) { + if (preloadedFile?.absolutePath == file.absolutePath) return + val p = preloadPlayer ?: buildPlayer().also { preloadPlayer = it } + if (warmSurface == null) { warmTexture = SurfaceTexture(0).apply { setDefaultBufferSize(16, 16) }; warmSurface = Surface(warmTexture) } + p.apply { + setVideoSurface(warmSurface) // decode frame 0 offscreen -> video size known pre-swap + volume = 0f // silent while parked; real volume set on swap + repeatMode = if (videoLooping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + setMediaItem(MediaItem.fromUri(Uri.fromFile(file))) + playWhenReady = false // buffer/parse/decode-frame-0 now, don't start + prepare() + } + preloadedFile = file + Log.i("MediaPlayerManager", "Preloaded next video: ${file.name}") + } + fun playVideo(file: File, muted: Boolean = false) { - Log.i("MediaPlayerManager", "Playing video: ${file.absolutePath} (muted=$muted)") currentType = MediaType.VIDEO // Show player, hide image @@ -163,6 +204,27 @@ class MediaPlayerManager( imageView.visibility = android.view.View.GONE youtubeWebView?.visibility = android.view.View.GONE + // Warm swap: if this exact file was preloaded, promote the parked player instead of a cold + // prepare — the container is already open/buffered so the first frame renders near-instantly. + val pp = preloadPlayer + if (pp != null && preloadedFile?.absolutePath == file.absolutePath) { + Log.i("MediaPlayerManager", "Playing video (warm swap): ${file.name}") + val old = exoPlayer + exoPlayer = pp + preloadPlayer = old + preloadedFile = null + pp.apply { + volume = if (muted || wallMute) 0f else 1f + repeatMode = if (videoLooping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + playWhenReady = true + } + playerView.player = pp + // Park the previous active player as the new preload slot (idle until the next preloadVideo). + old?.apply { playWhenReady = false; clearMediaItems() } + return + } + + Log.i("MediaPlayerManager", "Playing video: ${file.absolutePath} (muted=$muted)") exoPlayer?.apply { volume = if (muted || wallMute) 0f else 1f setMediaItem(MediaItem.fromUri(Uri.fromFile(file))) @@ -206,6 +268,11 @@ class MediaPlayerManager( fun release() { exoPlayer?.release() exoPlayer = null + preloadPlayer?.release() + preloadPlayer = null + preloadedFile = null + warmSurface?.release(); warmSurface = null + warmTexture?.release(); warmTexture = null } fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true) @@ -261,7 +328,9 @@ class MediaPlayerManager( * if the leader's next index sync is slightly late; the leader plays through normally. */ fun setVideoLooping(loop: Boolean) { + videoLooping = loop exoPlayer?.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF + preloadPlayer?.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF } /** 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 a8b474f..4118a44 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 @@ -87,6 +87,9 @@ class PlaylistController( val currentItem: PlaylistItem? get() = if (currentIndex in items.indices) items[currentIndex] else null + // #group-sync double buffer: resolve an item by index (for preloading the next clip). + fun itemAt(index: Int): PlaylistItem? = if (index in items.indices) items[index] else null + val currentContentId: String? get() = currentItem?.contentId @@ -125,6 +128,10 @@ class PlaylistController( // #129: include muted too, so a mute-only change (same content) re-renders with the // new flag instead of being de-duped (the real-time event handles the live toggle; // this makes a published mute persist across reloads). + // Signature is STRUCTURAL only — content/widget identity, order, mute, schedules. durationSec is + // deliberately EXCLUDED so a duration-only edit does NOT force a restart; instead it's applied + // IN PLACE below (the #group-sync schedule tick + the solo advance timer read durationSec live), + // so timing edits take effect without interrupting playback or resetting the index. fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" + it.schedules.joinToString(";") { b -> b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "") @@ -134,7 +141,16 @@ class PlaylistController( val playlistChanged = oldContentIds != newContentIds if (!playlistChanged && items.isNotEmpty()) { - Log.i("PlaylistController", "Playlist unchanged (${items.size} items), not interrupting playback") + // In-place duration refresh: patch durationSec on the existing items so a duration edit + // takes effect immediately without a restart. For a sync group this re-anchors the shared + // schedule (all members recompute the new period together); for solo it's picked up on the + // next advance. No index reset, no video reload. + var durChanged = false + for (i in items.indices) { + val ni = newItems.getOrNull(i) ?: continue + if (items[i].durationSec != ni.durationSec) { items[i] = items[i].copy(durationSec = ni.durationSec); durChanged = true } + } + Log.i("PlaylistController", if (durChanged) "Durations updated in place (${items.size} items), not interrupting" else "Playlist unchanged (${items.size} items), not interrupting playback") return } @@ -288,6 +304,34 @@ class PlaylistController( item.schedules.isEmpty() || ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone) + // #group-sync schedule engine. Lay the deterministic playlist (each active item occupies a + // CANONICAL slot, dayparted items skipped) on the server-disciplined clock and derive the target + // (index, position). The slot formula MUST be byte-identical to the web and Tizen players + // (max(1, duration_sec||10)*1000) or a mixed-platform group would diverge — so it deliberately + // does NOT use this player's solo-playback duration clamp. + // nextIndex + secToBoundary let the double buffer preload the upcoming clip a few seconds early. + // For a single active slot, nextIndex == index (the caller skips preloading a self-loop). + data class GroupTarget(val index: Int, val posSec: Float, val nextIndex: Int, val secToBoundary: Float) + private fun slotMs(it: PlaylistItem): Long = (if (it.durationSec > 0) it.durationSec else 10).toLong() * 1000L + fun groupScheduleTarget(syncedNowMs: Long): GroupTarget? { + if (items.isEmpty()) return null + var acc = 0L + val slots = ArrayList>() // index, startMs, durMs + for (i in items.indices) { + if (!scheduleAllows(items[i])) continue + val d = slotMs(items[i]); slots.add(Triple(i, acc, d)); acc += d + } + if (slots.isEmpty() || acc <= 0L) return null + val period = acc + val phase = ((syncedNowMs % period) + period) % period + var chosenIdx = slots.size - 1 + for (k in slots.indices) { val s = slots[k]; if (phase >= s.second && phase < s.second + s.third) { chosenIdx = k; break } } + val chosen = slots[chosenIdx] + val next = slots[(chosenIdx + 1) % slots.size] + val secToBoundary = (chosen.second + chosen.third - phase) / 1000f + return GroupTarget(chosen.first, (phase - chosen.second) / 1000f, next.first, secToBoundary) + } + // Playable NOW = schedule-active AND its content is downloaded/available. private fun playableNow(i: Int): Boolean = i in items.indices && scheduleAllows(items[i]) && contentReady(items[i]) diff --git a/android/app/src/main/java/com/remotedisplay/player/player/WallController.kt b/android/app/src/main/java/com/remotedisplay/player/player/WallController.kt index f33edf0..9e68dbc 100644 --- a/android/app/src/main/java/com/remotedisplay/player/player/WallController.kt +++ b/android/app/src/main/java/com/remotedisplay/player/player/WallController.kt @@ -28,18 +28,24 @@ class WallController( private val media: MediaPlayerManager, private val playlist: PlaylistController, private val deviceId: () -> String, - private val emitSync: (wallId: String, idx: Int, contentId: String?, posSec: Float) -> Unit, - private val emitSyncRequest: (wallId: String) -> Unit, + private val emitSync: (isGroup: Boolean, syncId: String, idx: Int, contentId: String?, posSec: Float) -> Unit, + private val emitSyncRequest: (isGroup: Boolean, syncId: String) -> Unit, private val applyTransform: (WallConfig?) -> Unit ) { + // WALL: spatial tiling (transform + object-fit:fill + followers muted). + // GROUP: #group-sync — same leader/follower timing, but full-screen content, no transform, and + // per-item mute honored (no forced follower mute). syncId is the wall_id or the group_id. + enum class Mode { WALL, GROUP } data class Rect(val x: Float, val y: Float, val w: Float, val h: Float) data class WallConfig( - val wallId: String, + val wallId: String, // sync id: wall_id (WALL) or group_id (GROUP) val screen: Rect, val player: Rect, val isLeader: Boolean, - val rotation: Int + val rotation: Int, + val mode: Mode = Mode.WALL ) + private val WallConfig.isGroup: Boolean get() = mode == Mode.GROUP private val handler = Handler(Looper.getMainLooper()) private var config: WallConfig? = null @@ -52,11 +58,13 @@ class WallController( config = cfg Log.i("WallController", "apply wall=${cfg.wallId} isLeader=${cfg.isLeader}") - applyTransform(cfg) // size/translate the root view to our slice - media.setWallMode(true) // object-fit:fill parity - playlist.setWallFollower(!cfg.isLeader) // followers don't self-advance - media.setWallMute(!cfg.isLeader) // followers muted (avoid flange) - media.setVideoLooping(!cfg.isLeader) // followers loop so they never freeze + // WALL-only spatial bits — a group syncs timing only, full-screen, per-item mute honored. + applyTransform(if (cfg.isGroup) null else cfg) // size/translate root view (wall) or clear (group) + media.setWallMode(!cfg.isGroup) // object-fit:fill for wall; normal fit for group + media.setWallMute(!cfg.isGroup && !cfg.isLeader) // followers muted only on a wall (avoid flange) + // Common to both: followers don't self-advance + loop video so they never freeze. + playlist.setWallFollower(!cfg.isLeader) + media.setVideoLooping(!cfg.isLeader) stopTimer() if (cfg.isLeader) { @@ -66,10 +74,26 @@ class WallController( handler.postDelayed(tick!!, 250) handler.postDelayed({ emitNow() }, 100) // immediate first align } else { - emitSyncRequest(cfg.wallId) // align now, don't wait a tick + emitSyncRequest(cfg.isGroup, cfg.wallId) // align now, don't wait a tick } } + /** + * Hard teardown for Activity destruction. Kills the 4Hz leader tick and drops the config so any + * still-queued tick no-ops (emitNow bails on a null config). Unlike [exit] this touches NO views + * or media — the Activity is going away (its MediaPlayer is about to be released), and restoring + * wall-mode/transform on a dying Activity is both pointless and unsafe. + * + * MUST be called from MainActivity.onDestroy(): the Handler runs on the MAIN looper, which + * outlives the Activity, so a leader tick left running becomes a zombie that keeps broadcasting + * `group:sync`/`wall:sync` forever against a released player — producing split-brain (two live + * "leaders") and garbage positions. This is the teardown that prevents that leak. + */ + fun shutdown() { + stopTimer() + config = null + } + /** Leave wall mode and restore full-screen playback. */ fun exit() { stopTimer() @@ -93,14 +117,17 @@ class WallController( } else { ((System.currentTimeMillis() - playlist.itemStartedAtMs()) / 1000f).coerceAtLeast(0f) } - emitSync(c.wallId, playlist.getIndex(), item.contentId.ifEmpty { null }, pos) + emitSync(c.isGroup, c.wallId, playlist.getIndex(), item.contentId.ifEmpty { null }, pos) } - /** Handle an incoming `wall:sync` (followers only). */ + // Sync payloads carry the id under "group_id" (group) or "wall_id" (wall). + private fun WallConfig.idField(): String = if (isGroup) "group_id" else "wall_id" + + /** Handle an incoming sync broadcast (followers only). */ fun onSync(data: JSONObject) { val c = config ?: return if (c.isLeader) return - if (data.optString("wall_id") != c.wallId) return + if (data.optString(c.idField()) != c.wallId) return val leaderIdx = data.optInt("current_index", -1) if (leaderIdx >= 0 && leaderIdx != playlist.getIndex()) playlist.gotoIndex(leaderIdx) @@ -128,11 +155,11 @@ class WallController( } } - /** Handle a follower's `wall:sync-request` (leader only): broadcast position now. */ + /** Handle a follower's sync-request (leader only): broadcast position now. */ fun onSyncRequest(data: JSONObject) { val c = config ?: return if (!c.isLeader) return - if (data.has("wall_id") && data.optString("wall_id") != c.wallId) return + if (data.has(c.idField()) && data.optString(c.idField()) != c.wallId) return emitNow() } diff --git a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt index 171928d..4630ba0 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/WebSocketService.kt @@ -74,6 +74,9 @@ class WebSocketService : Service() { var onCommand: ((String, JSONObject?) -> Unit)? = null var onWallSync: ((JSONObject) -> Unit)? = null var onWallSyncRequest: ((JSONObject) -> Unit)? = null + var onGroupSync: ((JSONObject) -> Unit)? = null // legacy leader-relay (unused by clock/schedule) + var onGroupSyncRequest: ((JSONObject) -> Unit)? = null // legacy leader-relay (unused by clock/schedule) + var onGroupResync: (() -> Unit)? = null // #group-sync: server-nudged immediate re-align var onPipShow: ((JSONObject) -> Unit)? = null var onPipClear: ((JSONObject) -> Unit)? = null var onMuteChanged: ((JSONObject) -> Unit)? = null @@ -240,10 +243,11 @@ class WebSocketService : Service() { // markAlive already fired via safeOn (any inbound); a healthy ack also resets the // reconnect backoff. Known ack-gap (reconnecting-not-yet-re-registered) is benign: // arm-after-ack + any-inbound-refresh keep the watchdog from firing in that window. - safeOn("device:heartbeat-ack") { + safeOn("device:heartbeat-ack") { args -> if (!livenessConfirmed) Log.i("WebSocketService", "v4 watchdog: ARMED (first heartbeat-ack)") livenessConfirmed = true watchdogAttempt = 0 + (args.firstOrNull() as? JSONObject)?.let { ingestClockSample(it.optLong("server_ms", 0L), it.optLong("client_ms", 0L)) } } safeOn("device:unpaired") { handleServerRejection("device:unpaired (removed on server)") } @@ -339,6 +343,21 @@ class WebSocketService : Service() { handler.post { try { onWallSyncRequest?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onWallSyncRequest cb: ${e.message}") } } } + safeOn("group:sync") { args -> + val data = args.firstOrNull() as? JSONObject ?: return@safeOn + handler.post { try { onGroupSync?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onGroupSync cb: ${e.message}") } } + } + + safeOn("group:sync-request") { args -> + val data = args.firstOrNull() as? JSONObject ?: return@safeOn + handler.post { try { onGroupSyncRequest?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onGroupSyncRequest cb: ${e.message}") } } + } + + // #group-sync: server-nudged immediate re-align (dashboard "Resync now"). + safeOn("group:resync") { + handler.post { try { onGroupResync?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onGroupResync cb: ${e.message}") } } + } + // #109: PiP overlay. Post to the main thread — the handlers build Views. safeOn("device:pip-show") { args -> val data = args.firstOrNull() as? JSONObject ?: return@safeOn @@ -644,11 +663,36 @@ class WebSocketService : Service() { heartbeatRunnable = null } + // #group-sync clock discipline. The server is the time authority (see the heartbeat-ack). The + // offset is CACHED in prefs so schedule-based group sync stays aligned through an internet outage + // (RTC drift is tiny). synced_now = System.currentTimeMillis() + clockOffsetMs. + @Volatile private var clockOffsetMs: Long = Long.MIN_VALUE // sentinel: not yet loaded from prefs + private var clockRttMs: Long = -1 + private fun ensureClockLoaded() { + if (clockOffsetMs == Long.MIN_VALUE) { + clockOffsetMs = try { getSharedPreferences("remote_display", MODE_PRIVATE).getLong("clock_offset_ms", 0L) } catch (e: Throwable) { 0L } + } + } + fun syncedNowMs(): Long { ensureClockLoaded(); return System.currentTimeMillis() + clockOffsetMs } + private fun ingestClockSample(serverMs: Long, clientMs: Long) { + if (serverMs <= 0L || clientMs <= 0L) return + ensureClockLoaded() + val t4 = System.currentTimeMillis() + val rtt = (t4 - clientMs).coerceAtLeast(0) + if (rtt > 5000) return // absurd RTT (GC/doze stall) — don't poison offset + val sample = serverMs - (clientMs + t4) / 2 // NTP-style: offset = server - (t1+t4)/2 + clockOffsetMs = if (clockRttMs < 0 || kotlin.math.abs(sample - clockOffsetMs) > 1000) sample + else Math.round(clockOffsetMs * 0.8 + sample * 0.2) // EMA-smooth jitter + clockRttMs = rtt + try { getSharedPreferences("remote_display", MODE_PRIVATE).edit().putLong("clock_offset_ms", clockOffsetMs).apply() } catch (e: Throwable) {} + } + private fun sendHeartbeat() { if (socket?.connected() != true) return try { val data = JSONObject().apply { put("device_id", config.deviceId) + put("client_ms", System.currentTimeMillis()) // #group-sync: t1 for NTP-style clock discipline try { put("telemetry", deviceInfo.getTelemetry()) } catch (e: Throwable) { Log.w("WebSocketService", "telemetry: ${e.message}") } } socket?.emit("device:heartbeat", data) @@ -857,6 +901,28 @@ class WebSocketService : Service() { } catch (e: Throwable) { Log.w("WebSocketService", "emitWallSyncRequest: ${e.message}") } } + // #group-sync: same payload as wall sync, keyed by group_id (server relays to group members). + fun emitGroupSync(groupId: String, currentIndex: Int, contentId: String?, positionSec: Float) { + if (socket?.connected() != true) return + try { + socket?.emit("group:sync", JSONObject().apply { + put("group_id", groupId) + put("device_id", config.deviceId) + put("current_index", currentIndex) + put("content_id", contentId ?: JSONObject.NULL) + put("position_sec", positionSec.toDouble()) + put("sent_at", System.currentTimeMillis()) + }) + } catch (e: Throwable) { Log.w("WebSocketService", "emitGroupSync: ${e.message}") } + } + + fun emitGroupSyncRequest(groupId: String) { + if (socket?.connected() != true) return + try { + socket?.emit("group:sync-request", JSONObject().apply { put("group_id", groupId) }) + } catch (e: Throwable) { Log.w("WebSocketService", "emitGroupSyncRequest: ${e.message}") } + } + fun disconnect() { stopHeartbeat() cancelReopen() diff --git a/frontend/js/api.js b/frontend/js/api.js index e275d30..2d518b6 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -136,6 +136,8 @@ export const api = { // Device Groups getGroups: () => request('/groups'), createGroup: (name, color) => request('/groups', { method: 'POST', body: JSON.stringify({ name, color }) }), + updateGroup: (id, data) => request(`/groups/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + resyncGroup: (id) => request(`/groups/${id}/resync`, { method: 'POST' }), deleteGroup: (id) => request(`/groups/${id}`, { method: 'DELETE' }), getGroupDevices: (id) => request(`/groups/${id}/devices`), addDeviceToGroup: (groupId, device_id) => request(`/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_id }) }), diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 89bf6f3..ca99c4f 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -105,6 +105,13 @@ export default { 'dashboard.set_playlist_placeholder': 'Set Playlist...', 'dashboard.send_command_placeholder': 'Send Command...', 'dashboard.manage': 'Manage', + 'dashboard.group_sync.label': 'Sync', + 'dashboard.group_sync.hint': "Play this group's shared playlist in lockstep across its displays — items start and end together, and it keeps them aligned even with no internet (each display follows a shared clock + schedule). Requires a playlist assigned to the group; a display on a different playlist is ignored.", + 'dashboard.group_sync.resync': 'Resync now', + 'dashboard.group_sync.resync_hint': 'Nudge every display in this group to re-snap to the shared schedule immediately.', + 'dashboard.group_sync.toast_on': 'Synchronized playback enabled', + 'dashboard.group_sync.toast_off': 'Synchronized playback disabled', + 'dashboard.group_sync.toast_resync': 'Resync sent to group', 'dashboard.manage_tooltip': 'Add/remove devices', 'dashboard.delete_group_tooltip': 'Delete group', 'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.', diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index 596c3bc..47539fa 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -227,6 +227,13 @@ function renderGroupSection(group, devices, playlists) { ${GROUP_COMMANDS.map(c => ``).join('')} ` : ''} + ${devices.length > 0 ? ` + + ${group.sync_enabled ? ` + ` : ''} + ` : ''} @@ -795,6 +802,35 @@ function attachGroupHandlers(groupsWithDevices, allDevices) { }); }); + // #group-sync: toggle synchronized playback for a group. + document.querySelectorAll('.group-sync-cb').forEach(cb => { + cb.addEventListener('change', async (e) => { + const groupId = e.target.dataset.groupId; + const enabled = e.target.checked; + try { + await api.updateGroup(groupId, { sync_enabled: enabled }); + showToast(enabled ? t('dashboard.group_sync.toast_on') : t('dashboard.group_sync.toast_off'), 'success'); + loadDashboard(); // re-render so the Resync button shows/hides + } catch (err) { + showToast(err.message, 'error'); + e.target.checked = !enabled; + } + }); + }); + + // #group-sync: manual "Resync now" — nudge all members to re-snap to the shared schedule. + document.querySelectorAll('.group-resync-btn').forEach(btn => { + btn.addEventListener('click', async (e) => { + const groupId = e.currentTarget.dataset.groupId; + try { + await api.resyncGroup(groupId); + showToast(t('dashboard.group_sync.toast_resync'), 'success'); + } catch (err) { + showToast(err.message, 'error'); + } + }); + }); + // Command select handlers document.querySelectorAll('.group-cmd-select').forEach(select => { select.addEventListener('change', async (e) => { diff --git a/server/db/database.js b/server/db/database.js index 1cb2f49..d784fa1 100644 --- a/server/db/database.js +++ b/server/db/database.js @@ -142,6 +142,12 @@ const migrations = [ "CREATE INDEX IF NOT EXISTS idx_content_folder ON content(folder_id)", // Group-level playlist: when set, devices added to the group inherit it. "ALTER TABLE device_groups ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL", + // Group synchronized playback: when sync_enabled, members on the group's playlist play it + // in lockstep (leader broadcasts index+position; followers align). Reuses the video-wall + // sync primitive, minus the spatial transform. leader_device_id is an optional pin; if unset + // or offline the server auto-elects the first online member on the matching playlist. + "ALTER TABLE device_groups ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE device_groups ADD COLUMN leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL", // Wall-level playlist: video walls now play a playlist (not just one content). "ALTER TABLE video_walls ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL", // Free-form canvas layout: walls store a player rect; member devices store diff --git a/server/player/index.html b/server/player/index.html index 3a270c2..3355229 100644 --- a/server/player/index.html +++ b/server/player/index.html @@ -414,6 +414,45 @@ // video position to whatever the leader is playing. let wallConfig = null; let wallSyncTimer = null; + // #group-sync: synchronized group playback. NOT leader/follower — every member derives its + // tick locally from a server-disciplined clock + the shared playlist schedule. Works offline. + let groupSync = null; + let groupSyncTimer = null; + let groupDbgLast = 0; + // A fresh item snaps ONCE to the exact schedule position (load-and-hold) instead of nudging away + // the ~0.3s load offset over ~10s. Steady-state drift still rides the gentle nudge afterward. + let groupAlignPending = true; + let groupLastAlignedIndex = -1; + // Double buffer: a hidden