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(() => { 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 {