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.PipOverlay
import com.remotedisplay.player.player.WallController
import com.remotedisplay.player.player.GroupScheduleController
import com.remotedisplay.player.player.ZoneManager
import com.remotedisplay.player.remote.ScreenshotCapture
import com.remotedisplay.player.remote.TouchInjector
@ -57,6 +58,7 @@ class MainActivity : AppCompatActivity() {
private lateinit var updateChecker: UpdateChecker
private var zoneManager: ZoneManager? = null
private lateinit var wallController: WallController
private lateinit var groupSchedule: GroupScheduleController
private lateinit var pipOverlay: PipOverlay // #109: PiP overlay layer
private lateinit var playerView: PlayerView
@ -235,11 +237,33 @@ class MainActivity : AppCompatActivity() {
media = mediaPlayer,
playlist = playlistController,
deviceId = { config.deviceId },
emitSync = { wallId, idx, contentId, posSec -> wsService?.emitWallSync(wallId, idx, contentId, posSec) },
emitSyncRequest = { wallId -> wsService?.emitWallSyncRequest(wallId) },
emitSync = { isGroup, id, idx, contentId, posSec ->
if (isGroup) wsService?.emitGroupSync(id, idx, contentId, posSec)
else wsService?.emitWallSync(id, idx, contentId, posSec)
},
emitSyncRequest = { isGroup, id ->
if (isGroup) wsService?.emitGroupSyncRequest(id) else wsService?.emitWallSyncRequest(id)
},
applyTransform = { cfg -> applyWallTransform(cfg) }
)
// #group-sync: clock/schedule group sync (no leader, offline-native). Reads the disciplined
// clock from the bound service; streams diagnostics to the dashboard live-log (tag 'sync').
groupSchedule = GroupScheduleController(
playlist = playlistController,
media = mediaPlayer,
syncedNow = { wsService?.syncedNowMs() ?: System.currentTimeMillis() },
report = { msg -> wsService?.sendLog("sync", "info", msg) },
// Double buffer: warm the NEXT clip's second player if it's a locally-cached video, so the
// boundary switch is a warm swap (no black hold). Non-video / uncached items just skip it.
onPreloadNext = { idx ->
val next = playlistController.itemAt(idx)
if (next != null && !next.isRemote && next.mimeType.startsWith("video/")) {
contentCache.getCachedFile(next.contentId)?.let { mediaPlayer.preloadVideo(it) }
}
}
)
// Restore cached playlist for offline cold-start (play immediately from disk cache).
// Catch Throwable (not just Exception) so an OOM or corrupt entry can't kill the app
// before the WebSocket connects — that's the crash-loop scenario. If the cache is
@ -255,6 +279,10 @@ class MainActivity : AppCompatActivity() {
playlistController.setTimezone(if (cached.isNull("timezone")) null else cached.optString("timezone", "").ifEmpty { null })
playlistController.updatePlaylist(assignments)
playlistController.startIfNeeded()
// #group-sync: if this device was in a sync group, resume the schedule immediately
// from the cached clock offset — a reboot mid-outage comes back aligned, no server.
val cg = if (cached.isNull("group_sync")) null else cached.optJSONObject("group_sync")
if (cg != null) groupSchedule.apply(cg.optString("group_id"))
}
} catch (e: Throwable) {
Log.w("MainActivity", "Failed to restore cached playlist, clearing cache: ${e.message}")
@ -372,6 +400,17 @@ class MainActivity : AppCompatActivity() {
)
}
// #group-sync: fullscreen synchronized playback — no tile geometry, just id + leader role.
private fun parseGroupConfig(gs: JSONObject): WallController.WallConfig {
val zero = WallController.Rect(0f, 0f, 0f, 0f)
return WallController.WallConfig(
wallId = gs.optString("group_id", ""),
screen = zero, player = zero, rotation = 0,
isLeader = gs.optBoolean("is_leader", false),
mode = WallController.Mode.GROUP
)
}
// Video-wall slice transform. The content view represents the whole wall (player_rect);
// size + offset rootView so this screen's screen_rect fills the device viewport, content
// stretched to fill (object-fit:fill parity, set on the views via MediaPlayerManager).
@ -452,10 +491,16 @@ class MainActivity : AppCompatActivity() {
if (wallObj != null) {
com.remotedisplay.player.util.DebugLog.i("Player", "Layout: VIDEO-WALL (${assignments.length()} assignments)")
if (zoneManager?.hasZones() == true) zoneManager?.cleanup()
groupSchedule.exit() // wall and group are mutually exclusive
wallController.apply(parseWallConfig(wallObj))
playlistController.updatePlaylist(assignments)
} else {
wallController.exit()
// #group-sync: not a wall — enter clock/schedule group sync if the payload carries a
// group_sync block, else leave it. No leader/relay: the schedule tick drives index +
// position locally (offline-native). Content renders through the normal path below.
wallController.exit() // never in wall mode here
val groupObj = if (data.isNull("group_sync")) null else data.optJSONObject("group_sync")
if (groupObj != null) groupSchedule.apply(groupObj.optString("group_id")) else groupSchedule.exit()
applyOrientation(data.optString("orientation", "landscape"))
// Check for multi-zone layout
@ -663,6 +708,9 @@ class MainActivity : AppCompatActivity() {
wsService?.onWallSync = { data -> if (::wallController.isInitialized) wallController.onSync(data) }
wsService?.onWallSyncRequest = { data -> if (::wallController.isInitialized) wallController.onSyncRequest(data) }
// #group-sync is clock/schedule now (no leader relay). The server only nudges an immediate
// re-align (dashboard "Resync now"); the schedule tick otherwise runs entirely locally.
wsService?.onGroupResync = { if (::groupSchedule.isInitialized) groupSchedule.resync() }
// #109: PiP overlay show/clear (posted to the main thread by the service).
wsService?.onPipShow = { data -> if (::pipOverlay.isInitialized) pipOverlay.show(data) }
@ -1065,6 +1113,11 @@ class MainActivity : AppCompatActivity() {
override fun onDestroy() {
remoteStreaming = false
// Kill the wall/group leader tick BEFORE releasing media. The Handler is on the main looper
// (outlives this Activity), so a surviving tick would keep broadcasting sync frames against
// the released player forever — the zombie-leader / split-brain / garbage-position leak.
if (::wallController.isInitialized) wallController.shutdown()
if (::groupSchedule.isInitialized) groupSchedule.shutdown()
if (::downloadCoordinator.isInitialized) downloadCoordinator.shutdown() // cancel in-flight downloads (no orphan/leak)
zoneManager?.cleanup()
if (::pipOverlay.isInitialized) pipOverlay.clear(null) // #109: tear down overlay WebView

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
import android.content.Context
import android.graphics.SurfaceTexture
import android.net.Uri
import android.util.Log
import android.view.Surface
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
@ -30,6 +32,19 @@ class MediaPlayerManager(
// Wall mode: followers must stay muted even as the leader's sync switches them
// to a new (possibly unmuted) item, so the mute has to survive each playVideo.
private var wallMute = false
// #group-sync loop state, tracked so it can be applied to a freshly-swapped double-buffer player.
private var videoLooping = false
// #group-sync double buffer: a second ExoPlayer that pre-opens/pre-buffers the NEXT clip so the
// boundary switch is a warm swap (~100-300ms) instead of a cold prepare (~1-2s black hold). Only
// engaged when preloadVideo() is called ahead of a boundary (group sync); the wall/solo paths are
// untouched (they never preload, so playVideo takes the normal cold path).
private var preloadPlayer: ExoPlayer? = null
private var preloadedFile: File? = null
// Throwaway offscreen surface for the preload player: it forces the preload clip to decode frame 0
// and populate its video size BEFORE the swap, so PlayerView doesn't reset the aspect to "fill"
// (a one-frame landscape stretch) while it waits for the new player's first video-size report.
private var warmTexture: SurfaceTexture? = null
private var warmSurface: Surface? = null
enum class MediaType { NONE, VIDEO, IMAGE, YOUTUBE, WIDGET }
@ -37,25 +52,31 @@ class MediaPlayerManager(
setupExoPlayer()
}
// Build a player with the shared end/error listener so BOTH the active and the preload player
// advance/self-heal identically once either is the visible one.
private fun buildPlayer(): ExoPlayer = ExoPlayer.Builder(context).build().also { player ->
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
// Only the ACTIVE (view-attached) player drives advance; ignore the preload player's
// own state changes (it's parked with playWhenReady=false and never ENDs while parked).
if (playbackState == Player.STATE_ENDED && player === exoPlayer) onVideoComplete()
}
// Root-2: a corrupt/undecodable video used to freeze the playlist forever — only
// STATE_ENDED advanced, and an error goes to STATE_IDLE, so onVideoComplete never
// fired. Treat a playback error like a completion so the loop moves on instead of
// wedging on the broken item (mirrors the web/.wgt onerror -> advance).
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e("MediaPlayerManager", "Playback error (${error.errorCodeName}) — advancing: ${error.message}")
if (player === exoPlayer) onVideoComplete()
}
})
}
private fun setupExoPlayer() {
exoPlayer = ExoPlayer.Builder(context).build().also { player ->
playerView.player = player
player.addListener(object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
onVideoComplete()
}
}
// Root-2: a corrupt/undecodable video used to freeze the playlist forever — only
// STATE_ENDED advanced, and an error goes to STATE_IDLE, so onVideoComplete never
// fired. Treat a playback error like a completion so the loop moves on instead of
// wedging on the broken item (mirrors the web/.wgt onerror -> advance).
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
Log.e("MediaPlayerManager", "Playback error (${error.errorCodeName}) — advancing: ${error.message}")
onVideoComplete()
}
})
}
// Hold the last frame instead of flashing black during a reset/prepare — turns any residual
// switch gap into a brief freeze-frame rather than a black hold.
try { playerView.setKeepContentOnPlayerReset(true) } catch (e: Throwable) {}
exoPlayer = buildPlayer().also { playerView.player = it }
}
// #129: remembered so the live device:mute-changed toggle knows YouTube's current
@ -154,8 +175,28 @@ class MediaPlayerManager(
}.start()
}
/**
* #group-sync double buffer: pre-open/pre-buffer the NEXT clip on the parked second player so the
* upcoming boundary switch (playVideo of the same file) is a warm swap instead of a cold prepare.
* Cheap to call every tick it no-ops if this file is already the preloaded one. Main thread only.
*/
fun preloadVideo(file: File) {
if (preloadedFile?.absolutePath == file.absolutePath) return
val p = preloadPlayer ?: buildPlayer().also { preloadPlayer = it }
if (warmSurface == null) { warmTexture = SurfaceTexture(0).apply { setDefaultBufferSize(16, 16) }; warmSurface = Surface(warmTexture) }
p.apply {
setVideoSurface(warmSurface) // decode frame 0 offscreen -> video size known pre-swap
volume = 0f // silent while parked; real volume set on swap
repeatMode = if (videoLooping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
setMediaItem(MediaItem.fromUri(Uri.fromFile(file)))
playWhenReady = false // buffer/parse/decode-frame-0 now, don't start
prepare()
}
preloadedFile = file
Log.i("MediaPlayerManager", "Preloaded next video: ${file.name}")
}
fun playVideo(file: File, muted: Boolean = false) {
Log.i("MediaPlayerManager", "Playing video: ${file.absolutePath} (muted=$muted)")
currentType = MediaType.VIDEO
// Show player, hide image
@ -163,6 +204,27 @@ class MediaPlayerManager(
imageView.visibility = android.view.View.GONE
youtubeWebView?.visibility = android.view.View.GONE
// Warm swap: if this exact file was preloaded, promote the parked player instead of a cold
// prepare — the container is already open/buffered so the first frame renders near-instantly.
val pp = preloadPlayer
if (pp != null && preloadedFile?.absolutePath == file.absolutePath) {
Log.i("MediaPlayerManager", "Playing video (warm swap): ${file.name}")
val old = exoPlayer
exoPlayer = pp
preloadPlayer = old
preloadedFile = null
pp.apply {
volume = if (muted || wallMute) 0f else 1f
repeatMode = if (videoLooping) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
playWhenReady = true
}
playerView.player = pp
// Park the previous active player as the new preload slot (idle until the next preloadVideo).
old?.apply { playWhenReady = false; clearMediaItems() }
return
}
Log.i("MediaPlayerManager", "Playing video: ${file.absolutePath} (muted=$muted)")
exoPlayer?.apply {
volume = if (muted || wallMute) 0f else 1f
setMediaItem(MediaItem.fromUri(Uri.fromFile(file)))
@ -206,6 +268,11 @@ class MediaPlayerManager(
fun release() {
exoPlayer?.release()
exoPlayer = null
preloadPlayer?.release()
preloadPlayer = null
preloadedFile = null
warmSurface?.release(); warmSurface = null
warmTexture?.release(); warmTexture = null
}
fun isPlayingVideo(): Boolean = currentType == MediaType.VIDEO && (exoPlayer?.isPlaying == true)
@ -261,7 +328,9 @@ class MediaPlayerManager(
* if the leader's next index sync is slightly late; the leader plays through normally.
*/
fun setVideoLooping(loop: Boolean) {
videoLooping = loop
exoPlayer?.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
preloadPlayer?.repeatMode = if (loop) Player.REPEAT_MODE_ONE else Player.REPEAT_MODE_OFF
}
/**

View file

@ -87,6 +87,9 @@ class PlaylistController(
val currentItem: PlaylistItem?
get() = if (currentIndex in items.indices) items[currentIndex] else null
// #group-sync double buffer: resolve an item by index (for preloading the next clip).
fun itemAt(index: Int): PlaylistItem? = if (index in items.indices) items[index] else null
val currentContentId: String?
get() = currentItem?.contentId
@ -125,6 +128,10 @@ class PlaylistController(
// #129: include muted too, so a mute-only change (same content) re-renders with the
// new flag instead of being de-duped (the real-time event handles the live toggle;
// this makes a published mute persist across reloads).
// Signature is STRUCTURAL only — content/widget identity, order, mute, schedules. durationSec is
// deliberately EXCLUDED so a duration-only edit does NOT force a restart; instead it's applied
// IN PLACE below (the #group-sync schedule tick + the solo advance timer read durationSec live),
// so timing edits take effect without interrupting playback or resetting the index.
fun sig(it: PlaylistItem) = it.contentId + "|" + (it.widgetId ?: "") + "|" + (if (it.muted) "m" else "") + "|" +
it.schedules.joinToString(";") { b ->
b.days.sorted().joinToString(",") + "@" + b.start + "-" + b.end + ":" + (b.startDate ?: "") + "~" + (b.endDate ?: "")
@ -134,7 +141,16 @@ class PlaylistController(
val playlistChanged = oldContentIds != newContentIds
if (!playlistChanged && items.isNotEmpty()) {
Log.i("PlaylistController", "Playlist unchanged (${items.size} items), not interrupting playback")
// In-place duration refresh: patch durationSec on the existing items so a duration edit
// takes effect immediately without a restart. For a sync group this re-anchors the shared
// schedule (all members recompute the new period together); for solo it's picked up on the
// next advance. No index reset, no video reload.
var durChanged = false
for (i in items.indices) {
val ni = newItems.getOrNull(i) ?: continue
if (items[i].durationSec != ni.durationSec) { items[i] = items[i].copy(durationSec = ni.durationSec); durChanged = true }
}
Log.i("PlaylistController", if (durChanged) "Durations updated in place (${items.size} items), not interrupting" else "Playlist unchanged (${items.size} items), not interrupting playback")
return
}
@ -288,6 +304,34 @@ class PlaylistController(
item.schedules.isEmpty() ||
ScheduleEval.isItemActiveNow(item.schedules, System.currentTimeMillis(), effectiveTimezone)
// #group-sync schedule engine. Lay the deterministic playlist (each active item occupies a
// CANONICAL slot, dayparted items skipped) on the server-disciplined clock and derive the target
// (index, position). The slot formula MUST be byte-identical to the web and Tizen players
// (max(1, duration_sec||10)*1000) or a mixed-platform group would diverge — so it deliberately
// does NOT use this player's solo-playback duration clamp.
// nextIndex + secToBoundary let the double buffer preload the upcoming clip a few seconds early.
// For a single active slot, nextIndex == index (the caller skips preloading a self-loop).
data class GroupTarget(val index: Int, val posSec: Float, val nextIndex: Int, val secToBoundary: Float)
private fun slotMs(it: PlaylistItem): Long = (if (it.durationSec > 0) it.durationSec else 10).toLong() * 1000L
fun groupScheduleTarget(syncedNowMs: Long): GroupTarget? {
if (items.isEmpty()) return null
var acc = 0L
val slots = ArrayList<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.
private fun playableNow(i: Int): Boolean =
i in items.indices && scheduleAllows(items[i]) && contentReady(items[i])

View file

@ -28,18 +28,24 @@ class WallController(
private val media: MediaPlayerManager,
private val playlist: PlaylistController,
private val deviceId: () -> String,
private val emitSync: (wallId: String, idx: Int, contentId: String?, posSec: Float) -> Unit,
private val emitSyncRequest: (wallId: String) -> Unit,
private val emitSync: (isGroup: Boolean, syncId: String, idx: Int, contentId: String?, posSec: Float) -> Unit,
private val emitSyncRequest: (isGroup: Boolean, syncId: String) -> Unit,
private val applyTransform: (WallConfig?) -> Unit
) {
// WALL: spatial tiling (transform + object-fit:fill + followers muted).
// GROUP: #group-sync — same leader/follower timing, but full-screen content, no transform, and
// per-item mute honored (no forced follower mute). syncId is the wall_id or the group_id.
enum class Mode { WALL, GROUP }
data class Rect(val x: Float, val y: Float, val w: Float, val h: Float)
data class WallConfig(
val wallId: String,
val wallId: String, // sync id: wall_id (WALL) or group_id (GROUP)
val screen: Rect,
val player: Rect,
val isLeader: Boolean,
val rotation: Int
val rotation: Int,
val mode: Mode = Mode.WALL
)
private val WallConfig.isGroup: Boolean get() = mode == Mode.GROUP
private val handler = Handler(Looper.getMainLooper())
private var config: WallConfig? = null
@ -52,11 +58,13 @@ class WallController(
config = cfg
Log.i("WallController", "apply wall=${cfg.wallId} isLeader=${cfg.isLeader}")
applyTransform(cfg) // size/translate the root view to our slice
media.setWallMode(true) // object-fit:fill parity
playlist.setWallFollower(!cfg.isLeader) // followers don't self-advance
media.setWallMute(!cfg.isLeader) // followers muted (avoid flange)
media.setVideoLooping(!cfg.isLeader) // followers loop so they never freeze
// WALL-only spatial bits — a group syncs timing only, full-screen, per-item mute honored.
applyTransform(if (cfg.isGroup) null else cfg) // size/translate root view (wall) or clear (group)
media.setWallMode(!cfg.isGroup) // object-fit:fill for wall; normal fit for group
media.setWallMute(!cfg.isGroup && !cfg.isLeader) // followers muted only on a wall (avoid flange)
// Common to both: followers don't self-advance + loop video so they never freeze.
playlist.setWallFollower(!cfg.isLeader)
media.setVideoLooping(!cfg.isLeader)
stopTimer()
if (cfg.isLeader) {
@ -66,10 +74,26 @@ class WallController(
handler.postDelayed(tick!!, 250)
handler.postDelayed({ emitNow() }, 100) // immediate first align
} else {
emitSyncRequest(cfg.wallId) // align now, don't wait a tick
emitSyncRequest(cfg.isGroup, cfg.wallId) // align now, don't wait a tick
}
}
/**
* Hard teardown for Activity destruction. Kills the 4Hz leader tick and drops the config so any
* still-queued tick no-ops (emitNow bails on a null config). Unlike [exit] this touches NO views
* or media the Activity is going away (its MediaPlayer is about to be released), and restoring
* wall-mode/transform on a dying Activity is both pointless and unsafe.
*
* MUST be called from MainActivity.onDestroy(): the Handler runs on the MAIN looper, which
* outlives the Activity, so a leader tick left running becomes a zombie that keeps broadcasting
* `group:sync`/`wall:sync` forever against a released player producing split-brain (two live
* "leaders") and garbage positions. This is the teardown that prevents that leak.
*/
fun shutdown() {
stopTimer()
config = null
}
/** Leave wall mode and restore full-screen playback. */
fun exit() {
stopTimer()
@ -93,14 +117,17 @@ class WallController(
} else {
((System.currentTimeMillis() - playlist.itemStartedAtMs()) / 1000f).coerceAtLeast(0f)
}
emitSync(c.wallId, playlist.getIndex(), item.contentId.ifEmpty { null }, pos)
emitSync(c.isGroup, c.wallId, playlist.getIndex(), item.contentId.ifEmpty { null }, pos)
}
/** Handle an incoming `wall:sync` (followers only). */
// Sync payloads carry the id under "group_id" (group) or "wall_id" (wall).
private fun WallConfig.idField(): String = if (isGroup) "group_id" else "wall_id"
/** Handle an incoming sync broadcast (followers only). */
fun onSync(data: JSONObject) {
val c = config ?: return
if (c.isLeader) return
if (data.optString("wall_id") != c.wallId) return
if (data.optString(c.idField()) != c.wallId) return
val leaderIdx = data.optInt("current_index", -1)
if (leaderIdx >= 0 && leaderIdx != playlist.getIndex()) playlist.gotoIndex(leaderIdx)
@ -128,11 +155,11 @@ class WallController(
}
}
/** Handle a follower's `wall:sync-request` (leader only): broadcast position now. */
/** Handle a follower's sync-request (leader only): broadcast position now. */
fun onSyncRequest(data: JSONObject) {
val c = config ?: return
if (!c.isLeader) return
if (data.has("wall_id") && data.optString("wall_id") != c.wallId) return
if (data.has(c.idField()) && data.optString(c.idField()) != c.wallId) return
emitNow()
}

View file

@ -74,6 +74,9 @@ class WebSocketService : Service() {
var onCommand: ((String, JSONObject?) -> Unit)? = null
var onWallSync: ((JSONObject) -> Unit)? = null
var onWallSyncRequest: ((JSONObject) -> Unit)? = null
var onGroupSync: ((JSONObject) -> Unit)? = null // legacy leader-relay (unused by clock/schedule)
var onGroupSyncRequest: ((JSONObject) -> Unit)? = null // legacy leader-relay (unused by clock/schedule)
var onGroupResync: (() -> Unit)? = null // #group-sync: server-nudged immediate re-align
var onPipShow: ((JSONObject) -> Unit)? = null
var onPipClear: ((JSONObject) -> Unit)? = null
var onMuteChanged: ((JSONObject) -> Unit)? = null
@ -240,10 +243,11 @@ class WebSocketService : Service() {
// markAlive already fired via safeOn (any inbound); a healthy ack also resets the
// reconnect backoff. Known ack-gap (reconnecting-not-yet-re-registered) is benign:
// arm-after-ack + any-inbound-refresh keep the watchdog from firing in that window.
safeOn("device:heartbeat-ack") {
safeOn("device:heartbeat-ack") { args ->
if (!livenessConfirmed) Log.i("WebSocketService", "v4 watchdog: ARMED (first heartbeat-ack)")
livenessConfirmed = true
watchdogAttempt = 0
(args.firstOrNull() as? JSONObject)?.let { ingestClockSample(it.optLong("server_ms", 0L), it.optLong("client_ms", 0L)) }
}
safeOn("device:unpaired") { handleServerRejection("device:unpaired (removed on server)") }
@ -339,6 +343,21 @@ class WebSocketService : Service() {
handler.post { try { onWallSyncRequest?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onWallSyncRequest cb: ${e.message}") } }
}
safeOn("group:sync") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
handler.post { try { onGroupSync?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onGroupSync cb: ${e.message}") } }
}
safeOn("group:sync-request") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
handler.post { try { onGroupSyncRequest?.invoke(data) } catch (e: Throwable) { Log.e("WebSocketService", "onGroupSyncRequest cb: ${e.message}") } }
}
// #group-sync: server-nudged immediate re-align (dashboard "Resync now").
safeOn("group:resync") {
handler.post { try { onGroupResync?.invoke() } catch (e: Throwable) { Log.e("WebSocketService", "onGroupResync cb: ${e.message}") } }
}
// #109: PiP overlay. Post to the main thread — the handlers build Views.
safeOn("device:pip-show") { args ->
val data = args.firstOrNull() as? JSONObject ?: return@safeOn
@ -644,11 +663,36 @@ class WebSocketService : Service() {
heartbeatRunnable = null
}
// #group-sync clock discipline. The server is the time authority (see the heartbeat-ack). The
// offset is CACHED in prefs so schedule-based group sync stays aligned through an internet outage
// (RTC drift is tiny). synced_now = System.currentTimeMillis() + clockOffsetMs.
@Volatile private var clockOffsetMs: Long = Long.MIN_VALUE // sentinel: not yet loaded from prefs
private var clockRttMs: Long = -1
private fun ensureClockLoaded() {
if (clockOffsetMs == Long.MIN_VALUE) {
clockOffsetMs = try { getSharedPreferences("remote_display", MODE_PRIVATE).getLong("clock_offset_ms", 0L) } catch (e: Throwable) { 0L }
}
}
fun syncedNowMs(): Long { ensureClockLoaded(); return System.currentTimeMillis() + clockOffsetMs }
private fun ingestClockSample(serverMs: Long, clientMs: Long) {
if (serverMs <= 0L || clientMs <= 0L) return
ensureClockLoaded()
val t4 = System.currentTimeMillis()
val rtt = (t4 - clientMs).coerceAtLeast(0)
if (rtt > 5000) return // absurd RTT (GC/doze stall) — don't poison offset
val sample = serverMs - (clientMs + t4) / 2 // NTP-style: offset = server - (t1+t4)/2
clockOffsetMs = if (clockRttMs < 0 || kotlin.math.abs(sample - clockOffsetMs) > 1000) sample
else Math.round(clockOffsetMs * 0.8 + sample * 0.2) // EMA-smooth jitter
clockRttMs = rtt
try { getSharedPreferences("remote_display", MODE_PRIVATE).edit().putLong("clock_offset_ms", clockOffsetMs).apply() } catch (e: Throwable) {}
}
private fun sendHeartbeat() {
if (socket?.connected() != true) return
try {
val data = JSONObject().apply {
put("device_id", config.deviceId)
put("client_ms", System.currentTimeMillis()) // #group-sync: t1 for NTP-style clock discipline
try { put("telemetry", deviceInfo.getTelemetry()) } catch (e: Throwable) { Log.w("WebSocketService", "telemetry: ${e.message}") }
}
socket?.emit("device:heartbeat", data)
@ -857,6 +901,28 @@ class WebSocketService : Service() {
} catch (e: Throwable) { Log.w("WebSocketService", "emitWallSyncRequest: ${e.message}") }
}
// #group-sync: same payload as wall sync, keyed by group_id (server relays to group members).
fun emitGroupSync(groupId: String, currentIndex: Int, contentId: String?, positionSec: Float) {
if (socket?.connected() != true) return
try {
socket?.emit("group:sync", JSONObject().apply {
put("group_id", groupId)
put("device_id", config.deviceId)
put("current_index", currentIndex)
put("content_id", contentId ?: JSONObject.NULL)
put("position_sec", positionSec.toDouble())
put("sent_at", System.currentTimeMillis())
})
} catch (e: Throwable) { Log.w("WebSocketService", "emitGroupSync: ${e.message}") }
}
fun emitGroupSyncRequest(groupId: String) {
if (socket?.connected() != true) return
try {
socket?.emit("group:sync-request", JSONObject().apply { put("group_id", groupId) })
} catch (e: Throwable) { Log.w("WebSocketService", "emitGroupSyncRequest: ${e.message}") }
}
fun disconnect() {
stopHeartbeat()
cancelReopen()

View file

@ -136,6 +136,8 @@ export const api = {
// Device Groups
getGroups: () => request('/groups'),
createGroup: (name, color) => request('/groups', { method: 'POST', body: JSON.stringify({ name, color }) }),
updateGroup: (id, data) => request(`/groups/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
resyncGroup: (id) => request(`/groups/${id}/resync`, { method: 'POST' }),
deleteGroup: (id) => request(`/groups/${id}`, { method: 'DELETE' }),
getGroupDevices: (id) => request(`/groups/${id}/devices`),
addDeviceToGroup: (groupId, device_id) => request(`/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_id }) }),

View file

@ -105,6 +105,13 @@ export default {
'dashboard.set_playlist_placeholder': 'Set Playlist...',
'dashboard.send_command_placeholder': 'Send Command...',
'dashboard.manage': 'Manage',
'dashboard.group_sync.label': 'Sync',
'dashboard.group_sync.hint': "Play this group's shared playlist in lockstep across its displays — items start and end together, and it keeps them aligned even with no internet (each display follows a shared clock + schedule). Requires a playlist assigned to the group; a display on a different playlist is ignored.",
'dashboard.group_sync.resync': 'Resync now',
'dashboard.group_sync.resync_hint': 'Nudge every display in this group to re-snap to the shared schedule immediately.',
'dashboard.group_sync.toast_on': 'Synchronized playback enabled',
'dashboard.group_sync.toast_off': 'Synchronized playback disabled',
'dashboard.group_sync.toast_resync': 'Resync sent to group',
'dashboard.manage_tooltip': 'Add/remove devices',
'dashboard.delete_group_tooltip': 'Delete group',
'dashboard.no_devices_in_group': 'No devices in this group. Click Manage to add some.',

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('')}
</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-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="${t('dashboard.delete_group_tooltip')}">&#x2715;</button>
</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
document.querySelectorAll('.group-cmd-select').forEach(select => {
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)",
// Group-level playlist: when set, devices added to the group inherit it.
"ALTER TABLE device_groups ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
// Group synchronized playback: when sync_enabled, members on the group's playlist play it
// in lockstep (leader broadcasts index+position; followers align). Reuses the video-wall
// sync primitive, minus the spatial transform. leader_device_id is an optional pin; if unset
// or offline the server auto-elects the first online member on the matching playlist.
"ALTER TABLE device_groups ADD COLUMN sync_enabled INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE device_groups ADD COLUMN leader_device_id TEXT REFERENCES devices(id) ON DELETE SET NULL",
// Wall-level playlist: video walls now play a playlist (not just one content).
"ALTER TABLE video_walls ADD COLUMN playlist_id TEXT REFERENCES playlists(id) ON DELETE SET NULL",
// Free-form canvas layout: walls store a player rect; member devices store

View file

@ -414,6 +414,45 @@
// video position to whatever the leader is playing.
let wallConfig = null;
let wallSyncTimer = null;
// #group-sync: synchronized group playback. NOT leader/follower — every member derives its
// tick locally from a server-disciplined clock + the shared playlist schedule. Works offline.
let groupSync = null;
let groupSyncTimer = null;
let groupDbgLast = 0;
// A fresh item snaps ONCE to the exact schedule position (load-and-hold) instead of nudging away
// the ~0.3s load offset over ~10s. Steady-state drift still rides the gentle nudge afterward.
let groupAlignPending = true;
let groupLastAlignedIndex = -1;
// Double buffer: a hidden <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 currentVideoEl = null;
let currentItemStartedAt = 0;
@ -605,6 +644,9 @@
layout = loadLayoutCache();
document.getElementById('setupScreen').style.display = 'none';
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
@ -872,7 +914,7 @@
socket.onAny(markAlive);
socket.io.on('ping', markAlive);
// 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', () => {
console.log('Connected');
@ -1008,6 +1050,18 @@
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:remote-start', () => { console.log('Remote start received'); remoteStreaming = true; startStreaming(); });
socket.on('device:remote-stop', () => { console.log('Remote stop received'); remoteStreaming = false; stopStreaming(); });
@ -1223,6 +1277,7 @@
if (!socket?.connected || !config.deviceId) return;
socket.emit('device:heartbeat', {
device_id: config.deviceId,
client_ms: Date.now(), // #group-sync: t1 for NTP-style clock discipline (echoed in the ack)
telemetry: {
battery_level: null,
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
// viewport fills edge-to-edge (no pillarbox at the seam between adjacent
// screens). With object-fit:fill on the video, the source stretches to
@ -1403,6 +1569,9 @@
const newItems = data.assignments || [];
// Build fingerprint from id + url + filename to detect any content change.
// #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 newFp = fingerprint(newItems);
const oldFp = fingerprint(playlist);
@ -1442,6 +1611,13 @@
if (!wallChanged && wallConfig && !wallConfig.is_leader && socket?.connected) {
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;
saveLayoutCache(layout);
@ -1449,6 +1625,12 @@
if (newFp === oldFp && playlist.length > 0 && !wallChanged) {
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
// 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
@ -1601,6 +1783,7 @@
// 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.
if (wallConfig?.is_leader) emitWallSync();
// (group members need no emit — the schedule tick drives them locally)
}
function nextItem() {
@ -1835,9 +2018,15 @@
mount = stage;
}
// Followers don't run their own advance timers — the leader's wall:sync
// dictates index transitions. Single-screen and leader behave normally.
const isFollower = !!wallConfig && !wallConfig.is_leader;
// Two independent concerns, previously conflated as "isFollower":
// - forceMuted: wall followers stay silent (N flanged copies across an adjacent wall).
// 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 isVideo = !isYoutube && item.mime_type?.startsWith('video/');
@ -1853,16 +2042,19 @@
if (isYoutube) {
createYoutubeEmbed(src, item, mount);
} else if (isVideo) {
const video = document.createElement('video');
video.src = src;
// Double buffer: reuse the pre-buffered element for this index if we warmed it (no black
// 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;
// Followers stay muted unconditionally (leader-only audio); leaders
// 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.
video.muted = isFollower ? true : (!userHasInteracted || !!item.muted);
// Explicit max volume on the leader so audio is at full level when
video.muted = forceMuted ? true : (!userHasInteracted || !!item.muted);
// 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).
if (!isFollower) video.volume = 1.0;
if (!forceMuted) video.volume = 1.0;
video.playsInline = true;
video.crossOrigin = 'anonymous';
// Wall mode uses object-fit:fill so the source stretches to the
@ -1874,7 +2066,9 @@
video.style.cssText = wallConfig
? 'width:100%;height:100%;object-fit:fill;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.onerror = (e) => {
console.error('Video error:', src, e);
@ -1883,10 +2077,11 @@
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);
};
// 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
// bug — without it, a single stray unmute call causes echo.
if (isFollower) {
// bug — without it, a single stray unmute call causes echo. Group
// members are NOT force-muted (per-item mute governs them).
if (forceMuted) {
video.addEventListener('volumechange', () => {
if (!video.muted) { video.muted = true; }
});

View file

@ -68,13 +68,42 @@ router.post('/', (req, res) => {
// Update group
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 (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);
// #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));
});
// #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
router.delete('/:id', requireGroupWrite, (req, res) => {
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');
});
// ============ 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');
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.socketSends[0].reason, 'crashed'); assert.equal(h.socketSends[0].ev, 'device:exit');
});
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);
});
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');
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.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
// 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) {
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.
const tzOverride = (device?.timezone && device.timezone !== 'UTC') ? device.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
// 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
@ -199,7 +248,7 @@ function buildPlaylistPayload(deviceId) {
// 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
// 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 : [];
const zoneCount = layout?.zones?.length || 0;
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,
orientation: orientation || 'landscape',
wall_config: wall_config || null,
group_sync: group_sync || null,
timezone: timezone || null,
};
}
@ -607,6 +657,24 @@ module.exports = function setupDeviceSocket(io) {
} 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
const access = checkDeviceAccess(device_id);
if (!access.allowed) {
@ -710,7 +778,12 @@ module.exports = function setupDeviceSocket(io) {
// finishes re-registering). Anonymous / never-authenticated sockets are NOT acked (degrade-safe
// covers them). Old clients simply ignore the ack — harmless.
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 (!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', () => {
// #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

View file

@ -33,7 +33,8 @@
token: 'st_device_token',
fp: 'st_fingerprint',
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 ----
@ -199,6 +200,26 @@
var authenticated = false; // #118: true only between device:registered and disconnect/auth-error
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() {
return {
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
// watchdog can't false-fire — markAlive (onAny) still refreshed lastServerMsgAt for the silence
// 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 () {
del(LS.code); clearToast(); show(elStage);
@ -369,6 +390,12 @@
// Leader broadcasts position; followers align index + drift-correct their video.
socket.on('wall:sync', function (d) { wallController.onSync(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
// 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
// requireDeviceAuth() rejects the beat with device:auth-error.
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;
// 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
@ -541,6 +568,8 @@
function () { return deviceId; },
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
// playlist. Reports show/clear over device:log (tag 'pip').
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
// player's sig so it repaints (see the single-zone branch for the full rationale).
if (stageOwner !== 'player') { zoneRenderer.clear(); player.invalidate(); }
groupSync.exit(); // wall and group are mutually exclusive
wallController.apply(payload.wall_config);
player.setTimezone(payload.timezone || null);
player.load(payload.assignments || []);
@ -606,7 +636,12 @@
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');
var layout = payload.layout;
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.DEFAULT_DURATION = 10;
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) {
// 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.
@ -43,10 +72,18 @@ PlaylistPlayer.prototype.load = function (assignments) {
items.sort(function (a, b) { return (a.sort_order || 0) - (b.sort_order || 0); });
var sig = JSON.stringify(items.map(function (a) {
// #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 || []];
// STRUCTURAL only. #74/#75: include schedules so a schedule edit (same content) re-renders.
// 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.items = items;
@ -238,14 +275,18 @@ PlaylistPlayer.prototype.renderImage = function (item, single) {
PlaylistPlayer.prototype.renderVideo = function (item, single) {
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.fit(v, item);
v.autoplay = true; v.muted = true; v.setAttribute('playsinline', '');
v.loop = single; // single item loops; multi advances on end
v.onended = function () { if (!single) self.advance(); };
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);
var p = v.play(); if (p && p.catch) p.catch(function () {});
// Safety net: if 'ended' never fires (rare), advance after the known
@ -557,15 +598,28 @@ WallController.prototype.styleStage = function (config) {
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) {
var isGroup = config.mode === 'group';
var id = this.syncId(config);
var roleChanged = !this.config ||
this.config.is_leader !== config.is_leader ||
this.config.wall_id !== config.wall_id;
this.syncId(this.config) !== id;
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);
// 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).
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
// until the next periodic tick (up to ~250ms of visible drift on a fresh join).
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.player.setWallFollower(false);
if (wasActive) {
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 = '';
this.player.invalidate(); // re-render cleanly back into normal (non-wall) mode
this.clearStageStyle();
this.player.invalidate(); // re-render cleanly back into normal (non-sync) mode
}
};
@ -606,19 +659,22 @@ WallController.prototype.emitSync = function () {
var v = this.player.getCurrentVideo();
var pos = v ? (v.currentTime || 0)
: Math.max(0, (Date.now() - this.player.getItemStartedAt()) / 1000);
s.emit('wall:sync', {
wall_id: this.config.wall_id,
var msg = {
device_id: this.getDeviceId(),
current_index: this.player.getIndex(),
content_id: item.content_id || null,
position_sec: pos,
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) {
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.
if (typeof data.current_index === 'number' && data.current_index !== this.player.getIndex()) {
this.player.gotoIndex(data.current_index);
@ -642,7 +698,111 @@ WallController.prototype.onSync = function (data) {
};
WallController.prototype.onSyncRequest = function (data) {
if (!this.config || !this.config.is_leader) return;
if (data && data.wall_id && data.wall_id !== this.config.wall_id) return;
var c = this.config;
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();
};
/* 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');
};