fix: sync playback resume position across devices in real-time (#91) - #130
Merged
Conversation
Reported behavior: a user finishes or advances an episode on device A, but device B still shows the old resume position for minutes (until a manual Home ON_RESUME or the 5-minute periodic fallback sync fires). Root cause: RealtimeSyncManager only subscribed to the `account_sync_state` table via a single postgres_changes channel. But playback progress updates are written directly to the `watch_history` table by WatchHistoryRepository.saveProgress every ~10 seconds during playback, NOT through the account_sync_state JSON snapshot. The snapshot was only pushed at pause/stop/end, which meant: - A user who finished an episode pushed via account_sync_state on end \u2014 OK. - A user who paused mid-episode pushed via account_sync_state on pause \u2014 OK. - A user actively watching on device A with no pause: watch_history updated on Supabase every 10 s, but device B had no realtime signal and only saw the new position after opening Home or after 5 minutes. Fix: 1. Subscribe to a SECOND realtime channel `realtime:watch_history` on the same WebSocket, listening for INSERT + UPDATE on the shared `watch_history` table filtered by `user_id=eq.$userId`. Both events trigger a lightweight "refresh Continue Watching" signal. This does NOT trigger a full cloud-state pull \u2014 that would be wasteful for a single-row position update. 2. Route incoming `postgres_changes` events by their topic so each channel dispatches to the right handler: - `realtime:account_sync` \u2192 debouncedPull() (full snapshot) - `realtime:watch_history` \u2192 debouncedWatchHistoryEmit() (CW row only) 3. Expose a `watchHistoryEvents: SharedFlow<Unit>` on the manager. HomeViewModel collects this flow in its init block and calls refreshContinueWatchingOnly(force = true) on each emission, which re-queries `watch_history` via the existing path and updates the Continue Watching row inline without reloading the whole home state. 4. Coalesce watch_history bursts with a 5-second debounce. Watch history fires every ~10 s during active playback, so without the coalescing a user binge-watching on device A would trigger a Home refresh on every other device every 10 seconds. The debounce groups rapid-fire events into one refresh. 5. Avoid a self-echo loop: WatchHistoryRepository.saveProgress now calls realtimeSyncManager.markLocalWatchHistoryWrite() on every successful save. debouncedWatchHistoryEmit() checks that timestamp with a 3 s window and skips the emit when the event almost certainly came from our own write. Without this, device A would pointlessly refresh its own Continue Watching row every time it wrote progress. 6. Reduce the periodic fallback sync interval from 5 minutes to 90 seconds. Users on flaky connections who miss a realtime event will still see fresh data within a reasonable time, without hammering Supabase. The interval only runs full pullFromCloud() if nothing else fired in between, so this is a net-cost of at most one small query per 90 s per active user. Dependency injection: WatchHistoryRepository now takes Provider<RealtimeSyncManager> (lazy, to break any construction-order edge cases \u2014 AuthRepository is injected the same way). Verified there is no dependency cycle: RealtimeSyncManager \u2192 CloudSyncRepository does not transitively import WatchHistoryRepository. Closes #91
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #91.
Cross-device playback resume position now updates within ~5 seconds of a progress update on another device, instead of waiting for the receiving device to manually reopen Home or for the 5-minute periodic fallback sync.
Reported behavior
Root cause
RealtimeSyncManageronly subscribed to one Supabase realtime channel:account_sync_state. But playback progress is written directly to a different table —watch_history— byWatchHistoryRepository.saveProgress()every ~10 seconds during playback. Theaccount_sync_statesnapshot is only pushed at pause/stop/end, which meant:watch_historywas being updated on Supabase every 10 seconds, but device B had no realtime signal and only saw the new position after manually reopening Home or after the 5-minute periodic fallback fired.The bug has been invisible for users who always pause or finish episodes cleanly, but has been breaking cross-device experience for anyone switching devices mid-episode.
Fix
1. Second realtime channel for
watch_historySubscribe to a second channel on the same WebSocket listening for INSERT + UPDATE on
watch_historyfiltered byuser_id=eq.$userId:2. Topic-based dispatch in
handleMessagepostgres_changesevents now route by topic:realtime:account_sync→debouncedPull()(full cloud snapshot restore — unchanged)realtime:watch_history→debouncedWatchHistoryEmit()(lightweight CW-only refresh — new)Watch-history events do not trigger
cloudSyncRepository.pullFromCloud(). That would be wasteful for a single-row position update — it would re-download the entire addons/profiles/catalogs/IPTV snapshot.3.
watchHistoryEvents: SharedFlow<Unit>New event stream on
RealtimeSyncManager.HomeViewModelcollects it ininitand calls the existingrefreshContinueWatchingOnly(force = true), which re-querieswatch_historyviaWatchHistoryRepository.getContinueWatching()and updates the Continue Watching row inline — no home reload.4. 5-second debounce to coalesce bursts
Watch history fires every ~10 s during active playback. Without coalescing, a user binge-watching on device A would trigger a Home refresh on every other device every 10 seconds. The new
WATCH_HISTORY_DEBOUNCE_MS = 5_000Lgroups rapid-fire events into a single refresh.5. Self-echo protection
WatchHistoryRepository.saveProgress()now callsrealtimeSyncManager.markLocalWatchHistoryWrite()on every successful save.debouncedWatchHistoryEmit()checks that timestamp with a 3-second window and skips the emit when the event almost certainly came from our own write. Without this, device A would pointlessly refresh its own Continue Watching row every 10 seconds while watching.6. Periodic fallback: 5 minutes → 90 seconds
PERIODIC_SYNC_INTERVAL_MSwas 5 minutes. Lowered to 90 seconds so users on flaky connections (or devices that missed a realtime notification) still see fresh data within a reasonable window. The interval runspullFromCloud()only if nothing else has fired in between — net cost is at most one small Supabase query per 90 s per active user.Dependency injection
WatchHistoryRepositorynow takesProvider<RealtimeSyncManager>(lazy). This matches the existingProvider<AuthRepository>pattern in the same class and avoids any construction-order edge cases. Verified no dependency cycle:Test plan
watch_historyupdate, device B's Continue Watching row should refresh to show the new position without any manual action.Risk
Medium. Changes touch the realtime sync core. Mitigations:
realtime:watch_historychannel is additive — if it fails, the existingrealtime:account_syncchannel still works and the 90-second periodic fallback still catches everything.extraBufferCapacity = 4onMutableSharedFlowso event delivery is non-suspending and can't back-pressure the network handler.watch_historytable that already exists and is already being written to on every device.