From 81d528e0f692f7f1876c6099ad25b5bd2f4ecc2f Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 22 Feb 2026 17:58:47 +0530
Subject: [PATCH 1/2] update sync logic
---
src/screens/SyncSettingsScreen.tsx | 4 +-
src/services/supabaseSyncService.ts | 159 ++++++++++++++++++++++------
2 files changed, 130 insertions(+), 33 deletions(-)
diff --git a/src/screens/SyncSettingsScreen.tsx b/src/screens/SyncSettingsScreen.tsx
index 6be0070f..87229945 100644
--- a/src/screens/SyncSettingsScreen.tsx
+++ b/src/screens/SyncSettingsScreen.tsx
@@ -178,8 +178,8 @@ const SyncSettingsScreen: React.FC = () => {
{externalSyncActive
- ? `${externalSyncServices.join(' + ')} is active. Watch progress and library updates are managed by these services instead of Nuvio cloud database.`
- : 'If Trakt or Simkl sync is enabled, watch progress and library updates will use those services instead of Nuvio cloud database.'}
+ ? `${externalSyncServices.join(' + ')} is active. Watch progress and watched status are managed by these services instead of Nuvio cloud database. Library sync still uses Nuvio cloud.`
+ : 'If Trakt or Simkl sync is enabled, watch progress and watched status will use those services instead of Nuvio cloud database. Library sync still uses Nuvio cloud.'}
diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts
index 635baeb8..1149c79f 100644
--- a/src/services/supabaseSyncService.ts
+++ b/src/services/supabaseSyncService.ts
@@ -7,6 +7,7 @@ import { catalogService, StreamingContent } from './catalogService';
import { storageService } from './storageService';
import { watchedService, LocalWatchedItem } from './watchedService';
import { TraktService } from './traktService';
+import { SimklService } from './simklService';
const SUPABASE_SESSION_KEY = '@supabase:session';
const DEFAULT_SYNC_DEBOUNCE_MS = 2000;
@@ -123,6 +124,7 @@ class SupabaseSyncService {
private readonly foregroundPullCooldownMs = 30000;
private pendingWatchProgressDeleteKeys = new Set();
private watchProgressDeleteTimer: ReturnType | null = null;
+ private watchProgressPushedSignatures = new Map();
private pendingPushTimers: Record | null> = {
plugins: null,
@@ -236,6 +238,7 @@ class SupabaseSyncService {
}
this.session = null;
+ this.watchProgressPushedSignatures.clear();
await mmkvStorage.removeItem(SUPABASE_SESSION_KEY);
}
@@ -277,13 +280,15 @@ class SupabaseSyncService {
await this.pushPluginsFromLocal();
await this.pushAddonsFromLocal();
- const traktConnected = await this.isTraktConnected();
- if (traktConnected) {
+ await this.pushLibraryFromLocal();
+ const externalProgressSyncConnected = await this.isExternalProgressSyncConnected();
+ if (externalProgressSyncConnected) {
+ logger.log('[SupabaseSyncService] External sync (Trakt/Simkl) is connected; skipping watch progress/watched-items push, keeping library sync.');
+ logger.log('[SupabaseSyncService] pushAllLocalData: complete');
return;
}
await this.pushWatchProgressFromLocal();
- await this.pushLibraryFromLocal();
await this.pushWatchedItemsFromLocal();
logger.log('[SupabaseSyncService] pushAllLocalData: complete');
}
@@ -296,13 +301,14 @@ class SupabaseSyncService {
await this.pullPluginsToLocal();
await this.pullAddonsToLocal();
- const traktConnected = await this.isTraktConnected();
- if (traktConnected) {
+ await this.pullLibraryToLocal();
+ const externalProgressSyncConnected = await this.isExternalProgressSyncConnected();
+ if (externalProgressSyncConnected) {
+ logger.log('[SupabaseSyncService] External sync (Trakt/Simkl) is connected; skipping watch progress/watched-items pull, keeping library sync.');
return;
}
await this.pullWatchProgressToLocal();
- await this.pullLibraryToLocal();
await this.pullWatchedItemsToLocal();
});
logger.log('[SupabaseSyncService] pullAllToLocal: complete');
@@ -493,9 +499,18 @@ class SupabaseSyncService {
logger.warn('[SupabaseSyncService] runStartupSync: one or more pull steps failed; skipped startup push-by-design');
}
- const traktConnected = await this.isTraktConnected();
- if (traktConnected) {
- logger.log('[SupabaseSyncService] Trakt is connected; skipping progress/library/watched Supabase sync.');
+ const libraryPullOk = await this.safeRun('pull_library', async () => {
+ await this.withSuppressedPushes(async () => {
+ await this.pullLibraryToLocal();
+ });
+ });
+
+ const externalProgressSyncConnected = await this.isExternalProgressSyncConnected();
+ if (externalProgressSyncConnected) {
+ logger.log('[SupabaseSyncService] External sync (Trakt/Simkl) is connected; skipping watch progress/watched-items Supabase sync (library still synced).');
+ if (!libraryPullOk) {
+ logger.warn('[SupabaseSyncService] runStartupSync: library pull failed while external sync priority is active');
+ }
return;
}
@@ -505,12 +520,6 @@ class SupabaseSyncService {
});
});
- const libraryPullOk = await this.safeRun('pull_library', async () => {
- await this.withSuppressedPushes(async () => {
- await this.pullLibraryToLocal();
- });
- });
-
const watchedPullOk = await this.safeRun('pull_watched_items', async () => {
await this.withSuppressedPushes(async () => {
await this.pullWatchedItemsToLocal();
@@ -629,8 +638,8 @@ class SupabaseSyncService {
return;
}
- const traktConnected = await this.isTraktConnected();
- if (traktConnected) return;
+ const externalProgressSyncConnected = await this.isExternalProgressSyncConnected();
+ if (externalProgressSyncConnected) return;
try {
logger.log(`[SupabaseSyncService] flushWatchProgressDeletes: deleting ${keys.length} keys`);
@@ -700,12 +709,12 @@ class SupabaseSyncService {
return;
}
- const traktConnected = await this.isTraktConnected();
- if (traktConnected) {
- return;
- }
-
if (target === 'watch_progress') {
+ const externalProgressSyncConnected = await this.isExternalProgressSyncConnected();
+ if (externalProgressSyncConnected) {
+ logger.log('[SupabaseSyncService] executeScheduledPush: skipping watch_progress due to external sync priority (Trakt/Simkl)');
+ return;
+ }
await this.pushWatchProgressFromLocal();
logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`);
return;
@@ -716,6 +725,12 @@ class SupabaseSyncService {
return;
}
+ const externalProgressSyncConnected = await this.isExternalProgressSyncConnected();
+ if (externalProgressSyncConnected) {
+ logger.log('[SupabaseSyncService] executeScheduledPush: skipping watched_items due to external sync priority (Trakt/Simkl)');
+ return;
+ }
+
await this.pushWatchedItemsFromLocal();
logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`);
}
@@ -745,6 +760,8 @@ class SupabaseSyncService {
}
private async setSession(session: SupabaseSession): Promise {
+ // Reset per-entry push cache on session changes to avoid cross-account state bleed.
+ this.watchProgressPushedSignatures.clear();
this.session = session;
await mmkvStorage.setItem(SUPABASE_SESSION_KEY, JSON.stringify(session));
}
@@ -775,6 +792,7 @@ class SupabaseSyncService {
} catch (error) {
logger.error('[SupabaseSyncService] Failed to refresh session:', error);
this.session = null;
+ this.watchProgressPushedSignatures.clear();
await mmkvStorage.removeItem(SUPABASE_SESSION_KEY);
return false;
}
@@ -791,6 +809,7 @@ class SupabaseSyncService {
} catch (error) {
logger.error('[SupabaseSyncService] Token refresh failed:', error);
this.session = null;
+ this.watchProgressPushedSignatures.clear();
await mmkvStorage.removeItem(SUPABASE_SESSION_KEY);
return null;
}
@@ -997,6 +1016,22 @@ class SupabaseSyncService {
};
}
+ private getWatchProgressEntrySignature(value: { currentTime?: number; duration?: number; lastUpdated?: number }): string {
+ return [
+ Number(value.currentTime || 0),
+ Number(value.duration || 0),
+ Number(value.lastUpdated || 0),
+ ].join('|');
+ }
+
+ private buildLocalWatchProgressKey(
+ contentType: 'movie' | 'series',
+ contentId: string,
+ episodeId?: string
+ ): string {
+ return `${contentType}:${contentId}${episodeId ? `:${episodeId}` : ''}`;
+ }
+
private toStreamingContent(item: LibraryRow): StreamingContent {
const type = item.content_type === 'movie' ? 'movie' : 'series';
const posterShape = (item.poster_shape || 'POSTER').toLowerCase() as 'poster' | 'square' | 'landscape';
@@ -1037,6 +1072,19 @@ class SupabaseSyncService {
}
}
+ private async isSimklConnected(): Promise {
+ try {
+ return await SimklService.getInstance().isAuthenticated();
+ } catch {
+ return false;
+ }
+ }
+
+ private async isExternalProgressSyncConnected(): Promise {
+ if (await this.isTraktConnected()) return true;
+ return await this.isSimklConnected();
+ }
+
private async pullPluginsToLocal(): Promise {
const token = await this.getValidAccessToken();
if (!token) return;
@@ -1254,11 +1302,10 @@ class SupabaseSyncService {
const type = row.content_type === 'movie' ? 'movie' : 'series';
const season = row.season == null ? null : Number(row.season);
const episode = row.episode == null ? null : Number(row.episode);
- remoteSet.add(`${type}:${row.content_id}:${season ?? ''}:${episode ?? ''}`);
-
const episodeId = type === 'series' && season != null && episode != null
? `${row.content_id}:${season}:${episode}`
: undefined;
+ remoteSet.add(this.buildLocalWatchProgressKey(type, row.content_id, episodeId));
const local = await storageService.getWatchProgress(row.content_id, type, episodeId);
const remoteLastWatched = this.normalizeEpochMs(row.last_watched);
@@ -1284,15 +1331,46 @@ class SupabaseSyncService {
);
}
- logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: merged ${(rows || []).length} remote entries (no local prune)`);
+ // Remote-first continue watching: remove local entries that no longer exist remotely.
+ // This intentionally treats the successful remote pull as authoritative.
+ const allLocal = await storageService.getAllWatchProgress();
+ let pruned = 0;
+ for (const [localKey] of Object.entries(allLocal)) {
+ if (remoteSet.has(localKey)) continue;
+
+ const parsed = this.parseWatchProgressKey(localKey);
+ if (!parsed) continue;
+
+ const episodeId = parsed.videoId && parsed.videoId !== parsed.contentId ? parsed.videoId : undefined;
+ await storageService.removeWatchProgress(parsed.contentId, parsed.contentType, episodeId);
+ this.watchProgressPushedSignatures.delete(localKey);
+ pruned += 1;
+ }
+
+ logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: merged=${(rows || []).length} prunedLocalMissing=${pruned}`);
}
private async pushWatchProgressFromLocal(): Promise {
const all = await storageService.getAllWatchProgress();
- const entries: WatchProgressRow[] = Object.entries(all).reduce((acc, [key, value]) => {
+ const nextSeenKeys = new Set();
+ const changedEntries: Array<{ key: string; row: WatchProgressRow; signature: string }> = [];
+
+ for (const [key, value] of Object.entries(all)) {
+ nextSeenKeys.add(key);
+ const signature = this.getWatchProgressEntrySignature(value);
+ if (this.watchProgressPushedSignatures.get(key) === signature) {
+ continue;
+ }
+
const parsed = this.parseWatchProgressKey(key);
- if (!parsed) return acc;
- acc.push({
+ if (!parsed) {
+ continue;
+ }
+
+ changedEntries.push({
+ key,
+ signature,
+ row: {
content_id: parsed.contentId,
content_type: parsed.contentType,
video_id: parsed.videoId,
@@ -1302,11 +1380,30 @@ class SupabaseSyncService {
duration: this.secondsToMsLong(value.duration),
last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()),
progress_key: parsed.progressKey,
+ },
});
- return acc;
- }, []);
+ }
- await this.callRpc('sync_push_watch_progress', { p_entries: entries });
+ // Prune signatures for entries no longer present locally (deletes are handled separately).
+ for (const existingKey of Array.from(this.watchProgressPushedSignatures.keys())) {
+ if (!nextSeenKeys.has(existingKey)) {
+ this.watchProgressPushedSignatures.delete(existingKey);
+ }
+ }
+
+ if (changedEntries.length === 0) {
+ logger.log('[SupabaseSyncService] pushWatchProgressFromLocal: no changed entries; skipping push');
+ return;
+ }
+
+ await this.callRpc('sync_push_watch_progress', {
+ p_entries: changedEntries.map((entry) => entry.row),
+ });
+
+ for (const entry of changedEntries) {
+ this.watchProgressPushedSignatures.set(entry.key, entry.signature);
+ }
+ logger.log(`[SupabaseSyncService] pushWatchProgressFromLocal: pushedChanged=${changedEntries.length} totalLocal=${Object.keys(all).length}`);
}
private async pullLibraryToLocal(): Promise {
From 32e93ea25dd9f9d2fe34a809b5e4600ae08413b5 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Mon, 23 Feb 2026 00:16:38 +0530
Subject: [PATCH 2/2] feat: Implement profilespecific watch progress syncing
with upsert logic and trigger progress saving on pause and seek completion.
---
src/components/player/AndroidVideoPlayer.tsx | 12 ++++++++-
src/components/player/KSPlayerCore.tsx | 11 +++++++-
.../player/hooks/usePlayerControls.ts | 7 +++--
.../player/hooks/useWatchProgress.ts | 27 +++++++------------
4 files changed, 36 insertions(+), 21 deletions(-)
diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx
index 331937f3..d8317ecf 100644
--- a/src/components/player/AndroidVideoPlayer.tsx
+++ b/src/components/player/AndroidVideoPlayer.tsx
@@ -768,7 +768,17 @@ const AndroidVideoPlayer: React.FC = () => {
onProgress={handleProgress}
onSeek={(data) => {
playerState.isSeeking.current = false;
- if (data.currentTime) traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true);
+ if (data.currentTime) {
+ if (id && type && playerState.duration > 0) {
+ void storageService.setWatchProgress(id, type, {
+ currentTime: data.currentTime,
+ duration: playerState.duration,
+ lastUpdated: Date.now(),
+ addonId: currentStreamProvider
+ }, episodeId);
+ }
+ traktAutosync.handleProgressUpdate(data.currentTime, playerState.duration, true);
+ }
}}
onEnd={() => {
if (modals.showEpisodeStreamsModal) return;
diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx
index 231d71d8..c5d36e0e 100644
--- a/src/components/player/KSPlayerCore.tsx
+++ b/src/components/player/KSPlayerCore.tsx
@@ -48,6 +48,7 @@ import { useTraktAutosync } from '../../hooks/useTraktAutosync';
import { useMetadata } from '../../hooks/useMetadata';
import { usePlayerGestureControls } from '../../hooks/usePlayerGestureControls';
import stremioService from '../../services/stremioService';
+import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger';
// Utils
@@ -227,7 +228,15 @@ const KSPlayerCore: React.FC = () => {
currentTime,
duration,
isSeeking,
- isMounted
+ isMounted,
+ onSeekComplete: (timeInSeconds) => {
+ if (!id || !type || duration <= 0) return;
+ void storageService.setWatchProgress(id, type, {
+ currentTime: timeInSeconds,
+ duration,
+ lastUpdated: Date.now()
+ }, episodeId);
+ }
});
const watchProgress = useWatchProgress(
diff --git a/src/components/player/hooks/usePlayerControls.ts b/src/components/player/hooks/usePlayerControls.ts
index f2561c08..73bf2972 100644
--- a/src/components/player/hooks/usePlayerControls.ts
+++ b/src/components/player/hooks/usePlayerControls.ts
@@ -17,6 +17,7 @@ interface PlayerControlsConfig {
duration: number;
isSeeking: MutableRefObject;
isMounted: MutableRefObject;
+ onSeekComplete?: (timeInSeconds: number) => void;
}
export const usePlayerControls = (config: PlayerControlsConfig) => {
@@ -27,7 +28,8 @@ export const usePlayerControls = (config: PlayerControlsConfig) => {
currentTime,
duration,
isSeeking,
- isMounted
+ isMounted,
+ onSeekComplete
} = config;
// iOS seeking helpers
@@ -54,6 +56,7 @@ export const usePlayerControls = (config: PlayerControlsConfig) => {
// Actually perform the seek
playerRef.current.seek(timeInSeconds);
+ onSeekComplete?.(timeInSeconds);
// Debounce the seeking state reset
seekTimeoutRef.current = setTimeout(() => {
@@ -62,7 +65,7 @@ export const usePlayerControls = (config: PlayerControlsConfig) => {
}
}, 500);
}
- }, [duration, paused, playerRef, isSeeking, isMounted]);
+ }, [duration, paused, playerRef, isSeeking, isMounted, onSeekComplete]);
const skip = useCallback((seconds: number) => {
seekToTime(currentTime + seconds);
diff --git a/src/components/player/hooks/useWatchProgress.ts b/src/components/player/hooks/useWatchProgress.ts
index 5b70282d..8440e38e 100644
--- a/src/components/player/hooks/useWatchProgress.ts
+++ b/src/components/player/hooks/useWatchProgress.ts
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
-import { AppState, AppStateStatus } from 'react-native';
+import { AppState } from 'react-native';
import { storageService } from '../../../services/storageService';
import { logger } from '../../../utils/logger';
import { useSettings } from '../../../hooks/useSettings';
@@ -19,10 +19,9 @@ export const useWatchProgress = (
const [savedDuration, setSavedDuration] = useState(null);
const [initialPosition, setInitialPosition] = useState(null);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
- const [progressSaveInterval, setProgressSaveInterval] = useState(null);
-
const { settings: appSettings } = useSettings();
const initialSeekTargetRef = useRef(null);
+ const wasPausedRef = useRef(paused);
// Values refs for unmount cleanup
const currentTimeRef = useRef(currentTime);
@@ -126,22 +125,16 @@ export const useWatchProgress = (
}
};
- // Save Interval
+
useEffect(() => {
- if (id && type && !paused && duration > 0) {
- if (progressSaveInterval) clearInterval(progressSaveInterval);
-
- const interval = setInterval(() => {
- saveWatchProgress();
- }, 10000);
-
- setProgressSaveInterval(interval);
- return () => {
- clearInterval(interval);
- setProgressSaveInterval(null);
- };
+ if (wasPausedRef.current !== paused) {
+ const becamePaused = paused;
+ wasPausedRef.current = paused;
+ if (becamePaused) {
+ void saveWatchProgress();
+ }
}
- }, [id, type, paused, currentTime, duration]);
+ }, [paused]);
// Unmount Save - deferred to allow navigation to complete first
useEffect(() => {