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] 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 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 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 */