mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
Group sync: clock/schedule synchronized playback (offline-native) + double-buffer (#167)
* 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:
parent
34f1cb9e7c
commit
938a43a466
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 }) }),
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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')}">✕</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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
69
server/test/group-sync-clock.test.js
Normal file
69
server/test/group-sync-clock.test.js
Normal 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();
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue