Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
Some checks are pending
CI / Unit tests (node --test) (push) Waiting to run
CI / OpenAPI spec lint (push) Waiting to run
CI / Android unit tests (Kotlin schedule evaluator vectors) (push) Waiting to run
CI / Boot smoke + version check (push) Waiting to run

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 / <display>" 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
screentinker 2026-07-11 14:24:31 -05:00 committed by GitHub
parent 34f1cb9e7c
commit 938a43a466
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1134 additions and 85 deletions

View file

@ -34,6 +34,7 @@ import com.remotedisplay.player.player.PlaylistController
import com.remotedisplay.player.player.PlaylistItem import com.remotedisplay.player.player.PlaylistItem
import com.remotedisplay.player.player.PipOverlay import com.remotedisplay.player.player.PipOverlay
import com.remotedisplay.player.player.WallController import com.remotedisplay.player.player.WallController
import com.remotedisplay.player.player.GroupScheduleController
import com.remotedisplay.player.player.ZoneManager import com.remotedisplay.player.player.ZoneManager
import com.remotedisplay.player.remote.ScreenshotCapture import com.remotedisplay.player.remote.ScreenshotCapture
import com.remotedisplay.player.remote.TouchInjector import com.remotedisplay.player.remote.TouchInjector
@ -57,6 +58,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var updateChecker: UpdateChecker private lateinit var updateChecker: UpdateChecker
private var zoneManager: ZoneManager? = null private var zoneManager: ZoneManager? = null
private lateinit var wallController: WallController private lateinit var wallController: WallController
private lateinit var groupSchedule: GroupScheduleController
private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer
private lateinit var playerView: PlayerView private lateinit var playerView: PlayerView
@ -235,11 +237,33 @@ class MainActivity : AppCompatActivity() {
media = mediaPlayer, media = mediaPlayer,
playlist = playlistController, playlist = playlistController,
deviceId = { config.deviceId }, deviceId = { config.deviceId },
emitSync = { wallId, idx, contentId, posSec -> wsService?.emitWallSync(wallId, idx, contentId, posSec) }, emitSync = { isGroup, id, idx, contentId, posSec ->
emitSyncRequest = { wallId -> wsService?.emitWallSyncRequest(wallId) }, 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) } 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). // 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 // 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 // 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.setTimezone(if (cached.isNull("timezone")) null else cached.optString("timezone", "").ifEmpty { null })
playlistController.updatePlaylist(assignments) playlistController.updatePlaylist(assignments)
playlistController.startIfNeeded() 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) { } catch (e: Throwable) {
Log.w("MainActivity", "Failed to restore cached playlist, clearing cache: ${e.message}") 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); // 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 // 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). // stretched to fill (object-fit:fill parity, set on the views via MediaPlayerManager).
@ -452,10 +491,16 @@ class MainActivity : AppCompatActivity() {
if (wallObj != null) { if (wallObj != null) {
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: VIDEO-WALL (${assignments.length()} assignments)") com.remotedisplay.player.util.DebugLog.i("Player", "Layout: VIDEO-WALL (${assignments.length()} assignments)")
if (zoneManager?.hasZones() == true) zoneManager?.cleanup() if (zoneManager?.hasZones() == true) zoneManager?.cleanup()
groupSchedule.exit() // wall and group are mutually exclusive
wallController.apply(parseWallConfig(wallObj)) wallController.apply(parseWallConfig(wallObj))
playlistController.updatePlaylist(assignments) playlistController.updatePlaylist(assignments)
} else { } 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")) applyOrientation(data.optString("orientation", "landscape"))
// Check for multi-zone layout // Check for multi-zone layout
@ -663,6 +708,9 @@ class MainActivity : AppCompatActivity() {
wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) } wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) }
wsService?.onWallSyncRequest = { data -> if (::wallController.isInitialized) wallController.onSyncRequest(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). // #109: PiP overlay show/clear (posted to the main thread by the service).
wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) } wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) }
@ -1065,6 +1113,11 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() { override fun onDestroy() {
remoteStreaming = false 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) if (::downloadCoordinator.isInitialized) downloadCoordinator.shutdown() // cancel in-flight downloads (no orphan/leak)
zoneManager?.cleanup() zoneManager?.cleanup()
if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView

View file

@ -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)
}

View file

@ -1,8 +1,10 @@
package com.remotedisplay.player.player package com.remotedisplay.player.player
import android.content.Context import android.content.Context
import android.graphics.SurfaceTexture
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import android.view.Surface
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
@ -30,6 +32,19 @@ class MediaPlayerManager(
// Wall mode: followers must stay muted even as the leader's sync switches them // 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. // to a new (possibly unmuted) item, so the mute has to survive each playVideo.
private var wallMute = false 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 } enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE, WIDGET }
@ -37,25 +52,31 @@ class MediaPlayerManager(
setupExoPlayer() 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() { private fun setupExoPlayer() {
exoPlayer = ExoPlayer.Builder(context).build().also { player -> // Hold the last frame instead of flashing black during a reset/prepare — turns any residual
playerView.player = player // switch gap into a brief freeze-frame rather than a black hold.
player.addListener(object : Player.Listener { try { playerView.setKeepContentOnPlayerReset(true) } catch (e: Throwable) {}
override fun onPlaybackStateChanged(playbackState: Int) { exoPlayer = buildPlayer().also { playerView.player = it }
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()
}
})
}
} }
// #129: remembered so the live device:mute-changed toggle knows YouTube's current // #129: remembered so the live device:mute-changed toggle knows YouTube's current
@ -154,8 +175,28 @@ class MediaPlayerManager(
}.start() }.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) { fun playVideo(file: File, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Playing video: ${file.absolutePath} (muted=$muted)")
currentType = MediaType.VIDEO currentType = MediaType.VIDEO
// Show player, hide image // Show player, hide image
@ -163,6 +204,27 @@ class MediaPlayerManager(
imageView.visibility = android.view.View.GONE imageView.visibility = android.view.View.GONE
youtubeWebView?.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 { exoPlayer?.apply {
volume = if (muted || wallMute) 0f else 1f volume = if (muted || wallMute) 0f else 1f
setMediaItem(MediaItem.fromUri(Uri.fromFile(file))) setMediaItem(MediaItem.fromUri(Uri.fromFile(file)))
@ -206,6 +268,11 @@ class MediaPlayerManager(
fun release() { fun release() {
exoPlayer?.release() exoPlayer?.release()
exoPlayer = null 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) 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. * if the leader's next index sync is slightly late; the leader plays through normally.
*/ */
fun setVideoLooping(loop: Boolean) { fun setVideoLooping(loop: Boolean) {
videoLooping = loop
exoPlayer?.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF 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
} }
/** /**

View file

@ -87,6 +87,9 @@ class PlaylistController(
val currentItem: PlaylistItem? val currentItem: PlaylistItem?
get() = if (currentIndex in items.indices) items[currentIndex] else null 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? val currentContentId: String?
get() = currentItem?.contentId get() = currentItem?.contentId
@ -125,6 +128,10 @@ class PlaylistController(
// #129: include muted too, so a mute-only change (same content) re-renders with the // #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; // new flag instead of being de-duped (the real-time event handles the live toggle;
// this makes a published mute persist across reloads). // 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 "") + "|" + fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" +
it.schedules.joinToString(";") { b -> it.schedules.joinToString(";") { b ->
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "") b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
@ -134,7 +141,16 @@ class PlaylistController(
val playlistChanged = oldContentIds != newContentIds val playlistChanged = oldContentIds != newContentIds
if (!playlistChanged && items.isNotEmpty()) { 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 return
} }
@ -288,6 +304,34 @@ class PlaylistController(
item.schedules.isEmpty() || item.schedules.isEmpty() ||
ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone) 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<Triple<Int, Long, Long>>() // 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. // Playable NOW = schedule-active AND its content is downloaded/available.
private fun playableNow(i: Int): Boolean = private fun playableNow(i: Int): Boolean =
i in items.indices && scheduleAllows(items[i]) && contentReady(items[i]) i in items.indices && scheduleAllows(items[i]) && contentReady(items[i])

View file

@ -28,18 +28,24 @@ class WallController(
private val media: MediaPlayerManager, private val media: MediaPlayerManager,
private val playlist: PlaylistController, private val playlist: PlaylistController,
private val deviceId: () -> String, private val deviceId: () -> String,
private val emitSync: (wallId: String, idx: Int, contentId: String?, posSec: Float) -> Unit, private val emitSync: (isGroup: Boolean, syncId: String, idx: Int, contentId: String?, posSec: Float) -> Unit,
private val emitSyncRequest: (wallId: String) -> Unit, private val emitSyncRequest: (isGroup: Boolean, syncId: String) -> Unit,
private val applyTransform: (WallConfig?) -> 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 Rect(val x: Float, val y: Float, val w: Float, val h: Float)
data class WallConfig( data class WallConfig(
val wallId: String, val wallId: String, // sync id: wall_id (WALL) or group_id (GROUP)
val screen: Rect, val screen: Rect,
val player: Rect, val player: Rect,
val isLeader: Boolean, 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 val handler = Handler(Looper.getMainLooper())
private var config: WallConfig? = null private var config: WallConfig? = null
@ -52,11 +58,13 @@ class WallController(
config = cfg config = cfg
Log.i("WallController", "apply wall=${cfg.wallId} isLeader=${cfg.isLeader}") Log.i("WallController", "apply wall=${cfg.wallId} isLeader=${cfg.isLeader}")
applyTransform(cfg) // size/translate the root view to our slice // WALL-only spatial bits — a group syncs timing only, full-screen, per-item mute honored.
media.setWallMode(true) // object-fit:fill parity applyTransform(if (cfg.isGroup) null else cfg) // size/translate root view (wall) or clear (group)
playlist.setWallFollower(!cfg.isLeader) // followers don't self-advance media.setWallMode(!cfg.isGroup) // object-fit:fill for wall; normal fit for group
media.setWallMute(!cfg.isLeader) // followers muted (avoid flange) media.setWallMute(!cfg.isGroup && !cfg.isLeader) // followers muted only on a wall (avoid flange)
media.setVideoLooping(!cfg.isLeader) // followers loop so they never freeze // Common to both: followers don't self-advance + loop video so they never freeze.
playlist.setWallFollower(!cfg.isLeader)
media.setVideoLooping(!cfg.isLeader)
stopTimer() stopTimer()
if (cfg.isLeader) { if (cfg.isLeader) {
@ -66,10 +74,26 @@ class WallController(
handler.postDelayed(tick!!, 250) handler.postDelayed(tick!!, 250)
handler.postDelayed({ emitNow() }, 100) // immediate first align handler.postDelayed({ emitNow() }, 100) // immediate first align
} else { } 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. */ /** Leave wall mode and restore full-screen playback. */
fun exit() { fun exit() {
stopTimer() stopTimer()
@ -93,14 +117,17 @@ class WallController(
} else { } else {
((System.currentTimeMillis() - playlist.itemStartedAtMs()) / 1000f).coerceAtLeast(0f) ((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) { fun onSync(data: JSONObject) {
val c = config ?: return val c = config ?: return
if (c.isLeader) 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) val leaderIdx = data.optInt("current_index", -1)
if (leaderIdx >= 0 && leaderIdx != playlist.getIndex()) playlist.gotoIndex(leaderIdx) 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) { fun onSyncRequest(data: JSONObject) {
val c = config ?: return val c = config ?: return
if (!c.isLeader) 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() emitNow()
} }

View file

@ -74,6 +74,9 @@ class WebSocketService : Service() {
var onCommand: ((String, JSONObject?) -> Unit)? = null var onCommand: ((String, JSONObject?) -> Unit)? = null
var onWallSync: ((JSONObject) -> Unit)? = null var onWallSync: ((JSONObject) -> Unit)? = null
var onWallSyncRequest: ((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 onPipShow: ((JSONObject) -> Unit)? = null
var onPipClear: ((JSONObject) -> Unit)? = null var onPipClear: ((JSONObject) -> Unit)? = null
var onMuteChanged: ((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 // 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: // 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. // 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)") if (!livenessConfirmed) Log.i("WebSocketService", "v4 watchdog: ARMED (first heartbeat-ack)")
livenessConfirmed = true livenessConfirmed = true
watchdogAttempt = 0 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)") } 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}") } } 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. // #109: PiP overlay. Post to the main thread — the handlers build Views.
safeOn("device:pip-show") { args -> safeOn("device:pip-show") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn val data = args.firstOrNull() as? JSONObject ?: return@safeOn
@ -644,11 +663,36 @@ class WebSocketService : Service() {
heartbeatRunnable = null 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() { private fun sendHeartbeat() {
if (socket?.connected() != true) return if (socket?.connected() != true) return
try { try {
val data = JSONObject().apply { val data = JSONObject().apply {
put("device_id", config.deviceId) 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}") } try { put("telemetry", deviceInfo.getTelemetry()) } catch (e: Throwable) { Log.w("WebSocketService", "telemetry: ${e.message}") }
} }
socket?.emit("device:heartbeat", data) socket?.emit("device:heartbeat", data)
@ -857,6 +901,28 @@ class WebSocketService : Service() {
} catch (e: Throwable) { Log.w("WebSocketService", "emitWallSyncRequest: ${e.message}") } } 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() { fun disconnect() {
stopHeartbeat() stopHeartbeat()
cancelReopen() cancelReopen()

View file

@ -136,6 +136,8 @@ export const api = {
// Device Groups // Device Groups
getGroups: () => request('/groups'), getGroups: () => request('/groups'),
createGroup: (name, color) => request('/groups', { method: 'POST', body: JSON.stringify({ name, color }) }), 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' }), deleteGroup: (id) => request(`/groups/${id}`, { method: 'DELETE' }),
getGroupDevices: (id) => request(`/groups/${id}/devices`), getGroupDevices: (id) => request(`/groups/${id}/devices`),
addDeviceToGroup: (groupId, device_id) => request(`/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_id }) }), addDeviceToGroup: (groupId, device_id) => request(`/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_id }) }),

View file

@ -105,6 +105,13 @@ export default {
'dashboard.set_playlist_placeholder': 'Set Playlist...', 'dashboard.set_playlist_placeholder': 'Set Playlist...',
'dashboard.send_command_placeholder': 'Send Command...', 'dashboard.send_command_placeholder': 'Send Command...',
'dashboard.manage': 'Manage', '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.manage_tooltip': 'Add/remove devices',
'dashboard.delete_group_tooltip': 'Delete group', 'dashboard.delete_group_tooltip': 'Delete group',
'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.', 'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.',

View file

@ -227,6 +227,13 @@ function renderGroupSection(group, devices, playlists) {
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${t(CMD_LABEL_KEY[c.type])}</option>`).join('')} ${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${t(CMD_LABEL_KEY[c.type])}</option>`).join('')}
</select> </select>
` : ''} ` : ''}
${devices.length > 0 ? `
<label class="group-sync-label" style="display:flex;align-items:center;gap:5px;font-size:12px;color:var(--text-secondary);cursor:pointer;white-space:nowrap" title="${esc(t('dashboard.group_sync.hint'))}">
<input type="checkbox" class="group-sync-cb" data-group-id="${group.id}" ${group.sync_enabled ? 'checked' : ''}> ${t('dashboard.group_sync.label')}
</label>
${group.sync_enabled ? `
<button class="btn group-resync-btn" data-group-id="${group.id}" style="padding:4px 10px;font-size:12px" title="${esc(t('dashboard.group_sync.resync_hint'))}">${t('dashboard.group_sync.resync')}</button>` : ''}
` : ''}
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button> <button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="${t('dashboard.manage_tooltip')}">${t('dashboard.manage')}</button>
<button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="${t('dashboard.delete_group_tooltip')}">&#x2715;</button> <button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="${t('dashboard.delete_group_tooltip')}">&#x2715;</button>
</div> </div>
@ -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 // Command select handlers
document.querySelectorAll('.group-cmd-select').forEach(select => { document.querySelectorAll('.group-cmd-select').forEach(select => {
select.addEventListener('change', async (e) => { select.addEventListener('change', async (e) => {

View file

@ -142,6 +142,12 @@ const migrations = [
"CREATE INDEX IF NOT EXISTS idx_content_folder ON content(folder_id)", "CREATE INDEX IF NOT EXISTS idx_content_folder ON content(folder_id)",
// Group-level playlist: when set, devices added to the group inherit it. // 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", "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). // 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", "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 // Free-form canvas layout: walls store a player rect; member devices store

View file

@ -414,6 +414,45 @@
// video position to whatever the leader is playing. // video position to whatever the leader is playing.
let wallConfig = null; let wallConfig = null;
let wallSyncTimer = 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 <video> for the NEXT clip, buffered/decoded ahead of the boundary so the
// switch is instant (no black hold). Reused by renderContent when it reaches that index.
let groupPreloadEl = null;
let groupPreloadIdx = -1;
// #group-sync clock discipline. The server is the time authority (see heartbeat-ack). We keep a
// smoothed offset so synced_now = Date.now() + clockOffsetMs, and CACHE it in localStorage so a
// player that has synced even once stays aligned through an internet outage (RTC drift is tiny).
let clockOffsetMs = 0;
let clockRttMs = null;
try { const c = localStorage.getItem('st_clock_offset'); if (c != null && isFinite(Number(c))) clockOffsetMs = Number(c); } catch (e) {}
function syncedNow() { return Date.now() + clockOffsetMs; }
function ingestClockSample(serverMs, clientMs) {
if (!serverMs || !clientMs) return;
const t4 = Date.now();
const rtt = Math.max(0, t4 - clientMs);
if (rtt > 5000) return; // absurd RTT (sleep/GC stall) — don't poison the offset
const sample = serverMs - (clientMs + t4) / 2; // NTP-style: offset = server - (t1+t4)/2
// Snap on the first sample or a big jump (clock step); otherwise EMA-smooth out jitter.
if (clockRttMs === null || Math.abs(sample - clockOffsetMs) > 1000) clockOffsetMs = Math.round(sample);
else clockOffsetMs = Math.round(clockOffsetMs * 0.8 + sample * 0.2);
clockRttMs = Math.round(rtt);
try { localStorage.setItem('st_clock_offset', String(clockOffsetMs)); } catch (e) {}
}
// Stream a group-sync diagnostic to the dashboard live-log (tag 'sync') + the in-page overlay.
function groupReport(level, msg) {
try { if (socket?.connected && config.deviceId) socket.emit('device:log', { device_id: config.deviceId, tag: 'sync', level, message: msg }); } catch (e) {}
try { window.__debugLog_push && window.__debugLog_push({ type: 'sync', level: level, msg: msg }); } catch (e) {}
}
let lastWallSync = null; let lastWallSync = null;
let currentVideoEl = null; let currentVideoEl = null;
let currentItemStartedAt = 0; let currentItemStartedAt = 0;
@ -605,6 +644,9 @@
layout = loadLayoutCache(); layout = loadLayoutCache();
document.getElementById('setupScreen').style.display = 'none'; document.getElementById('setupScreen').style.display = 'none';
startPlaybackAt(0); // #74/#75: honour schedules from the first frame on cold-start startPlaybackAt(0); // #74/#75: honour schedules from the first frame on cold-start
// #group-sync: if this device was in a sync group, resume the schedule immediately from the
// cached clock offset — so a reboot mid-outage comes back aligned WITHOUT waiting for a server.
try { const cg = localStorage.getItem('st_group_sync'); if (cg) applyGroupSync(JSON.parse(cg)); } catch (e) {}
} }
// Always show the tap overlay on cold load. Browser autoplay policy is // Always show the tap overlay on cold load. Browser autoplay policy is
@ -872,7 +914,7 @@
socket.onAny(markAlive); socket.onAny(markAlive);
socket.io.on('ping', markAlive); socket.io.on('ping', markAlive);
// v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack. // v4 degrade-safe ARM: the watchdog arms ONLY after the first app-level device:heartbeat-ack.
socket.on('device:heartbeat-ack', () => { livenessConfirmed = true; }); socket.on('device:heartbeat-ack', (data) => { livenessConfirmed = true; if (data) ingestClockSample(data.server_ms, data.client_ms); });
socket.on('connect', () => { socket.on('connect', () => {
console.log('Connected'); console.log('Connected');
@ -1008,6 +1050,18 @@
emitWallSync(); emitWallSync();
}); });
// #group-sync: index+position are computed LOCALLY from the disciplined clock + the shared
// schedule (see groupScheduleTick). There is deliberately NO leader and NO server relay of
// positions — every member derives the same tick from the same clock, so sync survives an
// internet outage and there is no leader to go split-brain. The server only (a) disciplines
// the clock via the heartbeat-ack and (b) can nudge an immediate re-align on demand:
socket.on('group:resync', (data) => {
if (!groupSync) return;
if (data?.group_id && data.group_id !== groupSync.group_id) return;
groupReport('info', 'manual resync requested');
groupScheduleTick(); // recompute + snap to the schedule target right now
});
socket.on('device:screenshot-request', () => { console.log('Screenshot requested'); captureAndSend(); }); socket.on('device:screenshot-request', () => { console.log('Screenshot requested'); captureAndSend(); });
socket.on('device:remote-start', () => { console.log('Remote start received'); remoteStreaming = true; startStreaming(); }); socket.on('device:remote-start', () => { console.log('Remote start received'); remoteStreaming = true; startStreaming(); });
socket.on('device:remote-stop', () => { console.log('Remote stop received'); remoteStreaming = false; stopStreaming(); }); socket.on('device:remote-stop', () => { console.log('Remote stop received'); remoteStreaming = false; stopStreaming(); });
@ -1223,6 +1277,7 @@
if (!socket?.connected || !config.deviceId) return; if (!socket?.connected || !config.deviceId) return;
socket.emit('device:heartbeat', { socket.emit('device:heartbeat', {
device_id: config.deviceId, device_id: config.deviceId,
client_ms: Date.now(), // #group-sync: t1 for NTP-style clock discipline (echoed in the ack)
telemetry: { telemetry: {
battery_level: null, battery_level: null,
battery_charging: false, battery_charging: false,
@ -1349,6 +1404,117 @@
}); });
} }
// #group-sync schedule engine. The shared, deterministic playlist schedule (each item occupies
// duration_sec, in order, skipping dayparted-out items) is laid on the synced clock: phase =
// syncedNow mod totalPeriod. Every same-playlist member computes the identical (index, position)
// — no leader, no relay, no split-brain — and it keeps running with no server at all.
function groupScheduleSlots() {
const slots = []; let acc = 0;
for (let i = 0; i < playlist.length; i++) {
if (!scheduleAllows(playlist[i])) continue; // same daypart filter as solo playback
const dur = Math.max(1, Number(playlist[i].duration_sec) || 10) * 1000;
slots.push({ index: i, start: acc, dur }); acc += dur;
}
return { slots, period: acc };
}
function groupScheduleTarget() {
const { slots, period } = groupScheduleSlots();
if (!slots.length || period <= 0) return null;
const phase = ((syncedNow() % period) + period) % period;
let ci = slots.findIndex(x => phase >= x.start && phase < x.start + x.dur);
if (ci < 0) ci = slots.length - 1;
const s = slots[ci];
const next = slots[(ci + 1) % slots.length];
// nextIndex + secToBoundary drive the double buffer (preload the upcoming clip a few s early).
return { index: s.index, posSec: (phase - s.start) / 1000, nextIndex: next.index, secToBoundary: (s.start + s.dur - phase) / 1000 };
}
// Runs at 4Hz while in a group: snap the index to the schedule, and for video correct drift with
// the same seek/nudge maths the wall uses — but toward the SCHEDULE target, not a leader broadcast.
function groupScheduleTick() {
if (!groupSync || playlist.length === 0) return;
const t = groupScheduleTarget();
if (!t) return;
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6 && groupPreloadIdx !== t.nextIndex) {
groupPreloadNext(t.nextIndex);
}
let action = 'hold';
if (t.index !== currentIndex) {
currentIndex = t.index;
playCurrentItem();
action = 'jump>' + t.index;
} else if (currentVideoEl && isFinite(currentVideoEl.duration) && currentVideoEl.duration > 0) {
const dur = currentVideoEl.duration;
const target = t.posSec % dur; // loop-safe when duration_sec > clip length
const drift = (currentVideoEl.currentTime || 0) - target;
const ad = Math.abs(drift);
if (currentIndex !== groupLastAlignedIndex) groupAlignPending = true;
if (groupAlignPending) {
if (ad > 0.05) { try { currentVideoEl.currentTime = target; } catch (e) {} }
try { currentVideoEl.playbackRate = 1.0; } catch (e) {}
groupAlignPending = false; groupLastAlignedIndex = currentIndex;
action = 'align ' + drift.toFixed(2);
}
else if (ad > 0.3) { try { currentVideoEl.currentTime = target; } catch (e) {} try { currentVideoEl.playbackRate = 1.0; } catch (e) {} action = 'seek ' + drift.toFixed(2); }
else if (ad > 0.05) { try { currentVideoEl.playbackRate = drift > 0 ? 0.97 : 1.03; } catch (e) {} action = 'nudge ' + drift.toFixed(2); }
else if (currentVideoEl.playbackRate !== 1.0) { try { currentVideoEl.playbackRate = 1.0; } catch (e) {} }
}
// Log discrete corrections (jump/align/seek) immediately so the transition is visible; only the
// routine steady-state line (hold/nudge) is throttled — else the one-tick "align" on load is
// sampled over by a later "hold"/"nudge" and reads misleadingly.
const now = Date.now();
const discrete = action.indexOf('jump') === 0 || action.indexOf('align') === 0 || action.indexOf('seek') === 0;
if (discrete || now - groupDbgLast > 1000) {
groupDbgLast = now;
groupReport('info', 'idx=' + currentIndex + ' tgt=' + t.index + ' pos=' + t.posSec.toFixed(2) + ' off=' + clockOffsetMs + 'ms rtt=' + clockRttMs + 'ms ' + action);
}
}
// Double buffer: build a hidden, buffering <video> for the next clip so renderContent can mount it
// instantly at the boundary (no black hold). Only for videos; images/widgets/youtube skip it.
function groupPreloadNext(idx) {
const item = playlist[idx];
if (!item) return;
const isVid = item.mime_type && item.mime_type.indexOf('video/') === 0 && item.mime_type !== 'video/youtube';
if (!isVid) { groupPreloadIdx = idx; groupPreloadEl = null; return; } // mark handled, nothing to warm
const url = item.remote_url || (config.serverUrl + '/uploads/content/' + item.filepath);
try {
if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} }
const v = document.createElement('video');
v.src = url; v.muted = true; v.playsInline = true; v.preload = 'auto';
v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;pointer-events:none;left:-9999px';
v.load();
document.body.appendChild(v);
groupPreloadEl = v; groupPreloadIdx = idx;
groupReport('info', 'preload>' + idx);
} catch (e) { groupPreloadEl = null; groupPreloadIdx = -1; }
}
// Hand off the preloaded element for `idx` (or null). Caller re-parents + plays it; src is already
// set and buffered, so playback starts near-instantly with no reload.
function takeGroupPreload(idx) {
if (groupPreloadIdx === idx && groupPreloadEl) { const el = groupPreloadEl; groupPreloadEl = null; groupPreloadIdx = -1; return el; }
return null;
}
// Enter/leave group sync. No CSS transform, no forced mute (per-item mute honored), no leader —
// just start the schedule tick. Idempotent across refreshes/role churn (there is no role now).
function applyGroupSync(cfg) {
if (groupSyncTimer) { clearInterval(groupSyncTimer); groupSyncTimer = null; }
try { if (cfg) localStorage.setItem('st_group_sync', JSON.stringify(cfg)); else localStorage.removeItem('st_group_sync'); } catch (e) {}
if (!cfg) {
if (groupSync) groupReport('info', 'group-sync exited'); groupSync = null; console.log('[group-sync] exited');
if (groupPreloadEl) { try { groupPreloadEl.remove(); } catch (e) {} groupPreloadEl = null; groupPreloadIdx = -1; }
return;
}
const first = !groupSync;
groupSync = cfg;
groupAlignPending = true; groupLastAlignedIndex = -1; // snap the first item into sync on entry
console.log('[group-sync] group=' + cfg.group_id + ' (clock/schedule, offset=' + clockOffsetMs + 'ms)');
groupReport('info', 'group-sync ' + (first ? 'entered' : 'refresh') + ' group=' + String(cfg.group_id).slice(0, 8) + ' off=' + clockOffsetMs + 'ms');
groupScheduleTick(); // align immediately
groupSyncTimer = setInterval(groupScheduleTick, 250); // 4Hz local correction
}
// Map the player rect into this device's viewport using vw/vh so the // Map the player rect into this device's viewport using vw/vh so the
// viewport fills edge-to-edge (no pillarbox at the seam between adjacent // viewport fills edge-to-edge (no pillarbox at the seam between adjacent
// screens). With object-fit:fill on the video, the source stretches to // screens). With object-fit:fill on the video, the source stretches to
@ -1403,6 +1569,9 @@
const newItems = data.assignments || []; const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change. // Build fingerprint from id + url + filename to detect any content change.
// #74/#75: include schedules so a schedule edit (same content) is detected too. // #74/#75: include schedules so a schedule edit (same content) is detected too.
// STRUCTURAL fingerprint only (identity + order + schedules). duration_sec is deliberately
// EXCLUDED so a duration-only edit is not treated as a full change/restart — it's applied IN
// PLACE in the unchanged branch below (the schedule tick + advance timer read duration_sec live).
const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}`).join(','); const fingerprint = (items) => items.map(a => `${a.content_id || ''}|${a.widget_id || ''}|${a.remote_url || ''}|${a.filepath || ''}|${a.filename || ''}|${JSON.stringify(a.schedules || [])}`).join(',');
const newFp = fingerprint(newItems); const newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist); const oldFp = fingerprint(playlist);
@ -1442,6 +1611,13 @@
if (!wallChanged && wallConfig && !wallConfig.is_leader && socket?.connected) { if (!wallChanged && wallConfig && !wallConfig.is_leader && socket?.connected) {
socket.emit('wall:sync-request', { wall_id: wallConfig.wall_id }); socket.emit('wall:sync-request', { wall_id: wallConfig.wall_id });
} }
// #group-sync: enter/leave on group membership change (mutually exclusive with wall — the
// server sends group_sync=null for a wall member). There's no leader role, so the key is just
// the group id; a plain refresh re-aligns the schedule locally (no server round-trip needed).
const groupKey = (g) => (g ? String(g.group_id) : '');
const groupChanged = groupKey(groupSync) !== groupKey(data.group_sync);
if (groupChanged) applyGroupSync(data.group_sync || null);
else if (groupSync) groupScheduleTick();
layout = data.layout || null; layout = data.layout || null;
saveLayoutCache(layout); saveLayoutCache(layout);
@ -1449,6 +1625,12 @@
if (newFp === oldFp && playlist.length > 0 && !wallChanged) { if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
console.log('Playlist unchanged'); console.log('Playlist unchanged');
// In-place duration refresh: a duration-only edit keeps the structural fingerprint identical,
// so patch duration_sec onto the live items here. The group schedule tick re-anchors on the
// new period next tick; solo advance uses it on the next item. No restart, no reload.
for (let i = 0; i < playlist.length && i < newItems.length; i++) {
if (playlist[i].duration_sec !== newItems[i].duration_sec) playlist[i].duration_sec = newItems[i].duration_sec;
}
// #146 fix: a no-change refresh used to blindly return — so if the <video> surface // #146 fix: a no-change refresh used to blindly return — so if the <video> surface
// had been lost (detached from the DOM while its element kept decoding audio: video // had been lost (detached from the DOM while its element kept decoding audio: video
// gone, audio still playing), the re-attach (which lives ONLY in the content-changed // gone, audio still playing), the re-attach (which lives ONLY in the content-changed
@ -1601,6 +1783,7 @@
// Push an immediate sync so followers don't have to wait up to 1s for // Push an immediate sync so followers don't have to wait up to 1s for
// the next periodic tick before snapping to the new item. // the next periodic tick before snapping to the new item.
if (wallConfig?.is_leader) emitWallSync(); if (wallConfig?.is_leader) emitWallSync();
// (group members need no emit — the schedule tick drives them locally)
} }
function nextItem() { function nextItem() {
@ -1835,9 +2018,15 @@
mount = stage; mount = stage;
} }
// Followers don't run their own advance timers — the leader's wall:sync // Two independent concerns, previously conflated as "isFollower":
// dictates index transitions. Single-screen and leader behave normally. // - forceMuted: wall followers stay silent (N flanged copies across an adjacent wall).
const isFollower = !!wallConfig && !wallConfig.is_leader; // Group members honor per-item mute instead (displays are spread out).
// - scheduleDriven: who does NOT run a local advance timer. Wall followers (leader drives the
// index) AND all group members (the clock/schedule tick drives the index).
const isWallFollower_ = (!!wallConfig && !wallConfig.is_leader);
const scheduleDriven = isWallFollower_ || !!groupSync;
const forceMuted = isWallFollower_;
const isFollower = scheduleDriven; // keep the old name for the branches below (advance gating)
const isYoutube = item.mime_type === 'video/youtube'; const isYoutube = item.mime_type === 'video/youtube';
const isVideo = !isYoutube && item.mime_type?.startsWith('video/'); const isVideo = !isYoutube && item.mime_type?.startsWith('video/');
@ -1853,16 +2042,19 @@
if (isYoutube) { if (isYoutube) {
createYoutubeEmbed(src, item, mount); createYoutubeEmbed(src, item, mount);
} else if (isVideo) { } else if (isVideo) {
const video = document.createElement('video'); // Double buffer: reuse the pre-buffered element for this index if we warmed it (no black
video.src = src; // hold). Its src is already set + buffered; a fresh element otherwise.
const preloaded = scheduleDriven ? takeGroupPreload(currentIndex) : null;
const video = preloaded || document.createElement('video');
if (!preloaded) video.src = src;
video.autoplay = true; video.autoplay = true;
// Followers stay muted unconditionally (leader-only audio); leaders // Followers stay muted unconditionally (leader-only audio); leaders
// start muted only if the user hasn't gestured yet (autoplay policy). // start muted only if the user hasn't gestured yet (autoplay policy).
// #129: a per-item mute (set in the admin console) also forces muted. // #129: a per-item mute (set in the admin console) also forces muted.
video.muted = isFollower ? true : (!userHasInteracted || !!item.muted); video.muted = forceMuted ? true : (!userHasInteracted || !!item.muted);
// Explicit max volume on the leader so audio is at full level when // Explicit max volume when audio is allowed so it's at full level when
// unmute happens (default is 1.0 but make it visible in logs). // unmute happens (default is 1.0 but make it visible in logs).
if (!isFollower) video.volume = 1.0; if (!forceMuted) video.volume = 1.0;
video.playsInline = true; video.playsInline = true;
video.crossOrigin = 'anonymous'; video.crossOrigin = 'anonymous';
// Wall mode uses object-fit:fill so the source stretches to the // Wall mode uses object-fit:fill so the source stretches to the
@ -1874,7 +2066,9 @@
video.style.cssText = wallConfig video.style.cssText = wallConfig
? 'width:100%;height:100%;object-fit:fill;background:#000' ? 'width:100%;height:100%;object-fit:fill;background:#000'
: 'width:100%;height:100%;object-fit:contain;background:#000'; : 'width:100%;height:100%;object-fit:contain;background:#000';
video.loop = (playlist.length === 1); // Group members loop so a clip shorter than its schedule slot holds until the schedule
// advances the index (and the tick seeks position % duration to stay aligned).
video.loop = (playlist.length === 1) || !!groupSync;
video.onended = () => { if (!video.loop && !isFollower) nextItem(); }; video.onended = () => { if (!video.loop && !isFollower) nextItem(); };
video.onerror = (e) => { video.onerror = (e) => {
console.error('Video error:', src, e); console.error('Video error:', src, e);
@ -1883,10 +2077,11 @@
video.onloadeddata = () => { video.onloadeddata = () => {
console.log('[wall/audio] video loaded file=' + item.filename + ' role=' + (wallConfig ? (wallConfig.is_leader ? 'leader' : 'follower') : 'solo') + ' muted=' + video.muted + ' volume=' + video.volume); console.log('[wall/audio] video loaded file=' + item.filename + ' role=' + (wallConfig ? (wallConfig.is_leader ? 'leader' : 'follower') : 'solo') + ' muted=' + video.muted + ' volume=' + video.volume);
}; };
// If anything (browser, scripts, the user) tries to unmute a // If anything (browser, scripts, the user) tries to unmute a WALL
// follower, snap it back. This is the safety net for the audio // follower, snap it back. This is the safety net for the audio
// bug — without it, a single stray unmute call causes echo. // bug — without it, a single stray unmute call causes echo. Group
if (isFollower) { // members are NOT force-muted (per-item mute governs them).
if (forceMuted) {
video.addEventListener('volumechange', () => { video.addEventListener('volumechange', () => {
if (!video.muted) { video.muted = true; } if (!video.muted) { video.muted = true; }
}); });

View file

@ -68,13 +68,42 @@ router.post('/', (req, res) => {
// Update group // Update group
router.put('/:id', requireGroupWrite, (req, res) => { router.put('/:id', requireGroupWrite, (req, res) => {
const { name, color } = req.body; const { name, color, sync_enabled, leader_device_id } = req.body;
if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' }); if (color && !VALID_COLOR.test(color)) return res.status(400).json({ error: 'invalid color format, use #RRGGBB' });
if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id); if (name) db.prepare('UPDATE device_groups SET name = ? WHERE id = ?').run(name, req.params.id);
if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ?').run(color, req.params.id); if (color) db.prepare('UPDATE device_groups SET color = ? WHERE id = ?').run(color, req.params.id);
// #group-sync: enable synchronized playback + optional pinned leader.
if (sync_enabled !== undefined) {
db.prepare('UPDATE device_groups SET sync_enabled = ? WHERE id = ?').run(sync_enabled ? 1 : 0, req.params.id);
}
if (leader_device_id !== undefined) {
if (leader_device_id !== null) {
const isMember = db.prepare('SELECT 1 FROM device_group_members WHERE group_id = ? AND device_id = ?').get(req.params.id, leader_device_id);
if (!isMember) return res.status(400).json({ error: 'leader_device_id must be a member of this group' });
}
db.prepare('UPDATE device_groups SET leader_device_id = ? WHERE id = ?').run(leader_device_id || null, req.params.id);
}
// Re-push to every member so they enter/exit sync mode and refresh their is_leader flag.
if (sync_enabled !== undefined || leader_device_id !== undefined) {
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
for (const m of members) pushPlaylistToDevice(req, m.device_id);
}
res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id)); res.json(db.prepare('SELECT * FROM device_groups WHERE id = ?').get(req.params.id));
}); });
// #group-sync: manual "Resync now" — nudge every member to re-snap to the shared schedule
// immediately. Sync is clock/schedule based (no leader), so this just forces an instant re-align
// (handy after a content change or if an operator wants to eyeball alignment).
router.post('/:id/resync', requireGroupWrite, (req, res) => {
const io = req.app.get('io');
const members = db.prepare('SELECT device_id FROM device_group_members WHERE group_id = ?').all(req.params.id);
if (io) {
const deviceNs = io.of('/device');
for (const m of members) deviceNs.to(m.device_id).emit('group:resync', { group_id: req.params.id });
}
res.json({ ok: true, notified: members.length });
});
// Delete group — converts group schedules to per-device schedules first // Delete group — converts group schedules to per-device schedules first
router.delete('/:id', requireGroupWrite, (req, res) => { router.delete('/:id', requireGroupWrite, (req, res) => {
const groupId = req.params.id; const groupId = req.params.id;

View file

@ -0,0 +1,69 @@
// #group-sync server contract: (1) the heartbeat-ack carries the server clock + echoes the client's
// send time (NTP-style discipline the players use to build a cached offset), and (2) the manual
// "Resync now" route fans a group:resync out to a group's members. Boots a real server + real device
// socket (same harness style as v4-exit-signal-phase3 PART A).
const path = require('node:path'); const os = require('node:os'); const crypto = require('node:crypto');
const fs = require('node:fs');
const { test, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const ioClient = require('../node_modules/socket.io-client');
const sleep = ms => new Promise(r => setTimeout(r, ms));
const PORT = 3976; const BASE = `http://127.0.0.1:${PORT}`;
const DATA_DIR = path.join(os.tmpdir(), 'st-gsync-' + crypto.randomBytes(4).toString('hex'));
let proc, JWT;
before(async () => {
const logFd = fs.openSync(path.join(os.tmpdir(), 'st-gsync.log'), 'w');
proc = spawn('node', ['server.js'], { cwd: path.join(__dirname, '..'), env: { ...process.env, DATA_DIR, SELF_HOSTED: 'true', PORT: String(PORT), NODE_ENV: 'test' }, stdio: ['ignore', logFd, logFd] });
let up = false; for (let i = 0; i < 80; i++) { try { if ((await fetch(BASE + '/api/status')).ok) { up = true; break; } } catch {} await sleep(250); }
if (!up) throw new Error('boot fail');
JWT = (await (await fetch(BASE + '/api/auth/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'op@t.local', password: 'test12345', name: 'Op' }) })).json()).token;
});
after(() => { try { proc.kill('SIGKILL'); } catch {} });
const connect = () => ioClient(`${BASE}/device`, { transports: ['websocket'], reconnection: false, forceNew: true });
const reg = (s, m) => new Promise((res, rej) => { s.once('device:registered', d => res(d)); s.emit('device:register', m); setTimeout(() => rej(new Error('to')), 5000); });
const pair = (c) => fetch(BASE + '/api/provision/pair', { method: 'POST', headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: JSON.stringify({ pairing_code: c, name: 't' }) });
const api = (p, method, body) => fetch(BASE + p, { method, headers: { Authorization: 'Bearer ' + JWT, 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
test('heartbeat-ack carries server_ms and echoes client_ms (NTP-style clock discipline)', async () => {
const s = connect(); await new Promise(r => s.on('connect', r));
const d = await reg(s, { pairing_code: '830001', fingerprint: 'gf1', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await pair('830001'); await sleep(150);
const t1 = Date.now();
const ack = await new Promise((res) => { s.once('device:heartbeat-ack', res); s.emit('device:heartbeat', { device_id: d.device_id, client_ms: t1, telemetry: {} }); setTimeout(() => res(null), 2000); });
assert.ok(ack, 'an ack was received');
assert.equal(typeof ack.server_ms, 'number', 'ack carries the server clock (server_ms)');
assert.equal(ack.client_ms, t1, 'ack echoes the client send time (t1) verbatim for RTT correction');
assert.ok(ack.server_ms >= t1 - 5000 && ack.server_ms <= Date.now() + 5000, 'server_ms is a sane wall-clock');
s.close();
});
test('POST /groups/:id/resync fans group:resync out to the group members', async () => {
// A device to receive the nudge.
const s = connect(); await new Promise(r => s.on('connect', r));
const d = await reg(s, { pairing_code: '830002', fingerprint: 'gf2', device_info: {}, client_type: 'apk', contract_version: 'v4' });
await pair('830002'); await sleep(150);
// Create a group, add the device, enable sync.
const grp = await (await api('/api/groups', 'POST', { name: 'sync-grp' })).json();
assert.ok(grp.id, 'group created');
const addRes = await api(`/api/groups/${grp.id}/devices`, 'POST', { device_id: d.device_id });
assert.ok(addRes.status === 200 || addRes.status === 201, 'device joined the group');
await api(`/api/groups/${grp.id}`, 'PUT', { sync_enabled: true });
// Arm a listener, then trigger the manual resync.
const got = new Promise((res) => { s.once('group:resync', res); setTimeout(() => res(null), 2000); });
const r = await api(`/api/groups/${grp.id}/resync`, 'POST');
assert.equal(r.status, 200, 'resync route ok');
const body = await r.json();
assert.ok(body.notified >= 1, 'reports at least one member notified');
const msg = await got;
assert.ok(msg, 'the member received group:resync');
assert.equal(msg.group_id, grp.id, 'resync carries the group id');
s.close();
});

View file

@ -50,21 +50,21 @@ test('B/player IDEMPOTENT: crash then pagehide -> only crashed (crash not relabe
assert.equal(h.beacons.length, 1); assert.equal(h.beacons[0].reason, 'crashed'); assert.equal(h.beacons.length, 1); assert.equal(h.beacons[0].reason, 'crashed');
}); });
// ============ PART B — .wgt classification (real source, lines 663-697) ============ // ============ PART B — .wgt classification (real source, lines 723-751) ============
const TIZEN = path.join(__dirname, '../../tizen/js/app.js'); const TIZEN = path.join(__dirname, '../../tizen/js/app.js');
test('B/wgt CRASH: error/rejection -> crashed (socket AND beacon; server dedups)', () => { test('B/wgt CRASH: error/rejection -> crashed (socket AND beacon; server dedups)', () => {
const h = harness(TIZEN, 682, 716); h.fire('error', { error: { message: 'boom' } }); const h = harness(TIZEN, 723, 751); h.fire('error', { error: { message: 'boom' } });
assert.equal(h.beacons[0].reason, 'crashed'); assert.equal(h.beacons[0].reason, 'crashed');
assert.equal(h.socketSends[0].reason, 'crashed'); assert.equal(h.socketSends[0].ev, 'device:exit'); assert.equal(h.socketSends[0].reason, 'crashed'); assert.equal(h.socketSends[0].ev, 'device:exit');
}); });
test('B/wgt NO-MISCLASSIFY: resource error is not a crash', () => { test('B/wgt NO-MISCLASSIFY: resource error is not a crash', () => {
const h = harness(TIZEN, 682, 716); h.fire('error', { target: { src: 'x.png' } }); const h = harness(TIZEN, 723, 751); h.fire('error', { target: { src: 'x.png' } });
assert.equal(h.beacons.length, 0); assert.equal(h.socketSends.length, 0); assert.equal(h.beacons.length, 0); assert.equal(h.socketSends.length, 0);
}); });
test('B/wgt CLEAN-CLOSE: pagehide(false) -> clean_exit; BACKGROUNDING pagehide(true) -> NO exit', () => { test('B/wgt CLEAN-CLOSE: pagehide(false) -> clean_exit; BACKGROUNDING pagehide(true) -> NO exit', () => {
let h = harness(TIZEN, 682, 716); h.fire('pagehide', { persisted: false }); let h = harness(TIZEN, 723, 751); h.fire('pagehide', { persisted: false });
assert.equal(h.beacons[0].reason, 'clean_exit'); assert.equal(h.beacons[0].reason, 'clean_exit');
h = harness(TIZEN, 682, 716); h.fire('pagehide', { persisted: true }); h = harness(TIZEN, 723, 751); h.fire('pagehide', { persisted: true });
assert.equal(h.beacons.length, 0, 'suspend must NOT emit clean_exit'); assert.equal(h.beacons.length, 0, 'suspend must NOT emit clean_exit');
assert.equal((h.handlers['visibilitychange'] || []).length, 0, 'no visibilitychange in the exit block'); assert.equal((h.handlers['visibilitychange'] || []).length, 0, 'no visibilitychange in the exit block');
}); });

View file

@ -104,6 +104,52 @@ function logDeviceStatus(deviceId, status) {
// Build playlist payload with layout and zones // Build playlist payload with layout and zones
// Reads from published_snapshot (Phase 3) so draft edits don't affect live devices // Reads from published_snapshot (Phase 3) so draft edits don't affect live devices
// #group-sync: membership is the device_group_members m2m table (a device can be in several
// groups). Sync-eligible members = the group's members whose playlist MATCHES the group's shared
// playlist. A member on a different playlist is ignored (never synced) — index sync would be
// meaningless. Ordered by id for a stable auto-election.
function groupSyncMembers(group) {
if (!group || !group.playlist_id) return [];
return db.prepare(`
SELECT d.id, d.status FROM devices d
JOIN device_group_members dgm ON dgm.device_id = d.id
WHERE dgm.group_id = ? AND d.playlist_id = ? ORDER BY d.id
`).all(group.id, group.playlist_id);
}
// Elect the group's sync leader: the pinned leader if it's an online, playlist-matching member;
// else the first online matching member; else the first matching member (stable id while all
// offline). null if the group has no eligible members.
function resolveGroupLeader(group) {
const members = groupSyncMembers(group);
if (!members.length) return null;
const online = members.filter(m => m.status === 'online');
if (group.leader_device_id && online.some(m => m.id === group.leader_device_id)) return group.leader_device_id;
if (online.length) return online[0].id;
return members[0].id;
}
// The device's sync group: a sync-enabled group it belongs to (m2m) whose shared playlist THIS
// device is on. Deterministic pick if it's somehow in several. Returns the group row or null.
function deviceSyncGroup(deviceId, devicePlaylistId) {
if (!devicePlaylistId) return null;
return db.prepare(`
SELECT g.id, g.sync_enabled, g.playlist_id, g.leader_device_id
FROM device_groups g JOIN device_group_members dgm ON dgm.group_id = g.id
WHERE dgm.device_id = ? AND g.sync_enabled = 1 AND g.playlist_id = ?
ORDER BY g.name ASC, g.id ASC LIMIT 1
`).get(deviceId, devicePlaylistId) || null;
}
// Build the group_sync block for a device, or null (the playlist-match guard lives in deviceSyncGroup).
function resolveGroupSync(device, deviceId) {
const group = deviceSyncGroup(deviceId, device?.playlist_id);
if (!group) return null;
const leaderId = resolveGroupLeader(group);
if (!leaderId) return null;
return { group_id: group.id, is_leader: leaderId === deviceId };
}
function buildPlaylistPayload(deviceId) { function buildPlaylistPayload(deviceId) {
const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id, timezone, reported_timezone FROM devices WHERE id = ?').get(deviceId); const device = db.prepare('SELECT playlist_id, layout_id, orientation, wall_id, timezone, reported_timezone FROM devices WHERE id = ?').get(deviceId);
@ -189,9 +235,12 @@ function buildPlaylistPayload(deviceId) {
// last OS-reported zone; otherwise null = the player trusts its own OS clock. // last OS-reported zone; otherwise null = the player trusts its own OS clock.
const tzOverride = (device?.timezone && device.timezone !== 'UTC') ? device.timezone : null; const tzOverride = (device?.timezone && device.timezone !== 'UTC') ? device.timezone : null;
const timezone = tzOverride || device?.reported_timezone || null; const timezone = tzOverride || device?.reported_timezone || null;
// #group-sync: synchronized group playback (wall takes precedence — a wall member is never
// also group-synced). Null unless the device is on a sync-enabled group's matching playlist.
const group_sync = wall_config ? null : resolveGroupSync(device, deviceId);
// #104: shared shape + zone-reset tail so the device payload and the dashboard // #104: shared shape + zone-reset tail so the device payload and the dashboard
// preview payload (GET /api/playlists/:id/preview-payload) can never drift. // preview payload (GET /api/playlists/:id/preview-payload) can never drift.
return assemblePayload({ assignments, layout, orientation: device?.orientation || 'landscape', wall_config, timezone }); return assemblePayload({ assignments, layout, orientation: device?.orientation || 'landscape', wall_config, group_sync, timezone });
} }
// #104: the canonical player payload shape, shared by the device path // #104: the canonical player payload shape, shared by the device path
@ -199,7 +248,7 @@ function buildPlaylistPayload(deviceId) {
// Zone reset: if this isn't a real multi-zone layout (single zone or no layout), // Zone reset: if this isn't a real multi-zone layout (single zone or no layout),
// strip any leftover zone_id so content falls back to the fullscreen renderer // strip any leftover zone_id so content falls back to the fullscreen renderer
// instead of binding to a now-gone left/right zone and never playing. // instead of binding to a now-gone left/right zone and never playing.
function assemblePayload({ assignments, layout, orientation, wall_config, timezone }) { function assemblePayload({ assignments, layout, orientation, wall_config, group_sync, timezone }) {
let a = Array.isArray(assignments) ? assignments : []; let a = Array.isArray(assignments) ? assignments : [];
const zoneCount = layout?.zones?.length || 0; const zoneCount = layout?.zones?.length || 0;
if (zoneCount < 2) a = a.map(x => (x && x.zone_id != null ? { ...x, zone_id: null } : x)); if (zoneCount < 2) a = a.map(x => (x && x.zone_id != null ? { ...x, zone_id: null } : x));
@ -208,6 +257,7 @@ function assemblePayload({ assignments, layout, orientation, wall_config, timezo
layout: layout || null, layout: layout || null,
orientation: orientation || 'landscape', orientation: orientation || 'landscape',
wall_config: wall_config || null, wall_config: wall_config || null,
group_sync: group_sync || null,
timezone: timezone || null, timezone: timezone || null,
}; };
} }
@ -607,6 +657,24 @@ module.exports = function setupDeviceSocket(io) {
} catch (e) { console.error('Wall leader reclaim failed:', e.message); } } catch (e) { console.error('Wall leader reclaim failed:', e.message); }
} }
// #group-sync: on (re)connect of a sync-group member, re-push the payload to the OTHER
// sync-eligible members so their is_leader flag refreshes — this self-heals leadership
// when the pinned leader returns or a first-online fallback takes over. The effective
// leader is COMPUTED (resolveGroupLeader), never persisted, so the operator's pin is
// preserved. The connecting device gets its own payload below.
try {
const syncGroups = db.prepare(`
SELECT g.id, g.sync_enabled, g.playlist_id FROM device_groups g
JOIN device_group_members dgm ON dgm.group_id = g.id
WHERE dgm.device_id = ? AND g.sync_enabled = 1 AND g.playlist_id IS NOT NULL
`).all(device_id);
for (const group of syncGroups) {
for (const m of groupSyncMembers(group)) {
if (m.id !== device_id) commandQueue.queueOrEmitPlaylistUpdate(deviceNs, m.id, buildPlaylistPayload);
}
}
} catch (e) { console.error('Group sync re-push failed:', e.message); }
// Check subscription/trial status before sending playlist // Check subscription/trial status before sending playlist
const access = checkDeviceAccess(device_id); const access = checkDeviceAccess(device_id);
if (!access.allowed) { if (!access.allowed) {
@ -710,7 +778,12 @@ module.exports = function setupDeviceSocket(io) {
// finishes re-registering). Anonymous / never-authenticated sockets are NOT acked (degrade-safe // finishes re-registering). Anonymous / never-authenticated sockets are NOT acked (degrade-safe
// covers them). Old clients simply ignore the ack — harmless. // covers them). Old clients simply ignore the ack — harmless.
if (liveness.ackableHeartbeat(currentDeviceId, device_id, deviceExists)) { if (liveness.ackableHeartbeat(currentDeviceId, device_id, deviceExists)) {
socket.emit('device:heartbeat-ack', {}); // cheap, to the emitting socket only // #group-sync clock discipline: the server is the time authority. Echo the client's send
// time (t1) and stamp the server's clock (t2≈t3, synchronous handler) so the client can do
// NTP-style offset+RTT correction: offset = server_ms - (t1 + t4)/2. The offset is CACHED by
// the client and used at play-time (local + offset), so schedule-based group sync keeps
// working through an internet outage. Absent/old clients just ignore the extra fields.
socket.emit('device:heartbeat-ack', { server_ms: Date.now(), client_ms: data?.client_ms ?? null });
} }
if (!requireDeviceAuth()) return; if (!requireDeviceAuth()) return;
if (!device_id || device_id !== currentDeviceId) return; if (!device_id || device_id !== currentDeviceId) return;
@ -939,6 +1012,41 @@ module.exports = function setupDeviceSocket(io) {
}); });
}); });
// #group-sync: leader broadcasts its index+position; relay to the OTHER sync-eligible members
// (same group_id AND on the group's shared playlist — the playlist-match guard). Mirrors
// wall:sync. The device_id is stamped with the authenticated id so followers can trust it.
// Sender must be an eligible member: in the group (m2m) AND on the group's shared playlist.
function groupSenderEligible(group) {
if (!group || !group.sync_enabled || !group.playlist_id) return false;
return !!db.prepare(`
SELECT 1 FROM device_group_members dgm JOIN devices d ON d.id = dgm.device_id
WHERE dgm.group_id = ? AND dgm.device_id = ? AND d.playlist_id = ?
`).get(group.id, currentDeviceId, group.playlist_id);
}
socket.on('group:sync', (data) => {
if (!requireDeviceAuth()) return;
if (!data?.group_id) return;
const group = db.prepare('SELECT id, sync_enabled, playlist_id FROM device_groups WHERE id = ?').get(data.group_id);
if (!groupSenderEligible(group)) return;
const payload = { ...data, device_id: currentDeviceId };
for (const m of groupSyncMembers(group)) {
if (m.id !== currentDeviceId) deviceNs.to(m.id).emit('group:sync', payload);
}
});
// A follower asks the current leader for an immediate position update (on (re)connect, so it
// doesn't drift a tick). Forwarded only to the group's elected leader, only from an eligible member.
socket.on('group:sync-request', (data) => {
if (!requireDeviceAuth()) return;
if (!data?.group_id) return;
const group = db.prepare('SELECT id, sync_enabled, playlist_id, leader_device_id FROM device_groups WHERE id = ?').get(data.group_id);
if (!groupSenderEligible(group)) return;
const leaderId = resolveGroupLeader(group);
if (!leaderId || leaderId === currentDeviceId) return;
deviceNs.to(leaderId).emit('group:sync-request', { group_id: group.id, requested_by: currentDeviceId });
});
socket.on('disconnect', () => { socket.on('disconnect', () => {
// #146: this socket was force-evicted by a newer registration for the same // #146: this socket was force-evicted by a newer registration for the same
// device. The new socket owns the device now (or is mid-register), so this // device. The new socket owns the device now (or is mid-register), so this

View file

@ -33,7 +33,8 @@
token: 'st_device_token', token: 'st_device_token',
fp: 'st_fingerprint', fp: 'st_fingerprint',
code: 'st_pairing_code', code: 'st_pairing_code',
payload: 'st_payload_cache' // A2: last renderable playlist-update, replayed on cold-start/offline payload: 'st_payload_cache', // A2: last renderable playlist-update, replayed on cold-start/offline
clock: 'st_clock_offset' // #group-sync: cached server-clock offset (survives reboot/outage)
}; };
// ---- persistent state ---- // ---- persistent state ----
@ -199,6 +200,26 @@
var authenticated = false; // #118: true only between device:registered and disconnect/auth-error var authenticated = false; // #118: true only between device:registered and disconnect/auth-error
var streamTimer = null; // #120: dashboard preview streaming interval var streamTimer = null; // #120: dashboard preview streaming interval
// #group-sync clock discipline. Server is the time authority (heartbeat-ack). Cache a smoothed
// offset so synced_now = Date.now() + clockOffsetMs keeps schedule sync aligned through an outage.
var clockOffsetMs = (function () { var v = Number(get(LS.clock)); return isFinite(v) ? v : 0; })();
var clockRttMs = null;
function syncedNow() { return Date.now() + clockOffsetMs; }
function ingestClockSample(serverMs, clientMs) {
if (!serverMs || !clientMs) return;
var t4 = Date.now(), rtt = Math.max(0, t4 - clientMs);
if (rtt > 5000) return; // absurd RTT (GC/sleep stall) — don't poison offset
var sample = serverMs - (clientMs + t4) / 2; // NTP-style: offset = server - (t1+t4)/2
if (clockRttMs === null || Math.abs(sample - clockOffsetMs) > 1000) clockOffsetMs = Math.round(sample);
else clockOffsetMs = Math.round(clockOffsetMs * 0.8 + sample * 0.2);
clockRttMs = Math.round(rtt);
set(LS.clock, String(clockOffsetMs));
}
// Stream a group-sync diagnostic to the dashboard live-log (tag 'sync').
function reportSync(level, msg) {
try { if (socket && socket.connected && deviceId) socket.emit('device:log', { device_id: deviceId, tag: 'sync', level: level, message: msg }); } catch (e) {}
}
function deviceInfo() { function deviceInfo() {
return { return {
android_version: 'Tizen ' + (tizenVersion() || ''), android_version: 'Tizen ' + (tizenVersion() || ''),
@ -293,7 +314,7 @@
// A server that sends engine pings but no app-ack (old/pre-contract server) never arms us, so the // A server that sends engine pings but no app-ack (old/pre-contract server) never arms us, so the
// watchdog can't false-fire — markAlive (onAny) still refreshed lastServerMsgAt for the silence // watchdog can't false-fire — markAlive (onAny) still refreshed lastServerMsgAt for the silence
// check, but ARMING is gated on the ack specifically. // check, but ARMING is gated on the ack specifically.
socket.on('device:heartbeat-ack', function () { livenessConfirmed = true; }); socket.on('device:heartbeat-ack', function (d) { livenessConfirmed = true; if (d) ingestClockSample(d.server_ms, d.client_ms); });
socket.on('device:paired', function () { socket.on('device:paired', function () {
del(LS.code); clearToast(); show(elStage); del(LS.code); clearToast(); show(elStage);
@ -369,6 +390,12 @@
// Leader broadcasts position; followers align index + drift-correct their video. // Leader broadcasts position; followers align index + drift-correct their video.
socket.on('wall:sync', function (d) { wallController.onSync(d); }); socket.on('wall:sync', function (d) { wallController.onSync(d); });
socket.on('wall:sync-request', function (d) { wallController.onSyncRequest(d); }); socket.on('wall:sync-request', function (d) { wallController.onSyncRequest(d); });
// #group-sync: clock/schedule — no leader relay. Server only nudges an immediate re-align.
socket.on('group:resync', function (d) {
if (!groupSync.active()) return;
if (d && d.group_id && d.group_id !== groupSync.groupId) return;
reportSync('info', 'manual resync requested'); groupSync.tick();
});
// #109: PiP overlay — a pushed floating layer above the playlist. The player // #109: PiP overlay — a pushed floating layer above the playlist. The player
// fetches the uri itself (same trust model as remote_url content). // fetches the uri itself (same trust model as remote_url content).
@ -400,7 +427,7 @@
// #118: only beat on a socket that finished device:register, or the server's // #118: only beat on a socket that finished device:register, or the server's
// requireDeviceAuth() rejects the beat with device:auth-error. // requireDeviceAuth() rejects the beat with device:auth-error.
if (!socket || !socket.connected || !deviceId || !authenticated) return; if (!socket || !socket.connected || !deviceId || !authenticated) return;
socket.emit('device:heartbeat', { device_id: deviceId, telemetry: telemetry() }); socket.emit('device:heartbeat', { device_id: deviceId, client_ms: Date.now(), telemetry: telemetry() });
// FIX C — every 4th beat (~60s) ask for a fresh playlist by re-emitting device:register; // FIX C — every 4th beat (~60s) ask for a fresh playlist by re-emitting device:register;
// the server responds with a fresh device:playlist-update (deviceSocket.js). This was // the server responds with a fresh device:playlist-update (deviceSocket.js). This was
// previously a duplicate device:heartbeat (comment != code), so the .wgt had NO working // previously a duplicate device:heartbeat (comment != code), so the .wgt had NO working
@ -541,6 +568,8 @@
function () { return deviceId; }, function () { return deviceId; },
function () { return authenticated && !!socket && socket.connected; } function () { return authenticated && !!socket && socket.connected; }
); );
// #group-sync: clock/schedule group sync (no leader, offline-native). Separate from WallController.
var groupSync = new GroupSyncController(player, function () { return clockOffsetMs; }, reportSync);
// #109: PiP overlay layer. Renders into #pip (above #stage); never touches the // #109: PiP overlay layer. Renders into #pip (above #stage); never touches the
// playlist. Reports show/clear over device:log (tag 'pip'). // playlist. Reports show/clear over device:log (tag 'pip').
var pipOverlay = new PipOverlay(elPip, { log: reportPip }); var pipOverlay = new PipOverlay(elPip, { log: reportPip });
@ -599,6 +628,7 @@
// #162: only blank the zone renderer when switching away from it, and invalidate the // #162: only blank the zone renderer when switching away from it, and invalidate the
// player's sig so it repaints (see the single-zone branch for the full rationale). // player's sig so it repaints (see the single-zone branch for the full rationale).
if (stageOwner !== 'player') { zoneRenderer.clear(); player.invalidate(); } if (stageOwner !== 'player') { zoneRenderer.clear(); player.invalidate(); }
groupSync.exit(); // wall and group are mutually exclusive
wallController.apply(payload.wall_config); wallController.apply(payload.wall_config);
player.setTimezone(payload.timezone || null); player.setTimezone(payload.timezone || null);
player.load(payload.assignments || []); player.load(payload.assignments || []);
@ -606,7 +636,12 @@
return; return;
} }
wallController.exit(); // leave wall mode if we were in it // #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, so it
// keeps running offline. Content renders through the normal path below (per-item mute honored).
wallController.exit(); // never in wall mode here
if (payload.group_sync) groupSync.apply(payload.group_sync.group_id);
else groupSync.exit();
applyOrientation(payload.orientation || 'landscape'); applyOrientation(payload.orientation || 'landscape');
var layout = payload.layout; var layout = payload.layout;
if (layout && Array.isArray(layout.zones) && layout.zones.length) { // B3: non-array zones would throw in zoneRenderer if (layout && Array.isArray(layout.zones) && layout.zones.length) { // B3: non-array zones would throw in zoneRenderer

View file

@ -31,8 +31,37 @@ function PlaylistPlayer(stageEl, getBase) {
this.itemStartedAt = 0; // wall position fallback for non-video items this.itemStartedAt = 0; // wall position fallback for non-video items
this.DEFAULT_DURATION = 10; this.DEFAULT_DURATION = 10;
this.MIN_DURATION = 3; this.MIN_DURATION = 3;
this.preloadEl = null; // #group-sync double buffer: pre-buffered next <video>
this.preloadIdx = -1;
} }
// Double buffer: build a hidden, buffering <video> for the next clip (in document.body so clearStage
// won't wipe it) so renderVideo can mount it instantly at the boundary (no black hold). Videos only.
PlaylistPlayer.prototype.preloadVideo = function (idx) {
if (this.preloadIdx === idx) return; // already handled this boundary
var item = this.items[idx];
if (!item) return;
if ((item.mime_type || '').indexOf('video/') !== 0) { this.preloadIdx = idx; this.preloadEl = null; return; }
try {
if (this.preloadEl && this.preloadEl.parentNode) this.preloadEl.parentNode.removeChild(this.preloadEl);
var v = document.createElement('video');
v.muted = true; v.setAttribute('playsinline', ''); v.preload = 'auto';
v.style.cssText = 'position:absolute;width:1px;height:1px;opacity:0;left:-9999px';
v.src = this.contentUrl(item);
v.load();
document.body.appendChild(v);
this.preloadEl = v; this.preloadIdx = idx;
} catch (e) { this.preloadEl = null; this.preloadIdx = -1; }
};
PlaylistPlayer.prototype._takePreload = function (idx) {
if (this.preloadIdx === idx && this.preloadEl) {
var el = this.preloadEl; this.preloadEl = null; this.preloadIdx = -1;
if (el.parentNode) el.parentNode.removeChild(el);
return el;
}
return null;
};
PlaylistPlayer.prototype.load = function (assignments) { PlaylistPlayer.prototype.load = function (assignments) {
// B3: a malformed device:playlist-update with a non-array `assignments` used to throw // B3: a malformed device:playlist-update with a non-array `assignments` used to throw
// (.filter is not a function) out of the socket handler; coerce to [] instead. // (.filter is not a function) out of the socket handler; coerce to [] instead.
@ -43,10 +72,18 @@ PlaylistPlayer.prototype.load = function (assignments) {
items.sort(function (a, b) { return (a.sort_order || 0) - (b.sort_order || 0); }); items.sort(function (a, b) { return (a.sort_order || 0) - (b.sort_order || 0); });
var sig = JSON.stringify(items.map(function (a) { var sig = JSON.stringify(items.map(function (a) {
// #74/#75: include schedules so a schedule edit (same content) re-renders. // STRUCTURAL only. #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 || []]; // duration_sec is EXCLUDED so a duration edit applies in place (below), not as a restart.
return [a.content_id, a.widget_id, a.remote_url, a.mime_type, a.schedules || []];
})); }));
if (sig === this.sig && this.items.length) return; // unchanged, keep playing if (sig === this.sig && this.items.length) {
// In-place duration refresh: patch duration_sec on the live items so a duration edit takes effect
// (group schedule tick re-anchors; solo advance uses it next) WITHOUT restarting playback.
for (var k = 0; k < this.items.length && k < items.length; k++) {
if (this.items[k].duration_sec !== items[k].duration_sec) this.items[k].duration_sec = items[k].duration_sec;
}
return;
}
this.sig = sig; this.sig = sig;
this.items = items; this.items = items;
@ -238,14 +275,18 @@ PlaylistPlayer.prototype.renderImage = function (item, single) {
PlaylistPlayer.prototype.renderVideo = function (item, single) { PlaylistPlayer.prototype.renderVideo = function (item, single) {
var self = this; var self = this;
var v = document.createElement('video'); // Double buffer: reuse the pre-buffered element for this index if warmed (no black hold); its src
// is already set + buffering, so playback starts near-instantly.
var pre = this._takePreload(this.index);
var v = pre || document.createElement('video');
this.currentVideoEl = v; // wall: leader reads currentTime; follower drift-corrects this this.currentVideoEl = v; // wall: leader reads currentTime; follower drift-corrects this
this.fit(v, item); this.fit(v, item);
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', ''); v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
v.loop = single; // single item loops; multi advances on end v.loop = single; // single item loops; multi advances on end
v.onended = function () { if (!single) self.advance(); }; v.onended = function () { if (!single) self.advance(); };
v.onerror = function () { self.skipSoon(); }; v.onerror = function () { self.skipSoon(); };
v.src = this.contentUrl(item); if (!pre) v.src = this.contentUrl(item);
v.style.cssText = ''; // clear the offscreen-hide style if reused
this.stage.appendChild(v); this.stage.appendChild(v);
var p = v.play(); if (p && p.catch) p.catch(function () {}); var p = v.play(); if (p && p.catch) p.catch(function () {});
// Safety net: if 'ended' never fires (rare), advance after the known // Safety net: if 'ended' never fires (rare), advance after the known
@ -557,15 +598,28 @@ WallController.prototype.styleStage = function (config) {
st.transform = ''; st.transformOrigin = ''; st.transform = ''; st.transformOrigin = '';
}; };
// #group-sync: the sync id is wall_id (WALL) or group_id (GROUP mode).
WallController.prototype.syncId = function (c) { return c && (c.mode === 'group' ? c.group_id : c.wall_id); };
WallController.prototype.clearStageStyle = function () {
this.stage.classList.remove('wall-mode');
var st = this.stage.style;
st.position = ''; st.left = ''; st.top = ''; st.width = ''; st.height = '';
st.transform = ''; st.transformOrigin = '';
};
WallController.prototype.apply = function (config) { WallController.prototype.apply = function (config) {
var isGroup = config.mode === 'group';
var id = this.syncId(config);
var roleChanged = !this.config || var roleChanged = !this.config ||
this.config.is_leader !== config.is_leader || this.config.is_leader !== config.is_leader ||
this.config.wall_id !== config.wall_id; this.syncId(this.config) !== id;
this.config = config; this.config = config;
this.styleStage(config); // WALL: map this screen's slice (transform). GROUP: full-screen — clear any wall styling; the
// normal render path honors per-item mute (no forced follower mute).
if (isGroup) this.clearStageStyle(); else this.styleStage(config);
this.player.setWallFollower(!config.is_leader); this.player.setWallFollower(!config.is_leader);
// Entering wall mode or flipping role: force a fresh render so leader/follower // Entering sync mode or flipping role: force a fresh render so leader/follower
// semantics take effect (otherwise an unchanged signature de-dupes the load). // semantics take effect (otherwise an unchanged signature de-dupes the load).
if (roleChanged) this.player.invalidate(); if (roleChanged) this.player.invalidate();
@ -580,7 +634,9 @@ WallController.prototype.apply = function (config) {
// Follower: ask the leader for its position now so we don't show the item start // Follower: ask the leader for its position now so we don't show the item start
// until the next periodic tick (up to ~250ms of visible drift on a fresh join). // until the next periodic tick (up to ~250ms of visible drift on a fresh join).
var s = this.getSocket(); var s = this.getSocket();
if (s && this.canEmit()) s.emit('wall:sync-request', { wall_id: config.wall_id }); if (s && this.canEmit()) {
s.emit(isGroup ? 'group:sync-request' : 'wall:sync-request', isGroup ? { group_id: id } : { wall_id: id });
}
} }
}; };
@ -590,11 +646,8 @@ WallController.prototype.exit = function () {
this.config = null; this.config = null;
this.player.setWallFollower(false); this.player.setWallFollower(false);
if (wasActive) { if (wasActive) {
this.stage.classList.remove('wall-mode'); this.clearStageStyle();
var st = this.stage.style; this.player.invalidate(); // re-render cleanly back into normal (non-sync) mode
st.position = ''; st.left = ''; st.top = ''; st.width = ''; st.height = '';
st.transform = ''; st.transformOrigin = '';
this.player.invalidate(); // re-render cleanly back into normal (non-wall) mode
} }
}; };
@ -606,19 +659,22 @@ WallController.prototype.emitSync = function () {
var v = this.player.getCurrentVideo(); var v = this.player.getCurrentVideo();
var pos = v ? (v.currentTime || 0) var pos = v ? (v.currentTime || 0)
: Math.max(0, (Date.now() - this.player.getItemStartedAt()) / 1000); : Math.max(0, (Date.now() - this.player.getItemStartedAt()) / 1000);
s.emit('wall:sync', { var msg = {
wall_id: this.config.wall_id,
device_id: this.getDeviceId(), device_id: this.getDeviceId(),
current_index: this.player.getIndex(), current_index: this.player.getIndex(),
content_id: item.content_id || null, content_id: item.content_id || null,
position_sec: pos, position_sec: pos,
sent_at: Date.now() sent_at: Date.now()
}); };
if (this.config.mode === 'group') { msg.group_id = this.config.group_id; s.emit('group:sync', msg); }
else { msg.wall_id = this.config.wall_id; s.emit('wall:sync', msg); }
}; };
WallController.prototype.onSync = function (data) { WallController.prototype.onSync = function (data) {
var c = this.config; var c = this.config;
if (!c || c.is_leader || !data || data.wall_id !== c.wall_id) return; if (!c || c.is_leader || !data) return;
var isG = c.mode === 'group';
if ((isG ? data.group_id : data.wall_id) !== this.syncId(c)) return;
// Align to the leader's current item. // Align to the leader's current item.
if (typeof data.current_index === 'number' && data.current_index !== this.player.getIndex()) { if (typeof data.current_index === 'number' && data.current_index !== this.player.getIndex()) {
this.player.gotoIndex(data.current_index); this.player.gotoIndex(data.current_index);
@ -642,7 +698,111 @@ WallController.prototype.onSync = function (data) {
}; };
WallController.prototype.onSyncRequest = function (data) { WallController.prototype.onSyncRequest = function (data) {
if (!this.config || !this.config.is_leader) return; var c = this.config;
if (data && data.wall_id && data.wall_id !== this.config.wall_id) return; if (!c || !c.is_leader) return;
var isG = c.mode === 'group';
var dataId = data && (isG ? data.group_id : data.wall_id);
if (dataId && dataId !== this.syncId(c)) return;
this.emitSync(); this.emitSync();
}; };
/* GroupSyncController clock/schedule group sync for the Tizen player. Mirrors the WEB player's
* groupScheduleTick (server/player/index.html). Unlike WallController there is NO leader and NO
* server relay: every same-playlist member lays the deterministic playlist schedule (each item
* occupies durationMs, in order, dayparted items skipped) on a server-DISCIPLINED clock and derives
* the identical (index, position) locally. That is offline-native (no server at play-time) and
* cannot go split-brain. Reuses the player's wallFollower mode (loop + no auto-advance).
*/
function GroupSyncController(player, getOffsetMs, report) {
this.player = player;
this.getOffsetMs = getOffsetMs; // () -> smoothed clock offset in ms (server is the time authority)
this.report = report; // (level, msg) -> dashboard live-log
this.groupId = null;
this.timer = null;
this.dbgLast = 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 rides the gentle nudge afterward.
this.alignPending = true;
this.lastAlignedIndex = -1;
}
GroupSyncController.prototype.active = function () { return !!this.groupId; };
GroupSyncController.prototype.syncedNow = function () { return Date.now() + (this.getOffsetMs() || 0); };
GroupSyncController.prototype.slots = function () {
var p = this.player, items = p.items, acc = 0, s = [];
for (var i = 0; i < items.length; i++) {
if (!p.scheduleAllows(items[i])) continue; // same daypart filter as solo playback
// CANONICAL slot length — MUST match the web + Android engines exactly (max(1,dur||10)*1000).
// Deliberately NOT durationMs() (its MIN_DURATION=3 clamp would diverge from the other players).
var d = Math.max(1, Number(items[i].duration_sec) || 10) * 1000;
s.push({ index: i, start: acc, dur: d }); acc += d;
}
return { slots: s, period: acc };
};
GroupSyncController.prototype.target = function () {
var r = this.slots();
if (!r.slots.length || r.period <= 0) return null;
var phase = ((this.syncedNow() % r.period) + r.period) % r.period;
var ci = -1;
for (var i = 0; i < r.slots.length; i++) { var x = r.slots[i]; if (phase >= x.start && phase < x.start + x.dur) { ci = i; break; } }
if (ci < 0) ci = r.slots.length - 1;
var s = r.slots[ci], nx = r.slots[(ci + 1) % r.slots.length];
// nextIndex + secToBoundary drive the double buffer (preload the upcoming clip a few s early).
return { index: s.index, posSec: (phase - s.start) / 1000, nextIndex: nx.index, secToBoundary: (s.start + s.dur - phase) / 1000 };
};
GroupSyncController.prototype.tick = function () {
if (!this.groupId || !this.player.items.length) return;
var t = this.target(); if (!t) return;
// Double buffer: warm the next clip ~6s before the boundary (once per boundary).
if (t.nextIndex !== t.index && t.secToBoundary >= 0 && t.secToBoundary <= 6) this.player.preloadVideo(t.nextIndex);
var action = 'hold';
if (t.index !== this.player.getIndex()) {
this.player.gotoIndex(t.index); action = 'jump>' + t.index;
} else {
var v = this.player.getCurrentVideo();
if (v && isFinite(v.duration) && v.duration > 0) {
var target = t.posSec % v.duration; // loop-safe when slot > clip length
var drift = (v.currentTime || 0) - target, ad = Math.abs(drift);
if (this.player.getIndex() !== this.lastAlignedIndex) this.alignPending = true;
try {
if (this.alignPending) {
if (ad > 0.05) v.currentTime = target;
v.playbackRate = 1.0; this.alignPending = false; this.lastAlignedIndex = this.player.getIndex();
action = 'align ' + drift.toFixed(2);
}
else if (ad > 0.3) { v.currentTime = target; v.playbackRate = 1.0; action = 'seek ' + drift.toFixed(2); }
else if (ad > 0.05) { v.playbackRate = drift > 0 ? 0.97 : 1.03; action = 'nudge ' + drift.toFixed(2); }
else if (v.playbackRate !== 1.0) { v.playbackRate = 1.0; }
} catch (e) {}
}
}
// Log discrete corrections (jump/align/seek) immediately so the transition is visible; only the
// routine steady-state line (hold/nudge) is throttled — else the one-tick "align" on load reads
// misleadingly (sampled over by a later hold/nudge).
var now = Date.now();
var discrete = action.indexOf('jump') === 0 || action.indexOf('align') === 0 || action.indexOf('seek') === 0;
if ((discrete || now - this.dbgLast > 1000) && this.report) {
this.dbgLast = now;
this.report('info', 'idx=' + this.player.getIndex() + ' tgt=' + t.index + ' pos=' + t.posSec.toFixed(2) + ' off=' + (this.getOffsetMs() || 0) + 'ms ' + action);
}
};
GroupSyncController.prototype.apply = function (groupId) {
var first = !this.groupId;
this.groupId = groupId;
this.alignPending = true; this.lastAlignedIndex = -1; // snap the first item into sync on entry
this.player.setWallFollower(true); // group member: loop + no local auto-advance (schedule drives)
this.player.invalidate(); // force a clean re-render into follower semantics
this.tick(); // align immediately from the cached clock offset
if (this.timer) clearInterval(this.timer);
var self = this;
this.timer = setInterval(function () { self.tick(); }, 250); // 4Hz local correction
if (this.report) this.report('info', 'group-sync ' + (first ? 'entered' : 'refresh') + ' group=' + String(groupId).slice(0, 8) + ' off=' + (this.getOffsetMs() || 0) + 'ms');
};
GroupSyncController.prototype.exit = function () {
if (!this.groupId && !this.timer) return;
if (this.timer) { clearInterval(this.timer); this.timer = null; }
this.groupId = null;
this.player.setWallFollower(false);
this.player.invalidate();
this.player._takePreload(this.player.preloadIdx); // drop any warmed next-clip element
if (this.report) this.report('info', 'group-sync exited');
};