diff --git a/assets/simkl-favicon.png b/assets/simkl-favicon.png new file mode 100644 index 00000000..c8454b3e Binary files /dev/null and b/assets/simkl-favicon.png differ diff --git a/assets/simkl-logo.png b/assets/simkl-logo.png new file mode 100644 index 00000000..6836cfc0 Binary files /dev/null and b/assets/simkl-logo.png differ 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 */