From 44f9aa5c35af6a96c4c6116595253c55642bf04a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 10:39:38 +0530 Subject: [PATCH 01/10] Auto-select first stream sort fix --- LibTorrent | 1 + iTorrent | 1 + src/screens/streams/useStreamsScreen.ts | 11 ++++++----- 3 files changed, 8 insertions(+), 5 deletions(-) create mode 160000 LibTorrent create mode 160000 iTorrent diff --git a/LibTorrent b/LibTorrent new file mode 160000 index 00000000..eb1c7139 --- /dev/null +++ b/LibTorrent @@ -0,0 +1 @@ +Subproject commit eb1c71397b8716b97fcd375fd646e96c89632a5e diff --git a/iTorrent b/iTorrent new file mode 160000 index 00000000..c27088b0 --- /dev/null +++ b/iTorrent @@ -0,0 +1 @@ +Subproject commit c27088b0ac36bf9bb30fae34dc36db1231263bfd diff --git a/src/screens/streams/useStreamsScreen.ts b/src/screens/streams/useStreamsScreen.ts index fc83da61..993cfdb6 100644 --- a/src/screens/streams/useStreamsScreen.ts +++ b/src/screens/streams/useStreamsScreen.ts @@ -234,25 +234,26 @@ export const useStreamsScreen = () => { return 0; }; - const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number }> = []; + const allStreams: Array<{ stream: Stream; quality: number; providerPriority: number; originalIndex: number }> = []; Object.entries(streamsData).forEach(([addonId, { streams }]) => { const qualityFiltered = filterByQuality(streams); const filteredStreams = filterByLanguage(qualityFiltered); - filteredStreams.forEach(stream => { + filteredStreams.forEach((stream, index) => { const quality = getQualityNumeric(stream.name || stream.title); const providerPriority = getProviderPriority(addonId); - allStreams.push({ stream, quality, providerPriority }); + allStreams.push({ stream, quality, providerPriority, originalIndex: index }); }); }); if (allStreams.length === 0) return null; + // Sort primarily by provider priority, then respect the addon's internal order (originalIndex) + // This ensures if an addon lists 1080p before 4K, we pick 1080p allStreams.sort((a, b) => { - if (a.quality !== b.quality) return b.quality - a.quality; if (a.providerPriority !== b.providerPriority) return b.providerPriority - a.providerPriority; - return 0; + return a.originalIndex - b.originalIndex; }); logger.log( From bfba45e74a6bb24e12b6b547e3cf3a76c0a2f2be Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 11:56:37 +0530 Subject: [PATCH 02/10] removed streamscreen header filter --- src/components/player/KSPlayerCore.tsx | 84 +++++++++++++++++++++++++ src/screens/streams/useStreamsScreen.ts | 47 ++++++++++++-- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 1cad96f9..7259813c 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -67,6 +67,7 @@ interface PlayerRouteParams { year?: number; streamProvider?: string; streamName?: string; + videoType?: string; id: string; type: string; episodeId?: string; @@ -92,6 +93,42 @@ const KSPlayerCore: React.FC = () => { initialPosition: routeInitialPosition } = params; + const videoType = (params as any)?.videoType as string | undefined; + + useEffect(() => { + if (!__DEV__) return; + const headerKeys = Object.keys(headers || {}); + logger.log('[KSPlayerCore] route params', { + uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + id, + type, + episodeId, + imdbId, + title, + episodeTitle, + season, + episode, + quality, + year, + streamProvider, + streamName, + videoType, + headersKeys: headerKeys, + headersCount: headerKeys.length, + }); + }, [uri, episodeId]); + + useEffect(() => { + if (!__DEV__) return; + const headerKeys = Object.keys(headers || {}); + logger.log('[KSPlayerCore] source update', { + uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + videoType, + headersCount: headerKeys.length, + headersKeys: headerKeys, + }); + }, [uri, headers, videoType]); + // --- Hooks --- const playerState = usePlayerState(); const { @@ -399,6 +436,17 @@ const KSPlayerCore: React.FC = () => { // Handlers const onLoad = (data: any) => { + if (__DEV__) { + logger.log('[KSPlayerCore] onLoad', { + uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + duration: data?.duration, + audioTracksCount: Array.isArray(data?.audioTracks) ? data.audioTracks.length : 0, + textTracksCount: Array.isArray(data?.textTracks) ? data.textTracks.length : 0, + videoType, + headersKeys: Object.keys(headers || {}), + }); + } + setDuration(data.duration); if (data.audioTracks) tracks.setKsAudioTracks(data.audioTracks); if (data.textTracks) tracks.setKsTextTracks(data.textTracks); @@ -482,6 +530,18 @@ const KSPlayerCore: React.FC = () => { } catch (e) { msg = 'Error parsing error details'; } + + if (__DEV__) { + logger.error('[KSPlayerCore] onError', { + msg, + uri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + videoType, + streamProvider, + streamName, + headersKeys: Object.keys(headers || {}), + rawError: error, + }); + } modals.setErrorDetails(msg); modals.setShowErrorModal(true); }; @@ -525,6 +585,17 @@ const KSPlayerCore: React.FC = () => { modals.setShowSourcesModal(false); return; } + + if (__DEV__) { + logger.log('[KSPlayerCore] switching stream', { + fromUri: typeof uri === 'string' ? uri.slice(0, 240) : uri, + toUri: typeof newStream?.url === 'string' ? newStream.url.slice(0, 240) : newStream?.url, + newStreamHeadersKeys: Object.keys(newStream?.headers || {}), + newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown', + newName: newStream?.name || newStream?.title || 'Unknown', + }); + } + modals.setShowSourcesModal(false); setPaused(true); @@ -559,6 +630,19 @@ const KSPlayerCore: React.FC = () => { setPaused(true); const ep = modals.selectedEpisodeForStreams; + if (__DEV__) { + logger.log('[KSPlayerCore] switching episode stream', { + toUri: typeof stream?.url === 'string' ? stream.url.slice(0, 240) : stream?.url, + streamHeadersKeys: Object.keys(stream?.headers || {}), + ep: { + season: ep?.season_number, + episode: ep?.episode_number, + name: ep?.name, + stremioId: ep?.stremioId, + }, + }); + } + const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]); const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown'; const newStreamName = stream.name || stream.title || 'Unknown Stream'; diff --git a/src/screens/streams/useStreamsScreen.ts b/src/screens/streams/useStreamsScreen.ts index 993cfdb6..de3299b1 100644 --- a/src/screens/streams/useStreamsScreen.ts +++ b/src/screens/streams/useStreamsScreen.ts @@ -25,7 +25,6 @@ import { getQualityNumeric, detectMkvViaHead, inferVideoTypeFromUrl, - filterHeadersForVidrock, sortStreamsByQuality, } from './utils'; import { @@ -356,11 +355,17 @@ export const useStreamsScreen = () => { // Navigate to player const navigateToPlayer = useCallback( async (stream: Stream, options?: { headers?: Record }) => { - const finalHeaders = filterHeadersForVidrock(options?.headers || (stream.headers as any)); + const optionHeaders = options?.headers; + const streamHeaders = (stream.headers as any) as Record | undefined; + const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as + | Record + | undefined; + const streamProvider = stream.addonId || (stream as any).addonName || stream.name; + const finalHeaders = optionHeaders || streamHeaders || proxyHeaders; const streamsToPass = selectedEpisode ? episodeStreams : groupedStreams; const streamName = stream.name || stream.title || 'Unnamed Stream'; - const streamProvider = stream.addonId || stream.addonName || stream.name; + const resolvedStreamProvider = streamProvider; // Save stream to cache try { @@ -393,6 +398,22 @@ export const useStreamsScreen = () => { } } catch { } + if (__DEV__) { + const finalHeaderKeys = Object.keys(finalHeaders || {}); + + logger.log('[StreamsScreen][navigateToPlayer] stream selection', { + url: typeof stream.url === 'string' ? stream.url.slice(0, 240) : stream.url, + addonId: stream.addonId, + addonName: (stream as any).addonName, + name: stream.name, + title: stream.title, + inferredVideoType: videoType, + optionHeadersKeys: Object.keys(optionHeaders || {}), + streamHeadersKeys: Object.keys(streamHeaders || {}), + finalHeadersKeys: finalHeaderKeys, + }); + } + const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid'; navigation.navigate(playerRoute as any, { @@ -403,7 +424,7 @@ export const useStreamsScreen = () => { episode: (type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined, quality: (stream.title?.match(/(\d+)p/) || [])[1] || undefined, year: metadata?.year, - streamProvider, + streamProvider: resolvedStreamProvider, streamName, headers: finalHeaders, id, @@ -424,6 +445,24 @@ export const useStreamsScreen = () => { try { if (!stream.url) return; + if (__DEV__) { + const streamHeaders = (stream.headers as any) as Record | undefined; + const proxyHeaders = ((stream as any)?.behaviorHints?.proxyHeaders?.request || undefined) as + | Record + | undefined; + + logger.log('[StreamsScreen][handleStreamPress] pressed stream', { + url: typeof stream.url === 'string' ? stream.url.slice(0, 240) : stream.url, + addonId: stream.addonId, + addonName: (stream as any).addonName, + name: stream.name, + title: stream.title, + streamHeadersKeys: Object.keys(streamHeaders || {}), + proxyHeadersKeys: Object.keys(proxyHeaders || {}), + inferredVideoType: inferVideoTypeFromUrl(stream.url), + }); + } + // Block magnet links if (typeof stream.url === 'string' && stream.url.startsWith('magnet:')) { openAlert('Not supported', 'Torrent streaming is not supported yet.'); From 25e1102832967e94feed37b58291794492df4d6b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 13:48:19 +0530 Subject: [PATCH 03/10] simkl init --- .gitignore | 3 +- src/components/icons/SimklIcon.tsx | 21 ++ src/hooks/useSimklIntegration.ts | 227 +++++++++++++ src/hooks/useTraktAutosync.ts | 268 +++++++++++---- src/navigation/AppNavigator.tsx | 17 + src/screens/SettingsScreen.tsx | 32 +- src/screens/SimklSettingsScreen.tsx | 318 +++++++++++++++++ src/screens/TraktSettingsScreen.tsx | 6 + src/services/simklService.ts | 509 ++++++++++++++++++++++++++++ src/services/storageService.ts | 147 +++++++- src/services/watchedService.ts | 189 ++++++++--- 11 files changed, 1615 insertions(+), 122 deletions(-) create mode 100644 src/components/icons/SimklIcon.tsx create mode 100644 src/hooks/useSimklIntegration.ts create mode 100644 src/screens/SimklSettingsScreen.tsx create mode 100644 src/services/simklService.ts diff --git a/.gitignore b/.gitignore index b953572e..84f27aeb 100644 --- a/.gitignore +++ b/.gitignore @@ -97,4 +97,5 @@ trakt-docss # Removed submodules (kept locally) libmpv-android/ mpv-android/ -mpvKt/ \ No newline at end of file +mpvKt/ +simkl-docss \ No newline at end of file diff --git a/src/components/icons/SimklIcon.tsx b/src/components/icons/SimklIcon.tsx new file mode 100644 index 00000000..8f2b5133 --- /dev/null +++ b/src/components/icons/SimklIcon.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Svg, { Path } from 'react-native-svg'; + +interface SimklIconProps { + size?: number; + color?: string; + style?: any; +} + +const SimklIcon: React.FC = ({ size = 24, color = '#000000', style }) => { + return ( + + + + ); +}; + +export default SimklIcon; diff --git a/src/hooks/useSimklIntegration.ts b/src/hooks/useSimklIntegration.ts new file mode 100644 index 00000000..a7bdd1bf --- /dev/null +++ b/src/hooks/useSimklIntegration.ts @@ -0,0 +1,227 @@ +import { useState, useEffect, useCallback } from 'react'; +import { AppState, AppStateStatus } from 'react-native'; +import { + SimklService, + SimklContentData, + SimklPlaybackData +} from '../services/simklService'; +import { storageService } from '../services/storageService'; +import { logger } from '../utils/logger'; + +const simklService = SimklService.getInstance(); + +export function useSimklIntegration() { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // Basic lists + const [continueWatching, setContinueWatching] = useState([]); + + // Check authentication status + const checkAuthStatus = useCallback(async () => { + setIsLoading(true); + try { + const authenticated = await simklService.isAuthenticated(); + setIsAuthenticated(authenticated); + } catch (error) { + logger.error('[useSimklIntegration] Error checking auth status:', error); + } finally { + setIsLoading(false); + } + }, []); + + // Force refresh + const refreshAuthStatus = useCallback(async () => { + await checkAuthStatus(); + }, [checkAuthStatus]); + + // Load playback/continue watching + const loadPlaybackStatus = useCallback(async () => { + if (!isAuthenticated) return; + try { + const playback = await simklService.getPlaybackStatus(); + setContinueWatching(playback); + } catch (error) { + logger.error('[useSimklIntegration] Error loading playback status:', error); + } + }, [isAuthenticated]); + + // Start watching (scrobble start) + const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { + if (!isAuthenticated) return false; + try { + const res = await simklService.scrobbleStart(content, progress); + return !!res; + } catch (error) { + logger.error('[useSimklIntegration] Error starting watch:', error); + return false; + } + }, [isAuthenticated]); + + // Update progress (scrobble pause) + const updateProgress = useCallback(async (content: SimklContentData, progress: number): Promise => { + if (!isAuthenticated) return false; + try { + const res = await simklService.scrobblePause(content, progress); + return !!res; + } catch (error) { + logger.error('[useSimklIntegration] Error updating progress:', error); + return false; + } + }, [isAuthenticated]); + + // Stop watching (scrobble stop) + const stopWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { + if (!isAuthenticated) return false; + try { + const res = await simklService.scrobbleStop(content, progress); + return !!res; + } catch (error) { + logger.error('[useSimklIntegration] Error stopping watch:', error); + return false; + } + }, [isAuthenticated]); + + // Sync All Local Progress -> Simkl + const syncAllProgress = useCallback(async (): Promise => { + if (!isAuthenticated) return false; + + try { + const unsynced = await storageService.getUnsyncedProgress(); + // Filter for items that specifically need SIMKL sync (unsynced.filter(i => !i.progress.simklSynced...)) + // storageService.getUnsyncedProgress currently returns items that need Trakt OR Simkl sync. + // We should check simklSynced specifically here. + + const itemsToSync = unsynced.filter(i => !i.progress.simklSynced || (i.progress.simklLastSynced && i.progress.lastUpdated > i.progress.simklLastSynced)); + + if (itemsToSync.length === 0) return true; + + logger.log(`[useSimklIntegration] Found ${itemsToSync.length} items to sync to Simkl`); + + for (const item of itemsToSync) { + try { + const season = item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined; + const episode = item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined; + + // Construct content data + const content: SimklContentData = { + type: item.type === 'series' ? 'episode' : 'movie', + title: 'Unknown', // Ideally storage has title, but it might not. Simkl needs IDs mainly. + ids: { imdb: item.id }, + season, + episode + }; + + const progressPercent = (item.progress.currentTime / item.progress.duration) * 100; + + // If completed (>=80% or 95% depending on logic, let's say 85% safe), add to history + // Simkl: Stop with >= 80% marks as watched. + // Or explicitly add to history. + + let success = false; + if (progressPercent >= 85) { + // Add to history + if (content.type === 'movie') { + await simklService.addToHistory({ movies: [{ ids: { imdb: item.id } }] }); + } else { + await simklService.addToHistory({ shows: [{ ids: { imdb: item.id }, seasons: [{ number: season, episodes: [{ number: episode }] }] }] }); + } + success = true; // Assume success if no throw + } else { + // Pause (scrobble) + const res = await simklService.scrobblePause(content, progressPercent); + success = !!res; + } + + if (success) { + await storageService.updateSimklSyncStatus(item.id, item.type, true, progressPercent, item.episodeId); + } + } catch (e) { + logger.error(`[useSimklIntegration] Failed to sync item ${item.id}`, e); + } + } + return true; + } catch (e) { + logger.error('[useSimklIntegration] Error syncing all progress', e); + return false; + } + }, [isAuthenticated]); + + // Fetch Simkl -> Merge Local + const fetchAndMergeSimklProgress = useCallback(async (): Promise => { + if (!isAuthenticated) return false; + + try { + const playback = await simklService.getPlaybackStatus(); + + for (const item of playback) { + let id: string | undefined; + let type: string; + let episodeId: string | undefined; + + if (item.movie) { + id = item.movie.ids.imdb; + type = 'movie'; + } else if (item.show && item.episode) { + id = item.show.ids.imdb; + type = 'series'; + episodeId = `${id}:${item.episode.season}:${item.episode.episode}`; + } + + if (id) { + await storageService.mergeWithSimklProgress( + id, + type!, + item.progress, + item.paused_at, + episodeId + ); + + // Mark as synced locally so we don't push it back + await storageService.updateSimklSyncStatus(id, type!, true, item.progress, episodeId); + } + } + return true; + } catch (e) { + logger.error('[useSimklIntegration] Error fetching/merging Simkl progress', e); + return false; + } + }, [isAuthenticated]); + + // Effects + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); + + useEffect(() => { + if (isAuthenticated) { + loadPlaybackStatus(); + fetchAndMergeSimklProgress(); + } + }, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress]); + + // App state listener for sync + useEffect(() => { + if (!isAuthenticated) return; + const sub = AppState.addEventListener('change', (state) => { + if (state === 'active') { + fetchAndMergeSimklProgress(); + } + }); + return () => sub.remove(); + }, [isAuthenticated, fetchAndMergeSimklProgress]); + + + return { + isAuthenticated, + isLoading, + checkAuthStatus, + refreshAuthStatus, + startWatching, + updateProgress, + stopWatching, + syncAllProgress, + fetchAndMergeSimklProgress, + continueWatching + }; +} diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 4200e1cb..57bec23a 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -1,7 +1,9 @@ import { useCallback, useRef, useEffect } from 'react'; import { useTraktIntegration } from './useTraktIntegration'; +import { useSimklIntegration } from './useSimklIntegration'; import { useTraktAutosyncSettings } from './useTraktAutosyncSettings'; import { TraktContentData } from '../services/traktService'; +import { SimklContentData } from '../services/simklService'; import { storageService } from '../services/storageService'; import { logger } from '../utils/logger'; @@ -30,6 +32,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { stopWatchingImmediate } = useTraktIntegration(); + const { + isAuthenticated: isSimklAuthenticated, + startWatching: startSimkl, + updateProgress: updateSimkl, + stopWatching: stopSimkl + } = useSimklIntegration(); + const { settings: autosyncSettings } = useTraktAutosyncSettings(); const hasStartedWatching = useRef(false); @@ -145,14 +154,29 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } }, [options]); + const buildSimklContentData = useCallback((): SimklContentData => { + return { + type: options.type === 'series' ? 'episode' : 'movie', + title: options.title, + ids: { + imdb: options.imdbId + }, + season: options.season, + episode: options.episode + }; + }, [options]); + // Start watching (scrobble start) const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => { if (isUnmounted.current) return; // Prevent execution after component unmount logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`); - if (!isAuthenticated || !autosyncSettings.enabled) { - logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); + const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; + const shouldSyncSimkl = isSimklAuthenticated; + + if (!shouldSyncTrakt && !shouldSyncSimkl) { + logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`); return; } @@ -190,16 +214,28 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } - const success = await startWatching(contentData, progressPercent); - if (success) { + if (shouldSyncTrakt) { + const success = await startWatching(contentData, progressPercent); + if (success) { + hasStartedWatching.current = true; + hasStopped.current = false; // Reset stop flag when starting + logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`); + } + } else { + // If Trakt is disabled but Simkl is enabled, we still mark stated/stopped flags for local logic hasStartedWatching.current = true; - hasStopped.current = false; // Reset stop flag when starting - logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`); + hasStopped.current = false; + } + + // Simkl Start + if (shouldSyncSimkl) { + const simklData = buildSimklContentData(); + await startSimkl(simklData, progressPercent); } } catch (error) { logger.error('[TraktAutosync] Error starting watch:', error); } - }, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]); + }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]); // Sync progress during playback const handleProgressUpdate = useCallback(async ( @@ -209,7 +245,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { ) => { if (isUnmounted.current) return; // Prevent execution after component unmount - if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) { + const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; + const shouldSyncSimkl = isSimklAuthenticated; + + if ((!shouldSyncTrakt && !shouldSyncSimkl) || duration <= 0) { return; } @@ -225,70 +264,95 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // IMMEDIATE SYNC: Use immediate method for user-triggered actions (force=true) // Use regular queued method for background periodic syncs - let success: boolean; + let traktSuccess: boolean = false; - if (force) { - // IMMEDIATE: User action (pause/unpause) - bypass queue - const contentData = buildContentData(); - if (!contentData) { - logger.warn('[TraktAutosync] Skipping progress update: invalid content data'); - return; - } - success = await updateProgressImmediate(contentData, progressPercent); + if (shouldSyncTrakt) { + if (force) { + // IMMEDIATE: User action (pause/unpause) - bypass queue + const contentData = buildContentData(); + if (!contentData) { + logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data'); + return; + } + traktSuccess = await updateProgressImmediate(contentData, progressPercent); - if (success) { - lastSyncTime.current = now; - lastSyncProgress.current = progressPercent; + if (traktSuccess) { + lastSyncTime.current = now; + lastSyncProgress.current = progressPercent; - // Update local storage sync status - await storageService.updateTraktSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId, - currentTime - ); + // Update local storage sync status + await storageService.updateTraktSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId, + currentTime + ); - logger.log(`[TraktAutosync] IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`); - } - } else { - // BACKGROUND: Periodic sync - use queued method - const progressDiff = Math.abs(progressPercent - lastSyncProgress.current); + logger.log(`[TraktAutosync] Trakt IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`); + } + } else { + // BACKGROUND: Periodic sync - use queued method + const progressDiff = Math.abs(progressPercent - lastSyncProgress.current); - // Only skip if not forced and progress difference is minimal (< 0.5%) - if (progressDiff < 0.5) { - return; - } + // Only skip if not forced and progress difference is minimal (< 0.5%) + if (progressDiff < 0.5) { + logger.log(`[TraktAutosync] Trakt: Skipping periodic progress update, progress diff too small (${progressDiff.toFixed(2)}%)`); + // If only Trakt is active and we skip, we should return here. + // If Simkl is also active, we continue to let Simkl update. + if (!shouldSyncSimkl) return; + } - const contentData = buildContentData(); - if (!contentData) { - logger.warn('[TraktAutosync] Skipping progress update: invalid content data'); - return; - } - success = await updateProgress(contentData, progressPercent, force); + const contentData = buildContentData(); + if (!contentData) { + logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data'); + return; + } + traktSuccess = await updateProgress(contentData, progressPercent, force); - if (success) { - lastSyncTime.current = now; - lastSyncProgress.current = progressPercent; + if (traktSuccess) { + lastSyncTime.current = now; + lastSyncProgress.current = progressPercent; - // Update local storage sync status - await storageService.updateTraktSyncStatus( - options.id, - options.type, - true, - progressPercent, - options.episodeId, - currentTime - ); + // Update local storage sync status + await storageService.updateTraktSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId, + currentTime + ); - // Progress sync logging removed + // Progress sync logging removed + logger.log(`[TraktAutosync] Trakt: Progress updated to ${progressPercent.toFixed(1)}%`); + } } } + + // Simkl Update (No immediate/queued differentiation for now in Simkl hook, just call update) + if (shouldSyncSimkl) { + // Debounce simkl updates slightly if needed, but hook handles calls. + // We do basic difference check here + const simklData = buildSimklContentData(); + await updateSimkl(simklData, progressPercent); + + // Update local storage for Simkl + await storageService.updateSimklSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId + ); + logger.log(`[TraktAutosync] Simkl: Progress updated to ${progressPercent.toFixed(1)}%`); + } + } catch (error) { logger.error('[TraktAutosync] Error syncing progress:', error); } - }, [isAuthenticated, autosyncSettings.enabled, updateProgress, updateProgressImmediate, buildContentData, options]); + }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, updateProgress, updateSimkl, updateProgressImmediate, buildContentData, buildSimklContentData, options]); // Handle playback end/pause const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => { @@ -298,8 +362,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Removed excessive logging for handlePlaybackEnd calls - if (!isAuthenticated || !autosyncSettings.enabled) { - // logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`); + const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled; + const shouldSyncSimkl = isSimklAuthenticated; + + if (!shouldSyncTrakt && !shouldSyncSimkl) { + logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`); return; } @@ -323,6 +390,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { isSignificantUpdate = true; } else { // Already stopped this session, skipping duplicate call + logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: session already stopped and no significant progress improvement.`); return; } } @@ -390,8 +458,20 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { if (!hasStartedWatching.current && progressPercent > 1) { const contentData = buildContentData(); if (contentData) { - const success = await startWatching(contentData, progressPercent); - if (success) { + let started = false; + // Try starting Trakt if enabled + if (shouldSyncTrakt) { + const s = await startWatching(contentData, progressPercent); + if (s) started = true; + } + // Try starting Simkl if enabled (always 'true' effectively if authenticated) + if (shouldSyncSimkl) { + const simklData = buildSimklContentData(); + await startSimkl(simklData, progressPercent); + started = true; + } + + if (started) { hasStartedWatching.current = true; } } @@ -401,6 +481,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Lower threshold for unmount calls to catch more edge cases if (reason === 'unmount' && progressPercent < 0.5) { // Early unmount stop logging removed + logger.log(`[TraktAutosync] Skipping unmount stop call due to minimal progress (${progressPercent.toFixed(1)}%)`); return; } @@ -419,13 +500,24 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { return; } - // IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends - const success = useImmediate - ? await stopWatchingImmediate(contentData, progressPercent) - : await stopWatching(contentData, progressPercent); + let overallSuccess = false; - if (success) { - // Update local storage sync status + // IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends + let traktStopSuccess = false; + if (shouldSyncTrakt) { + traktStopSuccess = useImmediate + ? await stopWatchingImmediate(contentData, progressPercent) + : await stopWatching(contentData, progressPercent); + if (traktStopSuccess) { + logger.log(`[TraktAutosync] Trakt: ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + overallSuccess = true; + } else { + logger.warn(`[TraktAutosync] Trakt: Failed to stop watching.`); + } + } + + if (traktStopSuccess) { + // Update local storage sync status for Trakt await storageService.updateTraktSyncStatus( options.id, options.type, @@ -434,7 +526,30 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { options.episodeId, currentTime ); + } else if (shouldSyncTrakt) { + // If Trakt stop failed, reset the stop flag so we can try again later + hasStopped.current = false; + logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`); + } + // Simkl Stop + if (shouldSyncSimkl) { + const simklData = buildSimklContentData(); + await stopSimkl(simklData, progressPercent); + + // Update local storage sync status for Simkl + await storageService.updateSimklSyncStatus( + options.id, + options.type, + true, + progressPercent, + options.episodeId + ); + logger.log(`[TraktAutosync] Simkl: Successfully stopped watching: ${simklData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + overallSuccess = true; // Mark overall success if at least one worked (Simkl doesn't have immediate/queued logic yet) + } + + if (overallSuccess) { // Mark session as complete if >= user completion threshold if (progressPercent >= autosyncSettings.completionThreshold) { isSessionComplete.current = true; @@ -450,8 +565,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { currentTime: duration, duration, lastUpdated: Date.now(), - traktSynced: true, - traktProgress: Math.max(progressPercent, 100), + traktSynced: shouldSyncTrakt ? true : undefined, + traktProgress: shouldSyncTrakt ? Math.max(progressPercent, 100) : undefined, + simklSynced: shouldSyncSimkl ? true : undefined, + simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined, } as any, options.episodeId, { forceNotify: true } @@ -460,11 +577,14 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } catch { } } - logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + // General success log if at least one service succeeded + if (!shouldSyncTrakt || traktStopSuccess) { // Only log this if Trakt succeeded or wasn't active + logger.log(`[TraktAutosync] Overall: Successfully processed stop for: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`); + } } else { - // If stop failed, reset the stop flag so we can try again later + // If neither service succeeded, reset the stop flag hasStopped.current = false; - logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`); + logger.warn(`[TraktAutosync] Overall: Failed to stop watching, reset stop flag for retry`); } // Reset state only for natural end or very high progress unmounts @@ -480,7 +600,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Reset stop flag on error so we can try again hasStopped.current = false; } - }, [isAuthenticated, autosyncSettings.enabled, stopWatching, stopWatchingImmediate, startWatching, buildContentData, options]); + }, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, stopWatching, stopSimkl, stopWatchingImmediate, startWatching, buildContentData, buildSimklContentData, options]); // Reset state (useful when switching content) const resetState = useCallback(() => { diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index fd1790cc..f0a91093 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -55,6 +55,7 @@ import TMDBSettingsScreen from '../screens/TMDBSettingsScreen'; import HomeScreenSettings from '../screens/HomeScreenSettings'; import HeroCatalogsScreen from '../screens/HeroCatalogsScreen'; import TraktSettingsScreen from '../screens/TraktSettingsScreen'; +import SimklSettingsScreen from '../screens/SimklSettingsScreen'; import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import ThemeScreen from '../screens/ThemeScreen'; import OnboardingScreen from '../screens/OnboardingScreen'; @@ -185,6 +186,7 @@ export type RootStackParamList = { HomeScreenSettings: undefined; HeroCatalogs: undefined; TraktSettings: undefined; + SimklSettings: undefined; PlayerSettings: undefined; ThemeSettings: undefined; ScraperSettings: undefined; @@ -1565,6 +1567,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + = 768; @@ -201,6 +203,7 @@ const SettingsScreen: React.FC = () => { const navigation = useNavigation>(); const { lastUpdate } = useCatalogContext(); const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext(); + const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration(); const { currentTheme } = useTheme(); // Tablet-specific state @@ -378,6 +381,17 @@ const SettingsScreen: React.FC = () => { customIcon={} renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} + isLast={!isItemVisible('simkl')} + isTablet={isTablet} + /> + )} + {isItemVisible('simkl') && ( + } + renderControl={() => } + onPress={() => navigation.navigate('SimklSettings')} isLast={true} isTablet={isTablet} /> @@ -618,7 +632,7 @@ const SettingsScreen: React.FC = () => { contentContainerStyle={[styles.bottomSheetContent, { paddingBottom: insets.bottom + 16 }]} > { - LOCALES.sort((a,b) => a.key.localeCompare(b.key)).map(l => + LOCALES.sort((a, b) => a.key.localeCompare(b.key)).map(l => { contentContainerStyle={styles.scrollContent} > {/* Account */} - {(settingsConfig?.categories?.['account']?.visible !== false) && isItemVisible('trakt') && ( + {(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl')) && ( {isItemVisible('trakt') && ( { customIcon={} renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast + isLast={!isItemVisible('simkl')} + /> + )} + {isItemVisible('simkl') && ( + } + renderControl={() => } + onPress={() => navigation.navigate('SimklSettings')} + isLast={true} /> )} @@ -940,7 +964,7 @@ const SettingsScreen: React.FC = () => { contentContainerStyle={[styles.bottomSheetContent, { paddingBottom: insets.bottom + 16 }]} > { - LOCALES.sort((a,b) => a.key.localeCompare(b.key)).map(l => + LOCALES.sort((a, b) => a.key.localeCompare(b.key)).map(l => { + const { settings } = useSettings(); + const isDarkMode = settings.enableDarkMode; + const navigation = useNavigation(); + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + + const { currentTheme } = useTheme(); + + const { + isAuthenticated, + isLoading, + checkAuthStatus, + refreshAuthStatus + } = useSimklIntegration(); + const { isAuthenticated: isTraktAuthenticated } = useTraktIntegration(); + + const [isExchangingCode, setIsExchangingCode] = useState(false); + + const openAlert = (title: string, message: string) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertVisible(true); + }; + + // Setup expo-auth-session hook + const [request, response, promptAsync] = useAuthRequest( + { + clientId: SIMKL_CLIENT_ID, + scopes: [], // Simkl doesn't strictly use scopes for basic access + redirectUri: SIMKL_REDIRECT_URI, // Must match what is set in Simkl Dashboard + responseType: ResponseType.Code, + // codeChallengeMethod: CodeChallengeMethod.S256, // Simkl might not verify PKCE, but standard compliant + }, + discovery + ); + + useEffect(() => { + checkAuthStatus(); + }, [checkAuthStatus]); + + // Handle the response from the auth request + useEffect(() => { + if (response) { + if (response.type === 'success') { + const { code } = response.params; + setIsExchangingCode(true); + logger.log('[SimklSettingsScreen] Auth code received, exchanging...'); + + simklService.exchangeCodeForToken(code) + .then(success => { + if (success) { + refreshAuthStatus(); + openAlert('Success', 'Connected to Simkl successfully!'); + } else { + openAlert('Error', 'Failed to connect to Simkl.'); + } + }) + .catch(err => { + logger.error('[SimklSettingsScreen] Token exchange error:', err); + openAlert('Error', 'An error occurred during connection.'); + }) + .finally(() => setIsExchangingCode(false)); + } else if (response.type === 'error') { + openAlert('Error', 'Authentication error: ' + (response.error?.message || 'Unknown')); + } + } + }, [response, refreshAuthStatus]); + + const handleSignIn = () => { + if (!SIMKL_CLIENT_ID) { + openAlert('Configuration Error', 'Simkl Client ID is missing in environment variables.'); + return; + } + + if (isTraktAuthenticated) { + openAlert('Conflict', 'You cannot connect to Simkl while Trakt is connected. Please disconnect Trakt first.'); + return; + } + + promptAsync(); + }; + + const handleSignOut = async () => { + await simklService.logout(); + refreshAuthStatus(); + openAlert('Signed Out', 'You have disconnected from Simkl.'); + }; + + return ( + + + + navigation.goBack()} + style={styles.backButton} + > + + + Settings + + + + + + Simkl Integration + + + + + {isLoading ? ( + + + + ) : isAuthenticated ? ( + + + Connected + + + Your watched items are syncing with Simkl. + + + Disconnect + + + ) : ( + + + Connect Simkl + + + Sync your watch history and track what you're watching. + + + {isExchangingCode ? ( + + ) : ( + Sign In with Simkl + )} + + + )} + + + + Nuvio is not affiliated with Simkl. + + + + setAlertVisible(false)} + actions={[{ label: 'OK', onPress: () => setAlertVisible(false) }]} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 8, + }, + backText: { + fontSize: 17, + marginLeft: 8, + }, + headerTitle: { + fontSize: 34, + fontWeight: 'bold', + paddingHorizontal: 16, + marginBottom: 24, + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 16, + paddingBottom: 32, + }, + card: { + borderRadius: 12, + overflow: 'hidden', + padding: 20, + marginBottom: 16, + elevation: 2, + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + }, + loadingContainer: { + padding: 40, + alignItems: 'center', + }, + signInContainer: { + alignItems: 'center', + paddingVertical: 20, + }, + signInTitle: { + fontSize: 20, + fontWeight: '600', + marginBottom: 8, + }, + signInDescription: { + textAlign: 'center', + marginBottom: 20, + fontSize: 15, + }, + profileContainer: { + alignItems: 'center', + paddingVertical: 20, + }, + statusTitle: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + }, + statusDesc: { + fontSize: 15, + marginBottom: 10, + }, + button: { + width: '100%', + height: 48, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + color: 'white', + }, + disclaimer: { + fontSize: 12, + textAlign: 'center', + marginTop: 20, + }, +}); + +export default SimklSettingsScreen; diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 68c3c333..952fa1a2 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -23,6 +23,7 @@ import TraktIcon from '../../assets/rating-icons/trakt.svg'; import { useTheme } from '../contexts/ThemeContext'; import { useTraktIntegration } from '../hooks/useTraktIntegration'; import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings'; +import { useSimklIntegration } from '../hooks/useSimklIntegration'; import { colors } from '../styles'; import CustomAlert from '../components/CustomAlert'; import { useTranslation } from 'react-i18next'; @@ -67,6 +68,7 @@ const TraktSettingsScreen: React.FC = () => { isLoading: traktLoading, refreshAuthStatus } = useTraktIntegration(); + const { isAuthenticated: isSimklAuthenticated } = useSimklIntegration(); const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false); const [showThresholdModal, setShowThresholdModal] = useState(false); @@ -184,6 +186,10 @@ const TraktSettingsScreen: React.FC = () => { }, [response, checkAuthStatus, request?.codeVerifier, navigation]); const handleSignIn = () => { + if (isSimklAuthenticated) { + openAlert('Conflict', 'You cannot connect to Trakt while Simkl is connected. Please disconnect Simkl first.'); + return; + } promptAsync(); // Trigger the authentication flow }; diff --git a/src/services/simklService.ts b/src/services/simklService.ts new file mode 100644 index 00000000..ae016c5c --- /dev/null +++ b/src/services/simklService.ts @@ -0,0 +1,509 @@ +import { mmkvStorage } from './mmkvStorage'; +import { AppState, AppStateStatus } from 'react-native'; +import { logger } from '../utils/logger'; + +// Storage keys +export const SIMKL_ACCESS_TOKEN_KEY = 'simkl_access_token'; + +// Simkl API configuration +const SIMKL_API_URL = 'https://api.simkl.com'; +const SIMKL_CLIENT_ID = process.env.EXPO_PUBLIC_SIMKL_CLIENT_ID as string; +const SIMKL_CLIENT_SECRET = process.env.EXPO_PUBLIC_SIMKL_CLIENT_SECRET as string; +const SIMKL_REDIRECT_URI = process.env.EXPO_PUBLIC_SIMKL_REDIRECT_URI || 'nuvio://auth/simkl'; + +if (!SIMKL_CLIENT_ID || !SIMKL_CLIENT_SECRET) { + logger.warn('[SimklService] Missing Simkl env vars. Simkl integration will be disabled.'); +} + +// Types +export interface SimklUser { + user: { + name: string; + joined_at: string; + avatar: string; + } +} + +export interface SimklIds { + simkl?: number; + slug?: string; + imdb?: string; + tmdb?: number; + mal?: string; + tvdb?: string; + anidb?: string; +} + +export interface SimklContentData { + type: 'movie' | 'episode' | 'anime'; + title: string; + year?: number; + ids: SimklIds; + // For episodes + season?: number; + episode?: number; + showTitle?: string; + // For anime + animeType?: string; +} + +export interface SimklScrobbleResponse { + id: number; + action: 'start' | 'pause' | 'scrobble'; + progress: number; + movie?: any; + show?: any; + episode?: any; + anime?: any; +} + +export interface SimklPlaybackData { + id: number; + progress: number; + paused_at: string; + type: 'movie' | 'episode'; + movie?: { + title: string; + year: number; + ids: SimklIds; + }; + show?: { + title: string; + year: number; + ids: SimklIds; + }; + episode?: { + season: number; + episode: number; + title: string; + tvdb_season?: number; + tvdb_number?: number; + }; +} + +export class SimklService { + private static instance: SimklService; + private accessToken: string | null = null; + private isInitialized: boolean = false; + + // Rate limiting & Debouncing + private lastApiCall: number = 0; + private readonly MIN_API_INTERVAL = 500; + private requestQueue: Array<() => Promise> = []; + private isProcessingQueue: boolean = false; + + // Track scrobbled items to prevent duplicates/spam + private lastSyncTimes: Map = new Map(); + private readonly SYNC_DEBOUNCE_MS = 15000; // 15 seconds + + // Default completion threshold (can't be configured on Simkl side essentially, but we use it for logic) + private readonly COMPLETION_THRESHOLD = 80; + + private constructor() { + // Determine cleanup logic if needed + AppState.addEventListener('change', this.handleAppStateChange); + } + + public static getInstance(): SimklService { + if (!SimklService.instance) { + SimklService.instance = new SimklService(); + } + return SimklService.instance; + } + + private handleAppStateChange = (nextAppState: AppStateStatus) => { + // Potential cleanup or flush queue logic here + }; + + /** + * Initialize the Simkl service by loading stored token + */ + public async initialize(): Promise { + if (this.isInitialized) return; + + try { + const accessToken = await mmkvStorage.getItem(SIMKL_ACCESS_TOKEN_KEY); + this.accessToken = accessToken; + this.isInitialized = true; + logger.log('[SimklService] Initialized, authenticated:', !!this.accessToken); + } catch (error) { + logger.error('[SimklService] Initialization failed:', error); + throw error; + } + } + + /** + * Check if the user is authenticated + */ + public async isAuthenticated(): Promise { + await this.ensureInitialized(); + return !!this.accessToken; + } + + /** + * Get auth URL for OAuth + */ + public getAuthUrl(): string { + return `https://simkl.com/oauth/authorize?response_type=code&client_id=${SIMKL_CLIENT_ID}&redirect_uri=${encodeURIComponent(SIMKL_REDIRECT_URI)}`; + } + + /** + * Exchange code for access token + * Simkl tokens do not expire + */ + public async exchangeCodeForToken(code: string): Promise { + await this.ensureInitialized(); + + try { + const response = await fetch(`${SIMKL_API_URL}/oauth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + code, + client_id: SIMKL_CLIENT_ID, + client_secret: SIMKL_CLIENT_SECRET, + redirect_uri: SIMKL_REDIRECT_URI, + grant_type: 'authorization_code' + }) + }); + + if (!response.ok) { + const errorBody = await response.text(); + logger.error('[SimklService] Token exchange error:', errorBody); + return false; + } + + const data = await response.json(); + if (data.access_token) { + await this.saveToken(data.access_token); + return true; + } + return false; + } catch (error) { + logger.error('[SimklService] Failed to exchange code:', error); + return false; + } + } + + private async saveToken(accessToken: string): Promise { + this.accessToken = accessToken; + try { + await mmkvStorage.setItem(SIMKL_ACCESS_TOKEN_KEY, accessToken); + logger.log('[SimklService] Token saved successfully'); + } catch (error) { + logger.error('[SimklService] Failed to save token:', error); + throw error; + } + } + + public async logout(): Promise { + await this.ensureInitialized(); + this.accessToken = null; + await mmkvStorage.removeItem(SIMKL_ACCESS_TOKEN_KEY); + logger.log('[SimklService] Logged out'); + } + + private async ensureInitialized(): Promise { + if (!this.isInitialized) { + await this.initialize(); + } + } + + /** + * Base API Request handler + */ + private async apiRequest( + endpoint: string, + method: 'GET' | 'POST' | 'PUT' | 'DELETE' = 'GET', + body?: any + ): Promise { + await this.ensureInitialized(); + + // Rate limiting + const now = Date.now(); + const timeSinceLastCall = now - this.lastApiCall; + if (timeSinceLastCall < this.MIN_API_INTERVAL) { + await new Promise(resolve => setTimeout(resolve, this.MIN_API_INTERVAL - timeSinceLastCall)); + } + this.lastApiCall = Date.now(); + + if (!this.accessToken) { + logger.warn('[SimklService] Cannot make request: Not authenticated'); + return null; + } + + const headers: HeadersInit = { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${this.accessToken}`, + 'simkl-api-key': SIMKL_CLIENT_ID + }; + + const options: RequestInit = { + method, + headers + }; + + if (body) { + options.body = JSON.stringify(body); + } + + if (endpoint.includes('scrobble')) { + logger.log(`[SimklService] Requesting: ${method} ${endpoint}`, body); + } + + try { + const response = await fetch(`${SIMKL_API_URL}${endpoint}`, options); + + if (response.status === 409) { + // Conflict means already watched/scrobbled within last hour, which is strictly a success for our purposes + logger.log(`[SimklService] 409 Conflict (Already watched/active) for ${endpoint}`); + // We can return a mock success or null depending on what caller expects. + // For scrobble actions (which usually return an ID or object), we might return null or handle it. + // Simkl returns body with "watched_at" etc. + return null; + } + + if (!response.ok) { + const errorText = await response.text(); + logger.error(`[SimklService] API Error ${response.status} for ${endpoint}:`, errorText); + return null; // Return null on error + } + + // Handle 204 No Content + if (response.status === 204) { + return {} as T; + } + + return await response.json(); + } catch (error) { + logger.error(`[SimklService] Network request failed for ${endpoint}:`, error); + throw error; + } + } + + /** + * Build payload for Scrobbling + */ + private buildScrobblePayload(content: SimklContentData, progress: number): any { + // Simkl uses flexible progress but let's standardize + const cleanProgress = Math.max(0, Math.min(100, progress)); + + const payload: any = { + progress: cleanProgress + }; + + // IDs object setup (remove undefined/nulls) + const ids: any = {}; + if (content.ids.imdb) ids.imdb = content.ids.imdb; + if (content.ids.tmdb) ids.tmdb = content.ids.tmdb; + if (content.ids.simkl) ids.simkl = content.ids.simkl; + if (content.ids.mal) ids.mal = content.ids.mal; // for anime + + // Construct object based on type + if (content.type === 'movie') { + payload.movie = { + title: content.title, + year: content.year, + ids: ids + }; + } else if (content.type === 'episode') { + payload.show = { + title: content.showTitle || content.title, + year: content.year, + ids: { + // If we have show IMDB/TMDB use those, otherwise fallback (might be same if passed in ids) + // Ideally caller passes show-specific IDs in ids, but often we just have ids for the general item + imdb: content.ids.imdb, + tmdb: content.ids.tmdb, + simkl: content.ids.simkl + } + }; + payload.episode = { + season: content.season, + number: content.episode + }; + } else if (content.type === 'anime') { + payload.anime = { + title: content.title, + ids: ids + }; + // Anime also needs episode info if it's an episode + if (content.episode) { + payload.episode = { + season: content.season || 1, + number: content.episode + }; + } + } + + return payload; + } + + /** + * SCROBBLE: START + */ + public async scrobbleStart(content: SimklContentData, progress: number): Promise { + try { + const payload = this.buildScrobblePayload(content, progress); + logger.log('[SimklService] scrobbleStart payload:', JSON.stringify(payload)); + const response = await this.apiRequest('/scrobble/start', 'POST', payload); + logger.log('[SimklService] scrobbleStart response:', JSON.stringify(response)); + return response; + } catch (e) { + logger.error('[SimklService] Scrobble Start failed', e); + return null; + } + } + + /** + * SCROBBLE: PAUSE + */ + public async scrobblePause(content: SimklContentData, progress: number): Promise { + try { + // Debounce check + const key = this.getContentKey(content); + const now = Date.now(); + const lastSync = this.lastSyncTimes.get(key) || 0; + + if (now - lastSync < this.SYNC_DEBOUNCE_MS) { + return null; // Skip if too soon + } + this.lastSyncTimes.set(key, now); + + this.lastSyncTimes.set(key, now); + + const payload = this.buildScrobblePayload(content, progress); + logger.log('[SimklService] scrobblePause payload:', JSON.stringify(payload)); + const response = await this.apiRequest('/scrobble/pause', 'POST', payload); + logger.log('[SimklService] scrobblePause response:', JSON.stringify(response)); + return response; + } catch (e) { + logger.error('[SimklService] Scrobble Pause failed', e); + return null; + } + } + + /** + * SCROBBLE: STOP + */ + public async scrobbleStop(content: SimklContentData, progress: number): Promise { + try { + const payload = this.buildScrobblePayload(content, progress); + logger.log('[SimklService] scrobbleStop payload:', JSON.stringify(payload)); + // Simkl automatically marks as watched if progress >= 80% (or server logic) + // We just hit /scrobble/stop + const response = await this.apiRequest('/scrobble/stop', 'POST', payload); + logger.log('[SimklService] scrobbleStop response:', JSON.stringify(response)); + + // If response is null (often 409 Conflict) OR we failed, but progress is high, + // we should force "mark as watched" via history sync to be safe. + // 409 means "Action already active" or "Checkin active", often if 'pause' was just called. + // If the user finished (progress >= 80), we MUST ensure it's marked watched. + if (!response && progress >= this.COMPLETION_THRESHOLD) { + logger.log(`[SimklService] scrobbleStop failed/conflict at ${progress}%. Falling back to /sync/history to ensure watched status.`); + + try { + const historyPayload: any = {}; + + if (content.type === 'movie') { + historyPayload.movies = [{ + ids: content.ids + }]; + } else if (content.type === 'episode') { + historyPayload.shows = [{ + ids: content.ids, + seasons: [{ + number: content.season, + episodes: [{ number: content.episode }] + }] + }]; + } else if (content.type === 'anime') { + // Anime structure similar to shows usually, or 'anime' key? + // Simkl API often uses 'shows' for anime too if listed as show, or 'anime' key. + // Safest is to try 'shows' if we have standard IDs, or 'anime' if specifically anime. + // Let's use 'anime' key if type is anime, assuming similar structure. + historyPayload.anime = [{ + ids: content.ids, + episodes: [{ + season: content.season || 1, + number: content.episode + }] + }]; + } + + if (Object.keys(historyPayload).length > 0) { + const historyResponse = await this.addToHistory(historyPayload); + logger.log('[SimklService] Fallback history sync response:', JSON.stringify(historyResponse)); + if (historyResponse) { + // Construct a fake scrobble response to satisfy caller + return { + id: 0, + action: 'scrobble', + progress: progress, + ...payload + } as SimklScrobbleResponse; + } + } + } catch (err) { + logger.error('[SimklService] Fallback history sync failed:', err); + } + } + + return response; + } catch (e) { + logger.error('[SimklService] Scrobble Stop failed', e); + return null; + } + } + + private getContentKey(content: SimklContentData): string { + return `${content.type}:${content.ids.imdb || content.ids.tmdb || content.title}:${content.season}:${content.episode}`; + } + + /** + * SYNC: Get Playback Sessions (Continue Watching) + */ + /** + * SYNC: Add items to History (Global "Mark as Watched") + */ + public async addToHistory(items: { movies?: any[], shows?: any[], episodes?: any[] }): Promise { + return await this.apiRequest('/sync/history', 'POST', items); + } + + /** + * SYNC: Remove items from History + */ + public async removeFromHistory(items: { movies?: any[], shows?: any[], episodes?: any[] }): Promise { + return await this.apiRequest('/sync/history/remove', 'POST', items); + } + + public async getPlaybackStatus(): Promise { + // Get both movies and episodes + // Simkl endpoint: /sync/playback (returns all if no type specified, or we specify type) + // Docs say /sync/playback/{type} + // Let's trying getting all if possible, or fetch both. Docs say type is optional param? + // Docs: /sync/playback/{type} -> actually path param seems required or at least standard. + // But query params: type (optional). + // Let's try fetching without path param or empty? + // Docs: "Retrieves all paused... optionally filter by type by appending /movie" + // Let's assume /sync/playback works for all. + + const response = await this.apiRequest('/sync/playback'); + return response || []; + } + + /** + * SYNC: Get Full Watch History (summary) + * Optimization: Check /sync/activities first in real usage. + * For now, we implement simple fetch. + */ + public async getAllItems(dateFrom?: string): Promise { + let url = '/sync/all-items/'; + if (dateFrom) { + url += `?date_from=${dateFrom}`; + } + return await this.apiRequest(url); + } +} diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 70946f2a..bd3b1484 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -9,6 +9,9 @@ interface WatchProgress { traktSynced?: boolean; traktLastSynced?: number; traktProgress?: number; + simklSynced?: boolean; + simklLastSynced?: number; + simklProgress?: number; } class StorageService { @@ -463,6 +466,46 @@ class StorageService { } } + /** + * Update Simkl sync status for a watch progress entry + */ + public async updateSimklSyncStatus( + id: string, + type: string, + simklSynced: boolean, + simklProgress?: number, + episodeId?: string, + exactTime?: number + ): Promise { + try { + const existingProgress = await this.getWatchProgress(id, type, episodeId); + if (existingProgress) { + // Preserve the highest Simkl progress and currentTime values + const highestSimklProgress = (() => { + if (simklProgress === undefined) return existingProgress.simklProgress; + if (existingProgress.simklProgress === undefined) return simklProgress; + return Math.max(simklProgress, existingProgress.simklProgress); + })(); + + const highestCurrentTime = (() => { + if (!exactTime || exactTime <= 0) return existingProgress.currentTime; + return Math.max(exactTime, existingProgress.currentTime); + })(); + + const updatedProgress: WatchProgress = { + ...existingProgress, + simklSynced, + simklLastSynced: simklSynced ? Date.now() : existingProgress.simklLastSynced, + simklProgress: highestSimklProgress, + currentTime: highestCurrentTime, + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + } + } catch (error) { + logger.error('Error updating Simkl sync status:', error); + } + } + /** * Get all watch progress entries that need Trakt sync */ @@ -495,8 +538,8 @@ class StorageService { continue; } // Check if needs sync (either never synced or local progress is newer) - const needsSync = !progress.traktSynced || - (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced); + const needsSync = (!progress.traktSynced || (progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced)) || + (!progress.simklSynced || (progress.simklLastSynced && progress.lastUpdated > progress.simklLastSynced)); if (needsSync) { const parts = key.split(':'); @@ -611,6 +654,7 @@ class StorageService { duration, lastUpdated: traktTimestamp, traktSynced: true, + simklSynced: false, traktLastSynced: Date.now(), traktProgress }; @@ -687,6 +731,105 @@ class StorageService { } } + /** + * Merge Simkl progress with local progress using exact time when available + */ + public async mergeWithSimklProgress( + id: string, + type: string, + simklProgress: number, + simklPausedAt: string, + episodeId?: string, + exactTime?: number + ): Promise { + try { + const localProgress = await this.getWatchProgress(id, type, episodeId); + const simklTimestamp = new Date(simklPausedAt).getTime(); + + if (!localProgress) { + let duration = await this.getContentDuration(id, type, episodeId); + let currentTime: number; + + if (exactTime && exactTime > 0) { + currentTime = exactTime; + if (!duration) { + duration = (exactTime / simklProgress) * 100; + } + } else { + if (!duration) { + if (type === 'movie') { + duration = 6600; + } else if (episodeId) { + duration = 2700; + } else { + duration = 3600; + } + } + currentTime = (simklProgress / 100) * duration; + } + + const newProgress: WatchProgress = { + currentTime, + duration, + lastUpdated: simklTimestamp, + simklSynced: true, + simklLastSynced: Date.now(), + simklProgress + }; + await this.setWatchProgress(id, type, newProgress, episodeId); + } else { + const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100; + const progressDiff = Math.abs(simklProgress - localProgressPercent); + + if (progressDiff < 5 && simklProgress < 100 && localProgressPercent < 100) { + return; + } + + let currentTime: number; + let duration = localProgress.duration; + + if (exactTime && exactTime > 0 && localProgress.duration > 0) { + currentTime = exactTime; + const calculatedDuration = (exactTime / simklProgress) * 100; + if (Math.abs(calculatedDuration - localProgress.duration) > 300) { + duration = calculatedDuration; + } + } else if (localProgress.duration > 0) { + currentTime = (simklProgress / 100) * localProgress.duration; + } else { + const storedDuration = await this.getContentDuration(id, type, episodeId); + duration = storedDuration || 0; + if (!duration || duration <= 0) { + if (exactTime && exactTime > 0) { + duration = (exactTime / simklProgress) * 100; + currentTime = exactTime; + } else { + if (type === 'movie') duration = 6600; + else if (episodeId) duration = 2700; + else duration = 3600; + currentTime = (simklProgress / 100) * duration; + } + } else { + currentTime = exactTime && exactTime > 0 ? exactTime : (simklProgress / 100) * duration; + } + } + + const updatedProgress: WatchProgress = { + ...localProgress, + currentTime, + duration, + lastUpdated: simklTimestamp, + simklSynced: true, + simklLastSynced: Date.now(), + simklProgress + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + } + } catch (error) { + logger.error('Error merging with Simkl progress:', error); + } + } + public async saveSubtitleSettings(settings: Record): Promise { try { const key = await this.getSubtitleSettingsKeyScoped(); diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 4bfca17d..50218aeb 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -1,4 +1,5 @@ import { TraktService } from './traktService'; +import { SimklService } from './simklService'; import { storageService } from './storageService'; import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; @@ -13,9 +14,11 @@ import { logger } from '../utils/logger'; class WatchedService { private static instance: WatchedService; private traktService: TraktService; + private simklService: SimklService; private constructor() { this.traktService = TraktService.getInstance(); + this.simklService = SimklService.getInstance(); } public static getInstance(): WatchedService { @@ -47,6 +50,13 @@ class WatchedService { logger.log(`[WatchedService] Trakt sync result for movie: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + await this.simklService.addToHistory({ movies: [{ ids: { imdb: imdbId }, watched_at: watchedAt.toISOString() }] }); + logger.log(`[WatchedService] Simkl sync request sent for movie`); + } + // Also store locally as "completed" (100% progress) await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt); @@ -90,6 +100,22 @@ class WatchedService { logger.log(`[WatchedService] Trakt sync result for episode: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + // Simkl structure: shows -> seasons -> episodes + await this.simklService.addToHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: [{ + number: season, + episodes: [{ number: episode, watched_at: watchedAt.toISOString() }] + }] + }] + }); + logger.log(`[WatchedService] Simkl sync request sent for episode`); + } + // Store locally as "completed" const episodeId = `${showId}:${season}:${episode}`; await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); @@ -135,6 +161,27 @@ class WatchedService { logger.log(`[WatchedService] Trakt batch sync result: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + // Group by season for Simkl payload efficiency + const seasonMap = new Map(); + episodes.forEach(ep => { + if (!seasonMap.has(ep.season)) seasonMap.set(ep.season, []); + seasonMap.get(ep.season)?.push({ number: ep.episode, watched_at: watchedAt.toISOString() }); + }); + + const seasons = Array.from(seasonMap.entries()).map(([num, eps]) => ({ number: num, episodes: eps })); + + await this.simklService.addToHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: seasons + }] + }); + logger.log(`[WatchedService] Simkl batch sync request sent`); + } + // Store locally as "completed" for each episode for (const ep of episodes) { const episodeId = `${showId}:${ep.season}:${ep.episode}`; @@ -180,6 +227,24 @@ class WatchedService { logger.log(`[WatchedService] Trakt season sync result: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + // Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it + // We know the episodeNumbers from the arguments! + const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() })); + await this.simklService.addToHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: [{ + number: season, + episodes: episodes + }] + }] + }); + logger.log(`[WatchedService] Simkl season sync request sent`); + } + // Store locally as "completed" for each episode in the season for (const epNum of episodeNumbers) { const episodeId = `${showId}:${season}:${epNum}`; @@ -210,6 +275,13 @@ class WatchedService { logger.log(`[WatchedService] Trakt remove result for movie: ${syncedToTrakt}`); } + // Simkl Unmark + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + await this.simklService.removeFromHistory({ movies: [{ ids: { imdb: imdbId } }] }); + logger.log(`[WatchedService] Simkl remove request sent for movie`); + } + // Remove local progress await storageService.removeWatchProgress(imdbId, 'movie'); await mmkvStorage.removeItem(`watched:movie:${imdbId}`); @@ -245,6 +317,21 @@ class WatchedService { logger.log(`[WatchedService] Trakt remove result for episode: ${syncedToTrakt}`); } + // Simkl Unmark + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + await this.simklService.removeFromHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: [{ + number: season, + episodes: [{ number: episode }] + }] + }] + }); + logger.log(`[WatchedService] Simkl remove request sent for episode`); + } + // Remove local progress const episodeId = `${showId}:${season}:${episode}`; await storageService.removeWatchProgress(showId, 'series', episodeId); @@ -281,9 +368,29 @@ class WatchedService { showImdbId, season ); + syncedToTrakt = await this.traktService.removeSeasonFromHistory( + showImdbId, + season + ); logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); } + // Sync to Simkl + const isSimklAuth = await this.simklService.isAuthenticated(); + if (isSimklAuth) { + const episodes = episodeNumbers.map(num => ({ number: num })); + await this.simklService.removeFromHistory({ + shows: [{ + ids: { imdb: showImdbId }, + seasons: [{ + number: season, + episodes: episodes + }] + }] + }); + logger.log(`[WatchedService] Simkl season removal request sent`); + } + // Remove local progress for each episode in the season for (const epNum of episodeNumbers) { const episodeId = `${showId}:${season}:${epNum}`; @@ -301,60 +408,60 @@ class WatchedService { * Check if a movie is marked as watched (locally) */ public async isMovieWatched(imdbId: string): Promise { - try { - const isAuthed = await this.traktService.isAuthenticated(); + try { + const isAuthed = await this.traktService.isAuthenticated(); - if (isAuthed) { - const traktWatched = - await this.traktService.isMovieWatchedAccurate(imdbId); - if (traktWatched) return true; + if (isAuthed) { + const traktWatched = + await this.traktService.isMovieWatchedAccurate(imdbId); + if (traktWatched) return true; + } + + const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`); + return local === 'true'; + } catch { + return false; } - - const local = await mmkvStorage.getItem(`watched:movie:${imdbId}`); - return local === 'true'; - } catch { - return false; - } } - + /** * Check if an episode is marked as watched (locally) */ public async isEpisodeWatched( - showId: string, - season: number, - episode: number + showId: string, + season: number, + episode: number ): Promise { - try { - const isAuthed = await this.traktService.isAuthenticated(); + try { + const isAuthed = await this.traktService.isAuthenticated(); - if (isAuthed) { - const traktWatched = - await this.traktService.isEpisodeWatchedAccurate( - showId, - season, - episode + if (isAuthed) { + const traktWatched = + await this.traktService.isEpisodeWatchedAccurate( + showId, + season, + episode + ); + if (traktWatched) return true; + } + + const episodeId = `${showId}:${season}:${episode}`; + const progress = await storageService.getWatchProgress( + showId, + 'series', + episodeId ); - if (traktWatched) return true; + + if (!progress) return false; + + const pct = (progress.currentTime / progress.duration) * 100; + return pct >= 99; + } catch { + return false; } - - const episodeId = `${showId}:${season}:${episode}`; - const progress = await storageService.getWatchProgress( - showId, - 'series', - episodeId - ); - - if (!progress) return false; - - const pct = (progress.currentTime / progress.duration) * 100; - return pct >= 99; - } catch { - return false; - } } - + /** * Set local watched status by creating a "completed" progress entry */ From ea2debb9dd106de02ce4d60cf27c3f3e3b46f320 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:15:02 +0530 Subject: [PATCH 04/10] ui changes --- assets/simkl-favicon.png | Bin 0 -> 5328 bytes assets/simkl-logo.png | Bin 0 -> 2767 bytes assets/trakt-favicon.png | 0 .../home/ContinueWatchingSection.tsx | 232 +++++++++++++++++- src/components/icons/SimklIcon.tsx | 16 +- src/components/icons/TraktIcon.tsx | 9 +- src/hooks/useSimklIntegration.ts | 31 ++- src/screens/SimklSettingsScreen.tsx | 160 +++++++++++- src/screens/TraktSettingsScreen.tsx | 72 +++++- src/services/simklService.ts | 125 +++++++++- src/services/traktService.ts | 23 ++ 11 files changed, 623 insertions(+), 45 deletions(-) create mode 100644 assets/simkl-favicon.png create mode 100644 assets/simkl-logo.png create mode 100644 assets/trakt-favicon.png diff --git a/assets/simkl-favicon.png b/assets/simkl-favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..c8454b3e64becd6865370543e628a2ab08acd93d GIT binary patch literal 5328 zcmd5=c|4TS*PpBts!_Hi29qTtlO<~*W0@Et(j+8?WXV3GNSFxOMg|j-Wn^Tpv6IG< zh)NoaB~96v5E|?I^!xAq<9+}BooBh9=icW&=lPs_&-Z-KiMwoRdWcV)4+H`oLYX0L zfcEXb3(NuB$6B^80}YplA=(fGs!HbHap4A@p{`~&Xb|Y090>G~00M0RT@PnKpkM?D zG7G>p=b{moMD8Nvav@C zzDLiM6q)(G92tusj-4_ef(btn)xGIG{QVe)xAX5~*gUDygq)kzlDu0`R8{G1{OSDK zy~2quJC>r<^32}M`ucdSwF1!;^j7py`xpq?cpwsF|Ipx}k^%IsFck8g4+8e%26NSM zaBjq-q1jO_4;*Q9`tA&;i9x5L@UYlcnrleK0iA=vT^cxn;=?K0BwiGB*`CT zxw2%RC;eGp#|7hXAt52oEqYp7u=xD)`81L3QhrSi_C~l1#ghobT(_wXStNgaraAFC z1nq-HJ2^?x5p~a|&XUW!I=i;Fw)8LHmgpov)I?Pr#igWbeVF@lY-{L%`tcY{RT!Id zbD}n!No&~BNy7STo;DhyDaZd{uZbSk)Re+c=g?>yNSR5G#GO+qf#2%AF$0X2t-sB> zIALMoAHIT?NNc^&)vqef5#jrhoCO8e5qEydM#6jeV(Aep)5+niZuwH`X@AWwm6?VI zKYo_jZ!8Q)eBY!}iG{68O_$*+-+eg^Aq3CsgLdiqJ8hfK@2$o)>9uMt@+!nr%gYr)@%s`hDo`En*TR%(^5l^vV{tKV+pU0q$R)Ft>#XHi1ux7K?ylO`(YWAqRaMnBxLo`SQbrm@g!jZ#0r6_^ zor!VDqm=0ene&vf->(28{{l9swt@?8D@%b|iOIpP1 z73nSIXgH4y1SLbJM7Q0vv-@?jc4BYn`T2&r7t$v+V(2a;EEXe&NPLVZtqX~Y7PX@vPf>{9=$bi>lmn;_j(bPYm>z zl8lr|eLUT{|5hL=N^uw zCnhFXG$sMA6YZggd+AX$b`X>}|&(kquz&%15KpZn+$lQuTX zz9Sj)f>GfhD!{X~#Y^liT+90!_u?7ZA#-bUNON}-M3?TZe&zO#l^_^9I{Lmg_raEx z6#IjzM}G(ocSPXugRR-(&oh|{@o`8QMO1wLOJHA;eZ?ZaRw~{|_o0R`6twgc+XD}) zOC}VR_z_{HrKQx;Qk&r8F)!vDsCTGT0HGAB;Y!tT^J;icpU{!%M!gkLVE4w)?by62 zof&C|K|Ew0yJ!uQuVh(_zb;34=e!HHaaC*O*W*eyC@3h%D&d#@NvPrBi0o&YdGRS4 z198T}mBWz{Y{UpQMc7>t?uhfS6T+mrVMv9Az)pCYZE)!lurzEn3<+yzZHqQXYe~yl z5v$*y*2`Ftm6f&JIK8wqk@7V%)#VB(1m4)?!GOQ3|DKbD{43_P4S}kGOX^qd(t%`g@>6lP3~#kxVIb zY;^xPH(1yB{A3CYV{2O%IzTCNSbhzk-(mApsax&Rda@}lV#dbCx*-d{yRW|I100Xd z#*OY3jf{}LeWT3`WlA0P7?N2Ea%{yh%72IHByVuV=aWfi9GmY9&XP!}se%iKCk4UM zu=pPzHOIcz;m-oFst81P0D154?;go_b;XCohKQjQon0zsrctX?m-J2m%tabSA(9qwwH2LWggE%)E#`+?Y#tu|Y+LuNJe9qr8 zihUMqV&aEouzKT6z`-HG8gQy}gWJLKb8fn+X2^lp(YzD%K~OJ;CwZe=ep=IK$-9x9WKt#Z9w9#{OE57p(a~)@cX#}08m%d*9t|S~?!9|7Q0!27;|nWd zm=8tF`H#%|)}PX3Y@g~6lq95-&)=UIiN&5#c@UY6_M80CK~nP&7Z z(t1B|SuE-P#TLk8D41~B%Bo*?SyCNc(^7s$><@lrXVe7p*C_Uga}!>1&Gc+57zyD* z5%U!!eJ=$3nX#rL65{#jlZYoKHIbCrq~tzH-H2b6ei=oqHqoa+aBy^`N4juwuRFFtQ z1_a2~*Vm_JXPa4CY6%JOa2#-g;FDZ2B$jqAD>QdHG-(>|%R$0wld#8QT*OA&vi@c} zt_@X~`}_MV2z?0KONrpVG}(I>*Yb>_*vUF;y)Zv7TV8wF?_TyCl5`ra+oN`ki2Y&;d6@aO?`Y*Ncym zd(UT`9@a?xG>{=kfoIQ2p{@TEK7`W|eOgnu-&c*c*B-1ABTFj+c24>g)n?%9>kIsv zv2my!7K=imw#F@mz~ISjzo|7;wN+k!kAN4-qtp5mLI8XHC>vhgEl&-Mu`-14@$-|^ z&(s5Ksxf5z%8eTfp_hG;n!a>wG39v+N6PF|<|!4vv> zpA&k>+8##{qf$r<8(D1#4(;OC zPNdt+2A99ugV@vRXesT0!Z&3;te8WRA_or*HxGK70wp9|dz-^x><<~NNpRh4_b zsB5f+x2^Tr_V?U)698Brd-X;)lXg;o=fsH<&C1`@(U_~M6DI{Yiexe2ZBcWMn+v}~ zZJML881oD`=G*J9%v*=(DSl1LT+s%zU;qGi*BPxXOC$$?Y}S}iJb5@HE5g@Tn)H&3 zT}r5AGpmio-kT6J(yyR}$w&O63!%T{ll6A|VDeqr0+(~nA~X^U?^qbiipLld-NGHm z`1|+iOT1FQn@mu|qjAV7Q>Z|)Di9OXa|s_$B_>uZu|0dTz8S?HeuE48b3~y1)>#zs zjUwvh6)gEeiR;$p@ppN}7u=dR7W=@~LLI6WP_g$PKPoFJIoLY4y06a#&-<2?Q!jP( z{>oI4BZ?h%5#u`AQRgD&$@lAf=mUD$yyj?w6C+sO*`5fK-^L88J2EWj>dJwaqAJuZ z3NOg>9A?s#y)Nd>T4Wi7tOo{cLA1|CB)StGrW^w(gZ7Kdh%wPP`|=y9FwZ4^0wlhp zQ`inY@%1!sjEhXFnT}K6oYfXDk8QBzjZRrYXQyj7QfAFDhZ%$mb`A9x6B84cL)QoKdP$`K$!@;8s2jYc-dP#q%{G9cLU7J!;P58) z)HA=}3(VP;FgCOQ0%Kw#c_s0<7AHl3etUW5T0@YhqmMEWK2)mV`ujVZ8&X;iHys5t z1M^uc$et|HubvEhdpnm=K1{5OSj7T+SV{l>VD+oet!iOjA`-B+cF4}(caQkz?|9XC zVY$x)Iqug+>?cR;wH;A=ZhG$I&ha=H%B0|P^zum=@5ZVsLGz0SB7zBV`g@EFr=1L` zQ~nlG&kjF`1V(^1VIdJxdivF;_O@4_W~8SJi;9X0{WO(Hy{sSv;Yymu z#9%YycE&Ms0hA*i2EihmnUs?GhVpqT^+UYOsUp0LWJ28Ms+n}90$Dme+K6ssWJIUa z?M+OLt%JTbZ0kj7@pF+sXe2ack7~)w0f7T3SiE!7TSkVd=nIN|EV_8Ff`YW_{9}`r z`s%igRuH<#@?&3h;@5kJJ#tcNKp!#eqWK02pwWv=p;FgMfDGzoeE$3RR|@fKFJAs9 z;hJq&dYW?G_$kL`XRa_t?o(o>O9^AJ+;V{!H&MJ)INW~Q(pmK!X3)F9;Yw+jJI3`Y z!qn!vgh%OvH@|*@}F_# zKPG$3JWYu67!>(Xo$kgdTVo6D8T$IZy|-PWoww(qS>B~HpgB>&pw){S>3|9l%GeTF Je(`41{{She^M(Kb literal 0 HcmV?d00001 diff --git a/assets/simkl-logo.png b/assets/simkl-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..6836cfc033316e546c360a9c601067066e9c075e GIT binary patch literal 2767 zcmZ9Odo&a7AIHbq7xN{$3SlIyw!$A8KlBZfa~oqtVP+<```}CMK4}Vo4p43Jwmz zrQq7z+w1D;jEsz0T3UIteOdm>W^-m{W_TTWhtJM384L!K$z(0DmY2UwOiXIbyb&C9M@$`O;) zH1soZ06^%7t)&^tqdTrJ=pw;J3?~2)gP2La7M_FX<-7N{bG(C#!S-6-=5&%Z2uu9KN+p=EeX&0!8Sfa}v1 zYhx>bxK=3FroRe4CsyU4H||)a<14Ld>C%$p=W*hS zD(<$FhWCVJK6tbU0;*LKJ4)2ordBa^9USvFO{1T-8+ncm;Jo?7>k6(GYHevVsy(3I z0x~^5IFCs+B@ar?thBOY<2QbhV9&^&8d9TG>$;J;ueUcK?=E8VF(G}t}GW@9cr|M-my zxVT6?M>JRoi(VQ?nsrPRPi&4^7-$-i&$@kN~rn{CSxpos@_wC#sm| z!rz5&?pD)DM79?^KzBq{0UpxW!q!?354nDrHlkXgrzZv1)6?r>gWB2z^gvK#QF1_3 z|Az$c+JPHWv@9W+oH+V=A3600t}{OeoImraB*UO&5vy=SMO8rwT16?C(UPAheVzPn zfH!VYo-WZ}yT51Hj4dvbC`)X1j@3;>t8yNS)Kkntf~>w9nD#aVxOfxS%jg;D5%(<= z6-yE{Yd&rOeaT(()Q2RpWh>5Gs|Fs#}L_^)iV(Fq*pcL zln!#=CF>;8ldev=wfvHjgX7~;fL80gerZgkXY~rPl7=}5QjSnt4IxID%#K(0#kN+M zyJYl2gqqUB3@y3M^?}~jqBaGcxb=%A`5HpXW5}y0h%qUB=4(H8b%W61d_7jN(Df&` z+kfP0(&JZ9fm{v$)eoBQIkG*-5!e-mpvX-?y@H)%pM#$NkqGXIqB&CFT{P+C)FdQ( z(9=Vldr9W)3rmS!qS(u4M|SyAxT`WZ1>(UK3AJ%ffKd>l*+8~I`#}(9X6?nm=-nH6 zcP-uU1{I6NyIYVH*7+2xDWn@Q&WS&AYc{Js)ln1GV8Q?FpsbaK`TJZ}U!=%3_813m zg+xu1?hiCGN_h+J6F?B~k~&Yp-6wL*AoJ0_?^H$Mx!v9UwSkln5_WOD3F$>6e_@>prR5<9u$wk+b$WjhY%g%JLaHW%Jf~#1Uy_i}{dI+2HQVG(&3= z@o_D-{t8t0XM=;U9B{`O*xBxqvV)j=x<9pc;r}5vgS~6~Csy^kg$zLt}}hv--)m?A&fK2I@&hDZ)HF+pllI90VU=lwPxj6Qt%d(^A0FX=$}(s7BA;OGW|r zmX80qW02`nzWLewpTZI*$1KUTChu0QaJ@y)316i0d<-DO1qeb35n zG5Qt%9`;G`B_2N~AsaTzxKu%&x49lvW`_)C>XFvhh7DFT1))anN)5#|U4Fe%BG$K= zg+$mC$RaLgt{Tb~`@lM05X%lT ztf67{5S6~q;Uf&7n*;8)X0)j$Y7zbtcd7Fnwd4M$YJtEzNlRXMPscFrE2O}oKKf0= z73-6fF-EZuh4mB>K64c09*!n^Xp&WB-=wyf?xZzLm-tOYlqLePafBa%jajF1+*!?S@tMOkT{_i#|H8@r|sE ze*4k8lH5m}316`v!%I*YmvMCz@bwU(t9cB${v&h9ll^8vXPUBkder2+X$W}bhPVk@ zSXbfHg;=tOJfhrf)xy)l(bMG+$Egkvf$m6yi=XGRfmE=l`U}IvfV?NPQp(3W-k#e^ zLM1C&l4maHFsec*fNd#N;mph_9A4?QFu(5m9Q+c#a;On-_Q%ceETQLxUshF>Yl}oQ z4H2U92z%qp(D=^cpPGtka)14>a@g zK*@XXj*+$GY=3XMh>#6{}`#Fbs|D?8RFN< zfo8_E(DMs>7M-<-pko?eLGEg2?$peAKQaY9wR4%I%{jKgekaA|e=l-6df5h$5L5A1 zw__!=M^WeFQ?}D>x0qHc{({9l8yH+%p9 literal 0 HcmV?d00001 diff --git a/assets/trakt-favicon.png b/assets/trakt-favicon.png new file mode 100644 index 00000000..e69de29b diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index ff0f2996..30e41a5a 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -28,6 +28,7 @@ import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import * as Haptics from 'expo-haptics'; import { TraktService } from '../../services/traktService'; +import { SimklService } from '../../services/simklService'; import { stremioService } from '../../services/stremioService'; import { streamCacheService } from '../../services/streamCacheService'; import { useSettings } from '../../hooks/useSettings'; @@ -221,6 +222,10 @@ const ContinueWatchingSection = React.forwardRef((props, re const lastTraktSyncRef = useRef(0); const TRAKT_SYNC_COOLDOWN = 0; // disabled (always fetch Trakt playback) + // Track last Simkl sync to prevent excessive API calls + const lastSimklSyncRef = useRef(0); + const SIMKL_SYNC_COOLDOWN = 0; // disabled (always fetch Simkl playback) + // Track last Trakt reconcile per item (local -> Trakt catch-up) const lastTraktReconcileRef = useRef>(new Map()); const TRAKT_RECONCILE_COOLDOWN = 0; // 2 minutes between reconcile attempts per item @@ -471,13 +476,19 @@ const ContinueWatchingSection = React.forwardRef((props, re const traktService = TraktService.getInstance(); const isTraktAuthed = await traktService.isAuthenticated(); + const simklService = SimklService.getInstance(); + // Prefer Trakt if both are authenticated + const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false; + + logger.log(`[CW] Providers authed: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`); + // Declare groupPromises outside the if block let groupPromises: Promise[] = []; // In Trakt mode, CW is sourced from Trakt only, but we still want to overlay local progress // when local is ahead (scrobble lag/offline playback). let localProgressIndex: Map | null = null; - if (isTraktAuthed) { + if (isTraktAuthed || isSimklAuthed) { try { const allProgress = await storageService.getAllWatchProgress(); const index = new Map(); @@ -519,8 +530,8 @@ const ContinueWatchingSection = React.forwardRef((props, re } } - // Non-Trakt: use local storage - if (!isTraktAuthed) { + // Local-only mode (no Trakt, no Simkl): use local storage + if (!isTraktAuthed && !isSimklAuthed) { const allProgress = await storageService.getAllWatchProgress(); if (Object.keys(allProgress).length === 0) { setContinueWatchingItems([]); @@ -1300,8 +1311,219 @@ const ContinueWatchingSection = React.forwardRef((props, re } })(); - // Wait for all groups and trakt merge to settle, then finalize loading state - await Promise.allSettled([...groupPromises, traktMergePromise]); + // SIMKL: fetch playback progress (in-progress, paused) and merge similarly to Trakt + const simklMergePromise = (async () => { + try { + if (!isSimklAuthed || isTraktAuthed) return; + + const now = Date.now(); + if (SIMKL_SYNC_COOLDOWN > 0 && (now - lastSimklSyncRef.current) < SIMKL_SYNC_COOLDOWN) { + return; + } + lastSimklSyncRef.current = now; + + const playbackItems = await simklService.getPlaybackStatus(); + logger.log(`[CW][Simkl] playback items: ${playbackItems.length}`); + + const simklBatch: ContinueWatchingItem[] = []; + const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000); + + const sortedPlaybackItems = [...playbackItems] + .sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()) + .slice(0, 30); + + for (const item of sortedPlaybackItems) { + try { + // Skip accidental clicks + if ((item.progress ?? 0) < 2) continue; + + const pausedAt = new Date(item.paused_at).getTime(); + if (pausedAt < thirtyDaysAgo) continue; + + if (item.type === 'movie' && item.movie?.ids?.imdb) { + // Skip completed movies + if (item.progress >= 85) continue; + + const imdbId = item.movie.ids.imdb.startsWith('tt') + ? item.movie.ids.imdb + : `tt${item.movie.ids.imdb}`; + + const movieKey = `movie:${imdbId}`; + if (recentlyRemovedRef.current.has(movieKey)) continue; + + const cachedData = await getCachedMetadata('movie', imdbId); + if (!cachedData?.basicContent) continue; + + simklBatch.push({ + ...cachedData.basicContent, + id: imdbId, + type: 'movie', + progress: item.progress, + lastUpdated: pausedAt, + addonId: undefined, + } as ContinueWatchingItem); + } else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) { + const showImdb = item.show.ids.imdb.startsWith('tt') + ? item.show.ids.imdb + : `tt${item.show.ids.imdb}`; + + const episodeNum = (item.episode as any).episode ?? (item.episode as any).number; + if (episodeNum === undefined || episodeNum === null) { + logger.warn('[CW][Simkl] Missing episode number in playback item, skipping', item); + continue; + } + + const showKey = `series:${showImdb}`; + if (recentlyRemovedRef.current.has(showKey)) continue; + + const cachedData = await getCachedMetadata('series', showImdb); + if (!cachedData?.basicContent) continue; + + // If episode is completed (>= 85%), find next episode + if (item.progress >= 85) { + const metadata = cachedData.metadata; + if (metadata?.videos) { + const nextEpisode = findNextEpisode( + item.episode.season, + episodeNum, + metadata.videos, + undefined, + showImdb + ); + + if (nextEpisode) { + simklBatch.push({ + ...cachedData.basicContent, + id: showImdb, + type: 'series', + progress: 0, + lastUpdated: pausedAt, + season: nextEpisode.season, + episode: nextEpisode.episode, + episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`, + addonId: undefined, + } as ContinueWatchingItem); + } + } + continue; + } + + simklBatch.push({ + ...cachedData.basicContent, + id: showImdb, + type: 'series', + progress: item.progress, + lastUpdated: pausedAt, + season: item.episode.season, + episode: episodeNum, + episodeTitle: item.episode.title || `Episode ${episodeNum}`, + addonId: undefined, + } as ContinueWatchingItem); + } + } catch { + // Continue with other items + } + } + + if (simklBatch.length === 0) { + setContinueWatchingItems([]); + return; + } + + // Dedupe (keep most recent per show/movie) + const deduped = new Map(); + for (const item of simklBatch) { + const key = `${item.type}:${item.id}`; + const existing = deduped.get(key); + if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) { + deduped.set(key, item); + } + } + + // Filter removed items + const filteredItems: ContinueWatchingItem[] = []; + for (const item of deduped.values()) { + const key = item.type === 'series' && item.season && item.episode + ? `${item.type}:${item.id}:${item.season}:${item.episode}` + : `${item.type}:${item.id}`; + if (recentlyRemovedRef.current.has(key)) continue; + + const removeId = item.type === 'series' && item.season && item.episode + ? `${item.id}:${item.season}:${item.episode}` + : item.id; + const isRemoved = await storageService.isContinueWatchingRemoved(removeId, item.type); + if (!isRemoved) filteredItems.push(item); + } + + // Overlay local progress when local is ahead or newer + const adjustedItems = filteredItems.map((it) => { + if (!localProgressIndex) return it; + + const matches: LocalProgressEntry[] = []; + for (const idVariant of getIdVariants(it.id)) { + const list = localProgressIndex.get(`${it.type}:${idVariant}`); + if (!list) continue; + for (const entry of list) { + if (it.type === 'series' && it.season !== undefined && it.episode !== undefined) { + if (entry.season === it.season && entry.episode === it.episode) { + matches.push(entry); + } + } else { + matches.push(entry); + } + } + } + + if (matches.length === 0) return it; + + const mostRecentLocal = matches.reduce((acc, cur) => { + if (!acc) return cur; + return (cur.lastUpdated ?? 0) > (acc.lastUpdated ?? 0) ? cur : acc; + }, null); + + const highestLocal = matches.reduce((acc, cur) => { + if (!acc) return cur; + return (cur.progressPercent ?? 0) > (acc.progressPercent ?? 0) ? cur : acc; + }, null); + + if (!mostRecentLocal || !highestLocal) return it; + + const localProgress = mostRecentLocal.progressPercent; + const simklProgress = it.progress ?? 0; + const localTs = mostRecentLocal.lastUpdated ?? 0; + const simklTs = it.lastUpdated ?? 0; + + const isAhead = isFinite(localProgress) && localProgress > simklProgress + 0.5; + const isLocalNewer = localTs > simklTs + 5000; + + if (isAhead || isLocalNewer) { + return { + ...it, + progress: localProgress, + lastUpdated: localTs > 0 ? localTs : it.lastUpdated, + } as ContinueWatchingItem; + } + + // Otherwise keep Simkl, but if local has a newer timestamp, use it for ordering + if (localTs > 0 && localTs > simklTs) { + return { + ...it, + lastUpdated: localTs, + } as ContinueWatchingItem; + } + + return it; + }); + + adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + setContinueWatchingItems(adjustedItems); + } catch (err) { + logger.error('[SimklSync] Error in Simkl merge:', err); + } + })(); + + // Wait for all groups and provider merges to settle, then finalize loading state + await Promise.allSettled([...groupPromises, traktMergePromise, simklMergePromise]); } catch (error) { // Continue even if loading fails } finally { diff --git a/src/components/icons/SimklIcon.tsx b/src/components/icons/SimklIcon.tsx index 8f2b5133..0d310e23 100644 --- a/src/components/icons/SimklIcon.tsx +++ b/src/components/icons/SimklIcon.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import Svg, { Path } from 'react-native-svg'; +import { Image, StyleSheet } from 'react-native'; interface SimklIconProps { size?: number; @@ -9,12 +9,14 @@ interface SimklIconProps { const SimklIcon: React.FC = ({ size = 24, color = '#000000', style }) => { return ( - - - + ); }; diff --git a/src/components/icons/TraktIcon.tsx b/src/components/icons/TraktIcon.tsx index 65e85c73..34ae41be 100644 --- a/src/components/icons/TraktIcon.tsx +++ b/src/components/icons/TraktIcon.tsx @@ -5,14 +5,15 @@ import Svg, { Path } from 'react-native-svg'; interface TraktIconProps { size?: number; color?: string; + style?: any; } -const TraktIcon: React.FC = ({ size = 24, color = '#ed2224' }) => { +const TraktIcon: React.FC = ({ size = 24, color = '#ed2224', style }) => { return ( - + ([]); + const [userSettings, setUserSettings] = useState(null); + const [userStats, setUserStats] = useState(null); // Check authentication status const checkAuthStatus = useCallback(async () => { @@ -46,6 +50,20 @@ export function useSimklIntegration() { } }, [isAuthenticated]); + // Load user settings and stats + const loadUserProfile = useCallback(async () => { + if (!isAuthenticated) return; + try { + const settings = await simklService.getUserSettings(); + setUserSettings(settings); + + const stats = await simklService.getUserStats(); + setUserStats(stats); + } catch (error) { + logger.error('[useSimklIntegration] Error loading user profile:', error); + } + }, [isAuthenticated]); + // Start watching (scrobble start) const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise => { if (!isAuthenticated) return false; @@ -153,6 +171,7 @@ export function useSimklIntegration() { try { const playback = await simklService.getPlaybackStatus(); + logger.log(`[useSimklIntegration] fetched Simkl playback: ${playback.length}`); for (const item of playback) { let id: string | undefined; @@ -165,7 +184,8 @@ export function useSimklIntegration() { } else if (item.show && item.episode) { id = item.show.ids.imdb; type = 'series'; - episodeId = `${id}:${item.episode.season}:${item.episode.episode}`; + const epNum = (item.episode as any).episode ?? (item.episode as any).number; + episodeId = epNum !== undefined ? `${id}:${item.episode.season}:${epNum}` : undefined; } if (id) { @@ -197,8 +217,9 @@ export function useSimklIntegration() { if (isAuthenticated) { loadPlaybackStatus(); fetchAndMergeSimklProgress(); + loadUserProfile(); } - }, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress]); + }, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress, loadUserProfile]); // App state listener for sync useEffect(() => { @@ -222,6 +243,8 @@ export function useSimklIntegration() { stopWatching, syncAllProgress, fetchAndMergeSimklProgress, - continueWatching + continueWatching, + userSettings, + userStats, }; } diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index 79366420..e326adcd 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -9,6 +9,7 @@ import { ScrollView, StatusBar, Platform, + Image, } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { makeRedirectUri, useAuthRequest, ResponseType, CodeChallengeMethod } from 'expo-auth-session'; @@ -54,7 +55,9 @@ const SimklSettingsScreen: React.FC = () => { isAuthenticated, isLoading, checkAuthStatus, - refreshAuthStatus + refreshAuthStatus, + userSettings, + userStats } = useSimklIntegration(); const { isAuthenticated: isTraktAuthenticated } = useTraktIntegration(); @@ -167,12 +170,71 @@ const SimklSettingsScreen: React.FC = () => { ) : isAuthenticated ? ( - - Connected - + + {userSettings?.user?.avatar ? ( + + ) : ( + + + + )} + + {userSettings?.user && ( + + {userSettings.user.name} + + )} + {userSettings?.account?.type && ( + + {userSettings.account.type} Account + + )} + + Your watched items are syncing with Simkl. + + {userStats && ( + + + + {userStats.movies?.completed?.count || 0} + + + Movies + + + + + {(userStats.tv?.watching?.count || 0) + (userStats.tv?.completed?.count || 0)} + + + TV Shows + + + + + {userStats.anime?.completed?.count || 0} + + + Anime + + + + + {Math.round(((userStats.total_mins || 0) + (userStats.movies?.total_mins || 0) + (userStats.tv?.total_mins || 0) + (userStats.anime?.total_mins || 0)) / 60)}h + + + Watched + + + + )} + { )} + + + + Nuvio is not affiliated with Simkl. @@ -284,17 +354,65 @@ const styles = StyleSheet.create({ fontSize: 15, }, profileContainer: { + alignItems: 'stretch', + paddingVertical: 8, + }, + profileHeader: { + flexDirection: 'row', alignItems: 'center', - paddingVertical: 20, + marginBottom: 12, + }, + avatar: { + width: 44, + height: 44, + borderRadius: 22, + marginRight: 12, + backgroundColor: '#00000010', + }, + avatarPlaceholder: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + marginRight: 12, + }, + profileText: { + flex: 1, }, statusTitle: { - fontSize: 20, - fontWeight: 'bold', + fontSize: 18, + fontWeight: '700', + marginBottom: 2, + }, + accountType: { + fontSize: 13, + fontWeight: '500', marginBottom: 8, }, statusDesc: { - fontSize: 15, - marginBottom: 10, + fontSize: 14, + marginBottom: 8, + }, + statsGrid: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 16, + marginVertical: 12, + borderTopWidth: 1, + borderBottomWidth: 1, + }, + statItem: { + alignItems: 'center', + }, + statValue: { + fontSize: 17, + fontWeight: '700', + marginBottom: 4, + }, + statLabel: { + fontSize: 12, + fontWeight: '500', }, button: { width: '100%', @@ -312,6 +430,30 @@ const styles = StyleSheet.create({ fontSize: 12, textAlign: 'center', marginTop: 20, + marginBottom: 8, + }, + logoSection: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 20, + marginTop: 16, + marginBottom: 8, + }, + logo: { + width: 150, + height: 30, + }, + logoContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 28, + marginBottom: 24, + borderRadius: 12, + elevation: 2, + shadowColor: '#000', + shadowOpacity: 0.1, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, }, }); diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index 952fa1a2..81b57bb6 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -16,7 +16,7 @@ import { useNavigation } from '@react-navigation/native'; import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import FastImage from '@d11/react-native-fast-image'; -import { traktService, TraktUser } from '../services/traktService'; +import { traktService, TraktUser, TraktUserStats } from '../services/traktService'; import { useSettings } from '../hooks/useSettings'; import { logger } from '../utils/logger'; import TraktIcon from '../../assets/rating-icons/trakt.svg'; @@ -55,6 +55,7 @@ const TraktSettingsScreen: React.FC = () => { const [isLoading, setIsLoading] = useState(true); const [isAuthenticated, setIsAuthenticated] = useState(false); const [userProfile, setUserProfile] = useState(null); + const [userStats, setUserStats] = useState(null); const { currentTheme } = useTheme(); const { @@ -109,8 +110,16 @@ const TraktSettingsScreen: React.FC = () => { if (authenticated) { const profile = await traktService.getUserProfile(); setUserProfile(profile); + try { + const stats = await traktService.getUserStats(); + setUserStats(stats); + } catch (statsError) { + logger.warn('[TraktSettingsScreen] Failed to load stats:', statsError); + setUserStats(null); + } } else { setUserProfile(null); + setUserStats(null); } } catch (error) { logger.error('[TraktSettingsScreen] Error checking auth status:', error); @@ -353,6 +362,42 @@ const TraktSettingsScreen: React.FC = () => { ]}> {t('trakt.joined', { date: new Date(userProfile.joined_at).toLocaleDateString() })} + {userStats && ( + + + + {userStats.movies?.watched ?? 0} + + + Movies + + + + + {userStats.shows?.watched ?? 0} + + + Shows + + + + + {userStats.episodes?.watched ?? 0} + + + Episodes + + + + + {Math.round(((userStats.minutes ?? 0) + (userStats.movies?.minutes ?? 0) + (userStats.shows?.minutes ?? 0) + (userStats.episodes?.minutes ?? 0)) / 60)}h + + + Watched + + + + )} ; +} + +export interface SimklStats { + total_mins: number; + movies?: { + total_mins: number; + completed?: { count: number }; + }; + tv?: { + total_mins: number; + watching?: { count: number }; + completed?: { count: number }; + }; + anime?: { + total_mins: number; + watching?: { count: number }; + completed?: { count: number }; + }; +} + export class SimklService { private static instance: SimklService; private accessToken: string | null = null; @@ -480,18 +518,41 @@ export class SimklService { } public async getPlaybackStatus(): Promise { - // Get both movies and episodes - // Simkl endpoint: /sync/playback (returns all if no type specified, or we specify type) - // Docs say /sync/playback/{type} - // Let's trying getting all if possible, or fetch both. Docs say type is optional param? - // Docs: /sync/playback/{type} -> actually path param seems required or at least standard. - // But query params: type (optional). - // Let's try fetching without path param or empty? - // Docs: "Retrieves all paused... optionally filter by type by appending /movie" - // Let's assume /sync/playback works for all. + // Docs: GET /sync/playback/{type} with {type} values `movies` or `episodes`. + // Some docs also mention appending /movie or /episode; we try both variants for safety. + const tryEndpoints = async (endpoints: string[]): Promise => { + for (const endpoint of endpoints) { + try { + const res = await this.apiRequest(endpoint); + if (Array.isArray(res)) { + logger.log(`[SimklService] getPlaybackStatus: ${endpoint} -> ${res.length} items`); + return res; + } + } catch (e) { + logger.warn(`[SimklService] getPlaybackStatus: ${endpoint} failed`, e); + } + } + return []; + }; - const response = await this.apiRequest('/sync/playback'); - return response || []; + const movies = await tryEndpoints([ + '/sync/playback/movies', + '/sync/playback/movie', + '/sync/playback?type=movies' + ]); + + const episodes = await tryEndpoints([ + '/sync/playback/episodes', + '/sync/playback/episode', + '/sync/playback?type=episodes' + ]); + + const combined = [...episodes, ...movies] + .filter(Boolean) + .sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()); + + logger.log(`[SimklService] getPlaybackStatus: combined ${combined.length} items (episodes=${episodes.length}, movies=${movies.length})`); + return combined; } /** @@ -506,4 +567,42 @@ export class SimklService { } return await this.apiRequest(url); } -} + + /** + * Get user settings/profile + */ + public async getUserSettings(): Promise { + try { + const response = await this.apiRequest('/users/settings', 'POST'); + logger.log('[SimklService] getUserSettings:', JSON.stringify(response)); + return response; + } catch (error) { + logger.error('[SimklService] Failed to get user settings:', error); + return null; + } + } + + /** + * Get user stats + */ + public async getUserStats(): Promise { + try { + if (!await this.isAuthenticated()) { + return null; + } + + // Need account ID from settings first + const settings = await this.getUserSettings(); + if (!settings?.account?.id) { + logger.warn('[SimklService] Cannot get user stats: no account ID'); + return null; + } + + const response = await this.apiRequest(`/users/${settings.account.id}/stats`, 'POST'); + logger.log('[SimklService] getUserStats:', JSON.stringify(response)); + return response; + } catch (error) { + logger.error('[SimklService] Failed to get user stats:', error); + return null; + } + }} \ No newline at end of file diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 80b0b6c5..bb5de6f9 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -27,6 +27,22 @@ export interface TraktUser { avatar?: string; } +export interface TraktUserStats { + movies?: { + watched?: number; + minutes?: number; + }; + shows?: { + watched?: number; + minutes?: number; + }; + episodes?: { + watched?: number; + minutes?: number; + }; + minutes?: number; // total minutes watched +} + export interface TraktWatchedItem { movie?: { title: string; @@ -1117,6 +1133,13 @@ export class TraktService { return this.apiRequest('/users/me?extended=full'); } + /** + * Get the user's watch stats + */ + public async getUserStats(): Promise { + return this.apiRequest('/users/me/stats'); + } + /** * Get the user's watched movies */ From 4a611366901464fa925f8b05762c8c10b2a77ff0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:18:30 +0530 Subject: [PATCH 05/10] removed primary colour from trakt icon --- src/screens/SettingsScreen.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index dc8e620c..577b980b 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -375,10 +375,10 @@ const SettingsScreen: React.FC = () => { return ( {isItemVisible('trakt') && ( - } + } renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} isLast={!isItemVisible('simkl')} @@ -386,10 +386,10 @@ const SettingsScreen: React.FC = () => { /> )} {isItemVisible('simkl') && ( - } + } renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} isLast={true} @@ -683,7 +683,7 @@ const SettingsScreen: React.FC = () => { } + customIcon={} renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} isLast={!isItemVisible('simkl')} @@ -693,7 +693,7 @@ const SettingsScreen: React.FC = () => { } + customIcon={} renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} isLast={true} From 3e30048c033ee385c74ae8eeadd0015227afaea7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:26:23 +0530 Subject: [PATCH 06/10] added "simk" harcodings to locales --- src/i18n/locales/ar.json | 24 +++++++++++++++++++++++ src/i18n/locales/de.json | 24 +++++++++++++++++++++++ src/i18n/locales/en.json | 24 +++++++++++++++++++++++ src/i18n/locales/es.json | 24 +++++++++++++++++++++++ src/i18n/locales/fr.json | 24 +++++++++++++++++++++++ src/i18n/locales/it.json | 24 +++++++++++++++++++++++ src/i18n/locales/pt-BR.json | 24 +++++++++++++++++++++++ src/i18n/locales/pt-PT.json | 24 +++++++++++++++++++++++ src/screens/SettingsScreen.tsx | 8 ++++---- src/screens/SimklSettingsScreen.tsx | 30 +++++++++++++++-------------- 10 files changed, 212 insertions(+), 18 deletions(-) diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index 623d4c06..44bc4fbc 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -522,6 +522,27 @@ "sync_success_msg": "تمت مزامنة تقدم المشاهدة مع Trakt بنجاح.", "sync_error_msg": "فشلت المزامنة. يرجى المحاولة مرة أخرى." }, + "simkl": { + "title": "إعدادات Simkl", + "settings_title": "إعدادات Simkl", + "connect_title": "الاتصال بـ Simkl", + "connect_desc": "زامن تاريخ مشاهدتك وتتبع ما تشاهده", + "sign_in": "تسجيل الدخول بـ Simkl", + "sign_out": "قطع الاتصال", + "sign_out_confirm": "هل أنت متأكد من أنك تريد قطع الاتصال من Simkl؟", + "syncing_desc": "عناصرك المشاهدة تتم مزامنتها مع Simkl.", + "auth_success_title": "تم الاتصال بنجاح", + "auth_success_msg": "تم ربط حساب Simkl الخاص بك بنجاح.", + "auth_error_title": "خطأ في المصادقة", + "auth_error_msg": "فشل في إكمال المصادقة مع Simkl.", + "auth_error_generic": "حدث خطأ أثناء المصادقة.", + "sign_out_error": "فشل في قطع الاتصال من Simkl.", + "config_error_title": "خطأ في التكوين", + "config_error_msg": "معرف عميل Simkl مفقود في متغيرات البيئة.", + "conflict_title": "تعارض", + "conflict_msg": "لا يمكنك ربط Simkl بينما Trakt متصل. يرجى قطع اتصال Trakt أولاً.", + "disclaimer": "Nuvio غير مرتبط بشركة Simkl." + }, "tmdb_settings": { "title": "إعدادات TMDb", "metadata_enrichment": "إثراء البيانات التعريفية", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "متصل", "mdblist_desc": "تفعيل لإضافة التقييمات والمراجعات", + "simkl": "Simkl", + "simkl_connected": "متصل", + "simkl_desc": "تتبع ما تشاهده", "tmdb": "TMDB", "tmdb_desc": "مزود البيانات التعريفية والشعارات", "openrouter": "OpenRouter API", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index ace11a3d..3501bcf9 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -522,6 +522,27 @@ "sync_success_msg": "Wiedergabefortschritt erfolgreich mit Trakt synchronisiert.", "sync_error_msg": "Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut." }, + "simkl": { + "title": "Simkl Einstellungen", + "settings_title": "Simkl Einstellungen", + "connect_title": "Mit Simkl verbinden", + "connect_desc": "Synchronisieren Sie Ihren Verlauf und verfolgen Sie, was Sie sehen", + "sign_in": "Mit Simkl anmelden", + "sign_out": "Trennen", + "sign_out_confirm": "Sind Sie sicher, dass Sie die Verbindung zu Simkl trennen möchten?", + "syncing_desc": "Ihre gesehenen Elemente werden mit Simkl synchronisiert.", + "auth_success_title": "Erfolgreich verbunden", + "auth_success_msg": "Ihr Simkl-Konto wurde erfolgreich verbunden.", + "auth_error_title": "Authentifizierungsfehler", + "auth_error_msg": "Authentifizierung mit Simkl fehlgeschlagen.", + "auth_error_generic": "Bei der Authentifizierung ist ein Fehler aufgetreten.", + "sign_out_error": "Verbindung zu Simkl konnte nicht getrennt werden.", + "config_error_title": "Konfigurationsfehler", + "config_error_msg": "Simkl Client ID fehlt in den Umgebungsvariablen.", + "conflict_title": "Konflikt", + "conflict_msg": "Sie können Simkl nicht verbinden, während Trakt verbunden ist. Bitte trennen Sie zuerst Trakt.", + "disclaimer": "Nuvio ist nicht mit Simkl verbunden." + }, "tmdb_settings": { "title": "TMDb Einstellungen", "metadata_enrichment": "Metadaten-Anreicherung", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Verbunden", "mdblist_desc": "Aktivieren, um Bewertungen & Rezensionen hinzuzufügen", + "simkl": "Simkl", + "simkl_connected": "Verbunden", + "simkl_desc": "Verfolge, was du schaust", "tmdb": "TMDB", "tmdb_desc": "Metadaten- & Logo-Quellanbieter", "openrouter": "OpenRouter API", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f11b13a7..3a3b52a2 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -522,6 +522,27 @@ "sync_success_msg": "Successfully synced your watch progress with Trakt.", "sync_error_msg": "Sync failed. Please try again." }, + "simkl": { + "title": "Simkl Settings", + "settings_title": "Simkl Settings", + "connect_title": "Connect with Simkl", + "connect_desc": "Sync your watch history and track what you're watching", + "sign_in": "Sign In with Simkl", + "sign_out": "Disconnect", + "sign_out_confirm": "Are you sure you want to disconnect from Simkl?", + "syncing_desc": "Your watched items are syncing with Simkl.", + "auth_success_title": "Successfully Connected", + "auth_success_msg": "Your Simkl account has been connected successfully.", + "auth_error_title": "Authentication Error", + "auth_error_msg": "Failed to complete authentication with Simkl.", + "auth_error_generic": "An error occurred during authentication.", + "sign_out_error": "Failed to disconnect from Simkl.", + "config_error_title": "Configuration Error", + "config_error_msg": "Simkl Client ID is missing in environment variables.", + "conflict_title": "Conflict", + "conflict_msg": "You cannot connect to Simkl while Trakt is connected. Please disconnect Trakt first.", + "disclaimer": "Nuvio is not affiliated with Simkl." + }, "tmdb_settings": { "title": "TMDb Settings", "metadata_enrichment": "Metadata Enrichment", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Connected", "mdblist_desc": "Enable to add ratings & reviews", + "simkl": "Simkl", + "simkl_connected": "Connected", + "simkl_desc": "Track what you watch", "tmdb": "TMDB", "tmdb_desc": "Metadata & logo source provider", "openrouter": "OpenRouter API", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 2efd681c..d542aa8b 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -522,6 +522,27 @@ "sync_success_msg": "Sincronización del progreso con Trakt completada con éxito.", "sync_error_msg": "La sincronización falló. Por favor, inténtalo de nuevo." }, + "simkl": { + "title": "Configuración de Simkl", + "settings_title": "Configuración de Simkl", + "connect_title": "Conectar con Simkl", + "connect_desc": "Sincroniza tu historial de visualización y rastrea lo que ves", + "sign_in": "Iniciar sesión con Simkl", + "sign_out": "Desconectar", + "sign_out_confirm": "¿Estás seguro de que quieres desconectar de Simkl?", + "syncing_desc": "Tus elementos vistos se están sincronizando con Simkl.", + "auth_success_title": "Conectado exitosamente", + "auth_success_msg": "Tu cuenta de Simkl se ha conectado exitosamente.", + "auth_error_title": "Error de autenticación", + "auth_error_msg": "Error al completar la autenticación con Simkl.", + "auth_error_generic": "Ocurrió un error durante la autenticación.", + "sign_out_error": "Error al desconectar de Simkl.", + "config_error_title": "Error de configuración", + "config_error_msg": "El ID de cliente de Simkl falta en las variables de entorno.", + "conflict_title": "Conflicto", + "conflict_msg": "No puedes conectar Simkl mientras Trakt está conectado. Por favor, desconecta Trakt primero.", + "disclaimer": "Nuvio no está afiliado con Simkl." + }, "tmdb_settings": { "title": "Ajustes de TMDb", "metadata_enrichment": "Enriquecimiento de metadatos", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Conectado", "mdblist_desc": "Activar para añadir valoraciones y reseñas", + "simkl": "Simkl", + "simkl_connected": "Conectado", + "simkl_desc": "Rastrea lo que ves", "tmdb": "TMDB", "tmdb_desc": "Proveedor de metadatos y logos", "openrouter": "API de OpenRouter", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 1bcd2808..38008ebc 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -522,6 +522,27 @@ "sync_success_msg": "Votre progression a été synchronisée avec succès avec Trakt.", "sync_error_msg": "La synchronisation a échoué. Veuillez réessayer." }, + "simkl": { + "title": "Paramètres Simkl", + "settings_title": "Paramètres Simkl", + "connect_title": "Se connecter avec Simkl", + "connect_desc": "Synchronisez votre historique de visionnage et suivez ce que vous regardez", + "sign_in": "Se connecter avec Simkl", + "sign_out": "Déconnecter", + "sign_out_confirm": "Êtes-vous sûr de vouloir vous déconnecter de Simkl ?", + "syncing_desc": "Vos éléments regardés sont synchronisés avec Simkl.", + "auth_success_title": "Connecté avec succès", + "auth_success_msg": "Votre compte Simkl a été connecté avec succès.", + "auth_error_title": "Erreur d'authentification", + "auth_error_msg": "Échec de l'authentification avec Simkl.", + "auth_error_generic": "Une erreur s'est produite lors de l'authentification.", + "sign_out_error": "Échec de la déconnexion de Simkl.", + "config_error_title": "Erreur de configuration", + "config_error_msg": "L'ID client Simkl est manquant dans les variables d'environnement.", + "conflict_title": "Conflit", + "conflict_msg": "Vous ne pouvez pas connecter Simkl tant que Trakt est connecté. Veuillez d'abord déconnecter Trakt.", + "disclaimer": "Nuvio n'est pas affilié à Simkl." + }, "tmdb_settings": { "title": "Paramètres TMDb", "metadata_enrichment": "Enrichissement des métadonnées", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Connecté", "mdblist_desc": "Activer pour ajouter les notes et avis", + "simkl": "Simkl", + "simkl_connected": "Connecté", + "simkl_desc": "Suivez ce que vous regardez", "tmdb": "TMDB", "tmdb_desc": "Fournisseur de métadonnées et de logos", "openrouter": "API OpenRouter", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index 6559bc0f..d1895f0b 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -522,6 +522,27 @@ "sync_success_msg": "Progressi di visione sincronizzati con successo con Trakt.", "sync_error_msg": "Sincronizzazione fallita. Riprova." }, + "simkl": { + "title": "Impostazioni Simkl", + "settings_title": "Impostazioni Simkl", + "connect_title": "Connetti con Simkl", + "connect_desc": "Sincronizza la tua cronologia di visione e traccia ciò che guardi", + "sign_in": "Accedi con Simkl", + "sign_out": "Disconnetti", + "sign_out_confirm": "Sei sicuro di voler disconnettere da Simkl?", + "syncing_desc": "I tuoi elementi guardati sono in sincronizzazione con Simkl.", + "auth_success_title": "Connesso con successo", + "auth_success_msg": "Il tuo account Simkl è stato connesso con successo.", + "auth_error_title": "Errore di autenticazione", + "auth_error_msg": "Impossibile completare l'autenticazione con Simkl.", + "auth_error_generic": "Si è verificato un errore durante l'autenticazione.", + "sign_out_error": "Impossibile disconnettere da Simkl.", + "config_error_title": "Errore di configurazione", + "config_error_msg": "L'ID client Simkl manca nelle variabili d'ambiente.", + "conflict_title": "Conflitto", + "conflict_msg": "Non puoi connettere Simkl mentre Trakt è connesso. Disconnetti prima Trakt.", + "disclaimer": "Nuvio non è affiliato con Simkl." + }, "tmdb_settings": { "title": "Impostazioni TMDb", "metadata_enrichment": "Arricchimento metadati", @@ -675,6 +696,9 @@ "mdblist": "MDBList", "mdblist_connected": "Connesso", "mdblist_desc": "Abilita per aggiungere voti e recensioni", + "simkl": "Simkl", + "simkl_connected": "Connesso", + "simkl_desc": "Traccia ciò che guardi", "tmdb": "TMDB", "tmdb_desc": "Sorgente metadati e loghi", "openrouter": "API OpenRouter", diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index 05f9823b..277ba9ef 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -522,6 +522,27 @@ "sync_success_msg": "Progresso sincronizado com sucesso com o Trakt.", "sync_error_msg": "Falha na sincronização. Tente novamente." }, + "simkl": { + "title": "Configurações do Simkl", + "settings_title": "Configurações do Simkl", + "connect_title": "Conectar com Simkl", + "connect_desc": "Sincronize seu histórico de visualização e rastreie o que você assiste", + "sign_in": "Entrar com Simkl", + "sign_out": "Desconectar", + "sign_out_confirm": "Tem certeza de que deseja desconectar do Simkl?", + "syncing_desc": "Seus itens assistidos estão sendo sincronizados com o Simkl.", + "auth_success_title": "Conectado com sucesso", + "auth_success_msg": "Sua conta Simkl foi conectada com sucesso.", + "auth_error_title": "Erro de autenticação", + "auth_error_msg": "Falha ao completar a autenticação com o Simkl.", + "auth_error_generic": "Ocorreu um erro durante a autenticação.", + "sign_out_error": "Falha ao desconectar do Simkl.", + "config_error_title": "Erro de configuração", + "config_error_msg": "O ID do cliente Simkl está faltando nas variáveis de ambiente.", + "conflict_title": "Conflito", + "conflict_msg": "Você não pode conectar o Simkl enquanto o Trakt está conectado. Desconecte o Trakt primeiro.", + "disclaimer": "Nuvio não é afiliado ao Simkl." + }, "tmdb_settings": { "title": "Configurações do TMDb", "metadata_enrichment": "Enriquecimento de Metadados", @@ -689,6 +710,9 @@ "mdblist": "MDBList", "mdblist_connected": "Conectado", "mdblist_desc": "Habilitar para adicionar avaliações e resenhas", + "simkl": "Simkl", + "simkl_connected": "Conectado", + "simkl_desc": "Acompanhe o que você assiste", "tmdb": "TMDB", "tmdb_desc": "Provedor de metadados e logos", "openrouter": "OpenRouter API", diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index 424fd409..8345eed9 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -522,6 +522,27 @@ "sync_success_msg": "Progresso sincronizado com sucesso com o Trakt.", "sync_error_msg": "Falha na sincronização. Tenta novamente." }, + "simkl": { + "title": "Configurações do Simkl", + "settings_title": "Configurações do Simkl", + "connect_title": "Ligar ao Simkl", + "connect_desc": "Sincroniza o teu histórico de visualização e rastreia o que vês", + "sign_in": "Entrar com Simkl", + "sign_out": "Desligar", + "sign_out_confirm": "Tens a certeza de que queres desligar do Simkl?", + "syncing_desc": "Os teus itens vistos estão a ser sincronizados com o Simkl.", + "auth_success_title": "Ligado com sucesso", + "auth_success_msg": "A tua conta Simkl foi ligada com sucesso.", + "auth_error_title": "Erro de autenticação", + "auth_error_msg": "Falha ao completar a autenticação com o Simkl.", + "auth_error_generic": "Ocorreu um erro durante a autenticação.", + "sign_out_error": "Falha ao desligar do Simkl.", + "config_error_title": "Erro de configuração", + "config_error_msg": "O ID do cliente Simkl está em falta nas variáveis de ambiente.", + "conflict_title": "Conflito", + "conflict_msg": "Não podes ligar o Simkl enquanto o Trakt está ligado. Desliga primeiro o Trakt.", + "disclaimer": "Nuvio não é afiliado ao Simkl." + }, "tmdb_settings": { "title": "Configurações do TMDb", "metadata_enrichment": "Enriquecimento de Metadados", @@ -689,6 +710,9 @@ "mdblist": "MDBList", "mdblist_connected": "Conectado", "mdblist_desc": "Ativar para adicionar avaliações e críticas", + "simkl": "Simkl", + "simkl_connected": "Conectado", + "simkl_desc": "Acompanhe o que vê", "tmdb": "TMDB", "tmdb_desc": "Provedor de metadados e logos", "openrouter": "OpenRouter API", diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 577b980b..386d511c 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -387,8 +387,8 @@ const SettingsScreen: React.FC = () => { )} {isItemVisible('simkl') && ( } renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} @@ -691,8 +691,8 @@ const SettingsScreen: React.FC = () => { )} {isItemVisible('simkl') && ( } renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index e326adcd..1ef22708 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -21,6 +21,7 @@ import { useTheme } from '../contexts/ThemeContext'; import { useSimklIntegration } from '../hooks/useSimklIntegration'; import { useTraktIntegration } from '../hooks/useTraktIntegration'; import CustomAlert from '../components/CustomAlert'; +import { useTranslation } from 'react-i18next'; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; @@ -50,6 +51,7 @@ const SimklSettingsScreen: React.FC = () => { const [alertMessage, setAlertMessage] = useState(''); const { currentTheme } = useTheme(); + const { t } = useTranslation(); const { isAuthenticated, @@ -97,30 +99,30 @@ const SimklSettingsScreen: React.FC = () => { .then(success => { if (success) { refreshAuthStatus(); - openAlert('Success', 'Connected to Simkl successfully!'); + openAlert(t('common.success'), t('simkl.auth_success_msg')); } else { - openAlert('Error', 'Failed to connect to Simkl.'); + openAlert(t('common.error'), t('simkl.auth_error_msg')); } }) .catch(err => { logger.error('[SimklSettingsScreen] Token exchange error:', err); - openAlert('Error', 'An error occurred during connection.'); + openAlert(t('common.error'), t('simkl.auth_error_generic')); }) .finally(() => setIsExchangingCode(false)); } else if (response.type === 'error') { - openAlert('Error', 'Authentication error: ' + (response.error?.message || 'Unknown')); + openAlert(t('simkl.auth_error_title'), t('simkl.auth_error_generic') + ' ' + (response.error?.message || t('common.unknown'))); } } }, [response, refreshAuthStatus]); const handleSignIn = () => { if (!SIMKL_CLIENT_ID) { - openAlert('Configuration Error', 'Simkl Client ID is missing in environment variables.'); + openAlert(t('simkl.config_error_title'), t('simkl.config_error_msg')); return; } if (isTraktAuthenticated) { - openAlert('Conflict', 'You cannot connect to Simkl while Trakt is connected. Please disconnect Trakt first.'); + openAlert(t('simkl.conflict_title'), t('simkl.conflict_msg')); return; } @@ -130,7 +132,7 @@ const SimklSettingsScreen: React.FC = () => { const handleSignOut = async () => { await simklService.logout(); refreshAuthStatus(); - openAlert('Signed Out', 'You have disconnected from Simkl.'); + openAlert(t('common.success'), t('simkl.sign_out_confirm')); }; return ( @@ -156,7 +158,7 @@ const SimklSettingsScreen: React.FC = () => { - Simkl Integration + {t('simkl.settings_title')} @@ -195,7 +197,7 @@ const SimklSettingsScreen: React.FC = () => { - Your watched items are syncing with Simkl. + {t('simkl.syncing_desc')} {userStats && ( @@ -239,16 +241,16 @@ const SimklSettingsScreen: React.FC = () => { style={[styles.button, { backgroundColor: currentTheme.colors.error, marginTop: 20 }]} onPress={handleSignOut} > - Disconnect + {t('simkl.sign_out')} ) : ( - Connect Simkl + {t('simkl.connect_title')} - Sync your watch history and track what you're watching. + {t('simkl.connect_desc')} { {isExchangingCode ? ( ) : ( - Sign In with Simkl + {t('simkl.sign_in')} )} @@ -277,7 +279,7 @@ const SimklSettingsScreen: React.FC = () => { - Nuvio is not affiliated with Simkl. + {t('simkl.disclaimer')} From 6f15e104ef51f8494d7ed526683bd2e0ba5d0f68 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:27:49 +0530 Subject: [PATCH 07/10] margin adjustments --- src/screens/SearchScreen.tsx | 31 ++++++++--------------------- src/screens/SimklSettingsScreen.tsx | 4 ++-- 2 files changed, 10 insertions(+), 25 deletions(-) diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index e70c37df..8d5a6745 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -153,18 +153,13 @@ const SearchScreen = () => { type: catalog.type, }; await mmkvStorage.setItem(DISCOVER_CATALOG_KEY, JSON.stringify(catalogData)); - } else { - // Clear catalog if null - await mmkvStorage.removeItem(DISCOVER_CATALOG_KEY); } - // Save genre - use empty string to indicate "All genres" - // This way we distinguish between "not set" and "All genres" + // Save genre if (genre) { await mmkvStorage.setItem(DISCOVER_GENRE_KEY, genre); } else { - // Save empty string to indicate "All genres" is selected - await mmkvStorage.setItem(DISCOVER_GENRE_KEY, ''); + await mmkvStorage.removeItem(DISCOVER_GENRE_KEY); } } catch (error) { logger.error('Failed to save discover settings:', error); @@ -193,21 +188,11 @@ const SearchScreen = () => { // Load saved genre const savedGenre = await mmkvStorage.getItem(DISCOVER_GENRE_KEY); - if (savedGenre !== null) { - if (savedGenre === '') { - // Empty string means "All genres" was selected - setSelectedDiscoverGenre(null); - } else if (foundCatalog.genres.includes(savedGenre)) { - setSelectedDiscoverGenre(savedGenre); - } else if (foundCatalog.genres.length > 0) { - // Set first genre as default if saved genre not available - setSelectedDiscoverGenre(foundCatalog.genres[0]); - } - } else { - // No saved genre, default to first genre - if (foundCatalog.genres.length > 0) { - setSelectedDiscoverGenre(foundCatalog.genres[0]); - } + if (savedGenre && foundCatalog.genres.includes(savedGenre)) { + setSelectedDiscoverGenre(savedGenre); + } else if (foundCatalog.genres.length > 0) { + // Set first genre as default if saved genre not available + setSelectedDiscoverGenre(foundCatalog.genres[0]); } return; } @@ -703,7 +688,7 @@ const SearchScreen = () => { const handleGenreSelect = (genre: string | null) => { setSelectedDiscoverGenre(genre); - // Save genre setting - this will save empty string for null (All genres) + // Save genre setting saveDiscoverSettings(selectedDiscoverType, selectedCatalog, genre); genreSheetRef.current?.dismiss(); diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index 1ef22708..d6a7bf96 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -431,7 +431,7 @@ const styles = StyleSheet.create({ disclaimer: { fontSize: 12, textAlign: 'center', - marginTop: 20, + marginTop: 8, marginBottom: 8, }, logoSection: { @@ -439,7 +439,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingVertical: 20, marginTop: 16, - marginBottom: 8, + marginBottom: 0, }, logo: { width: 150, From 00cf7e696daaf7e932ebc9e129b39f325a09c2f7 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:29:00 +0530 Subject: [PATCH 08/10] Revert "margin adjustments" This reverts commit 6f15e104ef51f8494d7ed526683bd2e0ba5d0f68. --- src/screens/SearchScreen.tsx | 31 +++++++++++++++++++++-------- src/screens/SimklSettingsScreen.tsx | 4 ++-- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 8d5a6745..e70c37df 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -153,13 +153,18 @@ const SearchScreen = () => { type: catalog.type, }; await mmkvStorage.setItem(DISCOVER_CATALOG_KEY, JSON.stringify(catalogData)); + } else { + // Clear catalog if null + await mmkvStorage.removeItem(DISCOVER_CATALOG_KEY); } - // Save genre + // Save genre - use empty string to indicate "All genres" + // This way we distinguish between "not set" and "All genres" if (genre) { await mmkvStorage.setItem(DISCOVER_GENRE_KEY, genre); } else { - await mmkvStorage.removeItem(DISCOVER_GENRE_KEY); + // Save empty string to indicate "All genres" is selected + await mmkvStorage.setItem(DISCOVER_GENRE_KEY, ''); } } catch (error) { logger.error('Failed to save discover settings:', error); @@ -188,11 +193,21 @@ const SearchScreen = () => { // Load saved genre const savedGenre = await mmkvStorage.getItem(DISCOVER_GENRE_KEY); - if (savedGenre && foundCatalog.genres.includes(savedGenre)) { - setSelectedDiscoverGenre(savedGenre); - } else if (foundCatalog.genres.length > 0) { - // Set first genre as default if saved genre not available - setSelectedDiscoverGenre(foundCatalog.genres[0]); + if (savedGenre !== null) { + if (savedGenre === '') { + // Empty string means "All genres" was selected + setSelectedDiscoverGenre(null); + } else if (foundCatalog.genres.includes(savedGenre)) { + setSelectedDiscoverGenre(savedGenre); + } else if (foundCatalog.genres.length > 0) { + // Set first genre as default if saved genre not available + setSelectedDiscoverGenre(foundCatalog.genres[0]); + } + } else { + // No saved genre, default to first genre + if (foundCatalog.genres.length > 0) { + setSelectedDiscoverGenre(foundCatalog.genres[0]); + } } return; } @@ -688,7 +703,7 @@ const SearchScreen = () => { const handleGenreSelect = (genre: string | null) => { setSelectedDiscoverGenre(genre); - // Save genre setting + // Save genre setting - this will save empty string for null (All genres) saveDiscoverSettings(selectedDiscoverType, selectedCatalog, genre); genreSheetRef.current?.dismiss(); diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index d6a7bf96..1ef22708 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -431,7 +431,7 @@ const styles = StyleSheet.create({ disclaimer: { fontSize: 12, textAlign: 'center', - marginTop: 8, + marginTop: 20, marginBottom: 8, }, logoSection: { @@ -439,7 +439,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingVertical: 20, marginTop: 16, - marginBottom: 0, + marginBottom: 8, }, logo: { width: 150, From 34d809c510dc6211c799f4be9746b890f6a1015d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:29:36 +0530 Subject: [PATCH 09/10] margin adjustment --- src/screens/SimklSettingsScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index 1ef22708..d6a7bf96 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -431,7 +431,7 @@ const styles = StyleSheet.create({ disclaimer: { fontSize: 12, textAlign: 'center', - marginTop: 20, + marginTop: 8, marginBottom: 8, }, logoSection: { @@ -439,7 +439,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingVertical: 20, marginTop: 16, - marginBottom: 8, + marginBottom: 0, }, logo: { width: 150, From 34a3c52aede90d9512148e5961d27bb49f0d407b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 18 Jan 2026 14:31:54 +0530 Subject: [PATCH 10/10] Mentioning Alpha stage --- src/screens/SimklSettingsScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/SimklSettingsScreen.tsx b/src/screens/SimklSettingsScreen.tsx index d6a7bf96..5bcb8087 100644 --- a/src/screens/SimklSettingsScreen.tsx +++ b/src/screens/SimklSettingsScreen.tsx @@ -158,7 +158,7 @@ const SimklSettingsScreen: React.FC = () => { - {t('simkl.settings_title')} + {t('simkl.settings_title')} (Alpha)