ui changes

This commit is contained in:
tapframe 2026-01-18 14:15:02 +05:30
parent 25e1102832
commit ea2debb9dd
11 changed files with 623 additions and 45 deletions

BIN
assets/simkl-favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
assets/simkl-logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

0
assets/trakt-favicon.png Normal file
View file

View file

@ -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<ContinueWatchingRef>((props, re
const lastTraktSyncRef = useRef<number>(0);
const TRAKT_SYNC_COOLDOWN = 0; // disabled (always fetch Trakt playback)
// Track last Simkl sync to prevent excessive API calls
const lastSimklSyncRef = useRef<number>(0);
const SIMKL_SYNC_COOLDOWN = 0; // disabled (always fetch Simkl playback)
// Track last Trakt reconcile per item (local -> Trakt catch-up)
const lastTraktReconcileRef = useRef<Map<string, number>>(new Map());
const TRAKT_RECONCILE_COOLDOWN = 0; // 2 minutes between reconcile attempts per item
@ -471,13 +476,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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<void>[] = [];
// 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<string, LocalProgressEntry[]> | null = null;
if (isTraktAuthed) {
if (isTraktAuthed || isSimklAuthed) {
try {
const allProgress = await storageService.getAllWatchProgress();
const index = new Map<string, LocalProgressEntry[]>();
@ -519,8 +530,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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<ContinueWatchingRef>((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<string, ContinueWatchingItem>();
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<LocalProgressEntry | null>((acc, cur) => {
if (!acc) return cur;
return (cur.lastUpdated ?? 0) > (acc.lastUpdated ?? 0) ? cur : acc;
}, null);
const highestLocal = matches.reduce<LocalProgressEntry | null>((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 {

View file

@ -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<SimklIconProps> = ({ size = 24, color = '#000000', style }) => {
return (
<Svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={style}>
<Path
d="M21.996 11.233c-.02-.85-.12-1.68-.28-2.5-.27-.08-.54-.15-.81-.22-.32 1.35-.55 2.72-.65 4.1h.005v.025c-.012.333-.012.67-.008 1.008l.008.026h-.005c.015 1.48.16 2.94.41 4.39.28-.06.56-.13.84-.2.17-.89.28-1.8.31-2.71.01-.15.01-.3.02-.45.39-1.07.66-2.22.79-3.41-.21.01-.42.01-.63.01v-.058l-.001-.01zm-3.15-5.96c-.32 1.94-.37 3.92-.12 5.88.24-.03.49-.06.74-.08l-.009-.045c.006-.33.02-.66.04-.98l.009-.045c.01-.19.03-.37.04-.55.03-.38.07-.76.11-1.13.27-.03.54-.05.81-.07-.06-1.57-.34-3.1-.78-4.57-.45.24-.9.46-1.34.69.17.29.33.6.49.9zM7.55 6.013c.09-.16.16-.33.22-.5l.024.009c.81.3 1.63.57 2.47.8l.044.01c-.13.21-.24.42-.36.63.15.54.34 1.07.56 1.58.26-.06.51-.12.77-.18l-.02-.075c-.21-1.01-.35-2.03-.42-3.05l-.04-.03c-.26-.14-.52-.28-.79-.41-.01.21-.01.42-.01.63-.8-.34-1.61-.64-2.43-.91-.12.49-.21.98-.27 1.48l.019.01zm-3.07 9.85c.66 1.05 1.46 1.99 2.37 2.79.16-.14.33-.27.49-.41-.1-.7-.16-1.41-.2-2.12-.53-.13-1.06-.23-1.59-.3-.3.63-.66 1.25-1.07 1.84v-.01-.19zm1.09-3.8c-.81.65-1.53 1.39-2.15 2.22l.02.04c.83.21 1.67.39 2.52.54.08-1.57.43-3.11 1.01-4.56-.27-.08-.54-.15-.81-.22-.22.65-.42 1.31-.59 1.98zm8.68-7.983c-.85-.14-1.72-.21-2.6-.19-.49.94-.85 1.93-1.08 2.96.26.06.52.12.77.19.16-.9.4-1.79.71-2.65.23.01.47.03.7.05.34-.14.67-.3.99-.48l.01-.01c.17.04.33.09.5.13zm5.75 1.92l-.01.07c-1.12.21-2.22.52-3.3.9.5 1.14 1.15 2.21 1.91 3.16.2-.08.41-.15.61-.22-.64-.81-1.2-1.69-1.66-2.61l.01-.06c.4-.2.81-.39 1.23-.57l.01-.07c.39-.21.79-.41 1.2-.6z"
fill={color}
/>
</Svg>
<Image
source={require('../../../assets/simkl-favicon.png')}
style={[
{ width: size, height: size, flex: 1 },
style
]}
resizeMode="cover"
/>
);
};

View file

@ -5,14 +5,15 @@ import Svg, { Path } from 'react-native-svg';
interface TraktIconProps {
size?: number;
color?: string;
style?: any;
}
const TraktIcon: React.FC<TraktIconProps> = ({ size = 24, color = '#ed2224' }) => {
const TraktIcon: React.FC<TraktIconProps> = ({ size = 24, color = '#ed2224', style }) => {
return (
<View style={{ width: size, height: size }}>
<View style={[{ width: size, height: size, flex: 1 }, style]}>
<Svg
width={size}
height={size}
width="100%"
height="100%"
viewBox="0 0 144.8 144.8"
>
<Path

View file

@ -3,7 +3,9 @@ import { AppState, AppStateStatus } from 'react-native';
import {
SimklService,
SimklContentData,
SimklPlaybackData
SimklPlaybackData,
SimklUserSettings,
SimklStats
} from '../services/simklService';
import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
@ -16,6 +18,8 @@ export function useSimklIntegration() {
// Basic lists
const [continueWatching, setContinueWatching] = useState<SimklPlaybackData[]>([]);
const [userSettings, setUserSettings] = useState<SimklUserSettings | null>(null);
const [userStats, setUserStats] = useState<SimklStats | null>(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<boolean> => {
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,
};
}

View file

@ -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 = () => {
</View>
) : isAuthenticated ? (
<View style={styles.profileContainer}>
<Text style={[styles.statusTitle, { color: currentTheme.colors.highEmphasis }]}>
Connected
</Text>
<View style={styles.profileHeader}>
{userSettings?.user?.avatar ? (
<Image
source={{ uri: userSettings.user.avatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatarPlaceholder, { backgroundColor: currentTheme.colors.elevation3 }]}>
<MaterialIcons name="person" size={20} color={currentTheme.colors.mediumEmphasis} />
</View>
)}
<View style={styles.profileText}>
{userSettings?.user && (
<Text style={[styles.statusTitle, { color: currentTheme.colors.highEmphasis }]}>
{userSettings.user.name}
</Text>
)}
{userSettings?.account?.type && (
<Text style={[styles.accountType, { color: currentTheme.colors.mediumEmphasis, textTransform: 'capitalize' }]}>
{userSettings.account.type} Account
</Text>
)}
</View>
</View>
<Text style={[styles.statusDesc, { color: currentTheme.colors.mediumEmphasis }]}>
Your watched items are syncing with Simkl.
</Text>
{userStats && (
<View style={[styles.statsGrid, { borderTopColor: currentTheme.colors.border, borderBottomColor: currentTheme.colors.border }]}>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.movies?.completed?.count || 0}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
Movies
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{(userStats.tv?.watching?.count || 0) + (userStats.tv?.completed?.count || 0)}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
TV Shows
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.anime?.completed?.count || 0}
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
Anime
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{Math.round(((userStats.total_mins || 0) + (userStats.movies?.total_mins || 0) + (userStats.tv?.total_mins || 0) + (userStats.anime?.total_mins || 0)) / 60)}h
</Text>
<Text style={[styles.statLabel, { color: currentTheme.colors.mediumEmphasis }]}>
Watched
</Text>
</View>
</View>
)}
<TouchableOpacity
style={[styles.button, { backgroundColor: currentTheme.colors.error, marginTop: 20 }]}
onPress={handleSignOut}
@ -206,6 +268,14 @@ const SimklSettingsScreen: React.FC = () => {
)}
</View>
<View style={styles.logoSection}>
<Image
source={require('../../assets/simkl-logo.png')}
style={styles.logo}
resizeMode="contain"
/>
</View>
<Text style={[styles.disclaimer, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Nuvio is not affiliated with Simkl.
</Text>
@ -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 },
},
});

View file

@ -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<TraktUser | null>(null);
const [userStats, setUserStats] = useState<TraktUserStats | null>(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() })}
</Text>
{userStats && (
<View style={[styles.statsGrid, { borderTopColor: currentTheme.colors.border, borderBottomColor: currentTheme.colors.border }]}>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.movies?.watched ?? 0}
</Text>
<Text style={[styles.statLabel, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Movies
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.shows?.watched ?? 0}
</Text>
<Text style={[styles.statLabel, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Shows
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{userStats.episodes?.watched ?? 0}
</Text>
<Text style={[styles.statLabel, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Episodes
</Text>
</View>
<View style={styles.statItem}>
<Text style={[styles.statValue, { color: currentTheme.colors.primary }]}>
{Math.round(((userStats.minutes ?? 0) + (userStats.movies?.minutes ?? 0) + (userStats.shows?.minutes ?? 0) + (userStats.episodes?.minutes ?? 0)) / 60)}h
</Text>
<Text style={[styles.statLabel, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
Watched
</Text>
</View>
</View>
)}
</View>
<TouchableOpacity
@ -708,14 +753,35 @@ const styles = StyleSheet.create({
color: '#000',
},
statsContainer: {
marginTop: 16,
paddingTop: 16,
marginTop: 12,
paddingTop: 12,
borderTopWidth: 0.5,
borderTopColor: 'rgba(150,150,150,0.2)',
},
joinedDate: {
fontSize: 14,
},
statsGrid: {
flexDirection: 'row',
justifyContent: 'space-between',
paddingVertical: 14,
marginTop: 12,
borderTopWidth: 1,
borderBottomWidth: 1,
},
statItem: {
alignItems: 'center',
flex: 1,
},
statValue: {
fontSize: 16,
fontWeight: '700',
marginBottom: 4,
},
statLabel: {
fontSize: 11,
fontWeight: '500',
},
settingsSection: {
padding: 20,
},

View file

@ -74,13 +74,51 @@ export interface SimklPlaybackData {
};
episode?: {
season: number;
episode: number;
// Simkl docs show `episode` in playback responses, but some APIs return `number`
episode?: number;
number?: number;
title: string;
tvdb_season?: number;
tvdb_number?: number;
};
}
export interface SimklUserSettings {
user: {
name: string;
joined_at: string;
gender?: string;
avatar: string;
bio?: string;
loc?: string;
age?: string;
};
account: {
id: number;
timezone?: string;
type?: string;
};
connections?: Record<string, boolean>;
}
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<SimklPlaybackData[]> {
// 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<SimklPlaybackData[]> => {
for (const endpoint of endpoints) {
try {
const res = await this.apiRequest<SimklPlaybackData[]>(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<SimklPlaybackData[]>('/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<SimklUserSettings | null> {
try {
const response = await this.apiRequest<SimklUserSettings>('/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<SimklStats | null> {
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<SimklStats>(`/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;
}
}}

View file

@ -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<TraktUser>('/users/me?extended=full');
}
/**
* Get the user's watch stats
*/
public async getUserStats(): Promise<TraktUserStats> {
return this.apiRequest<TraktUserStats>('/users/me/stats');
}
/**
* Get the user's watched movies
*/