mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-26 11:02:53 +00:00
ui changes
This commit is contained in:
parent
25e1102832
commit
ea2debb9dd
11 changed files with 623 additions and 45 deletions
BIN
assets/simkl-favicon.png
Normal file
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
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
0
assets/trakt-favicon.png
Normal 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 {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue