added simkl watchlists

This commit is contained in:
tapframe 2026-01-26 12:53:14 +05:30
parent f4bd44d3e0
commit c36210e9c2
5 changed files with 1453 additions and 77 deletions

17
App.tsx
View file

@ -30,6 +30,7 @@ import 'react-native-reanimated';
import { CatalogProvider } from './src/contexts/CatalogContext';
import { GenreProvider } from './src/contexts/GenreContext';
import { TraktProvider } from './src/contexts/TraktContext';
import { SimklProvider } from './src/contexts/SimklContext';
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
import { TrailerProvider } from './src/contexts/TrailerContext';
import { DownloadsProvider } from './src/contexts/DownloadsContext';
@ -263,13 +264,15 @@ function App(): React.JSX.Element {
<GenreProvider>
<CatalogProvider>
<TraktProvider>
<ThemeProvider>
<TrailerProvider>
<ToastProvider>
<ThemedApp />
</ToastProvider>
</TrailerProvider>
</ThemeProvider>
<SimklProvider>
<ThemeProvider>
<TrailerProvider>
<ToastProvider>
<ThemedApp />
</ToastProvider>
</TrailerProvider>
</ThemeProvider>
</SimklProvider>
</TraktProvider>
</CatalogProvider>
</GenreProvider>

View file

@ -0,0 +1,85 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useSimklIntegration } from '../hooks/useSimklIntegration';
import {
SimklWatchlistItem,
SimklPlaybackData,
SimklRatingItem,
SimklUserSettings,
SimklStats,
SimklStatus
} from '../services/simklService';
export interface SimklContextProps {
// Authentication
isAuthenticated: boolean;
isLoading: boolean;
userSettings: SimklUserSettings | null;
userStats: SimklStats | null;
// Collections - Shows
watchingShows: SimklWatchlistItem[];
planToWatchShows: SimklWatchlistItem[];
completedShows: SimklWatchlistItem[];
onHoldShows: SimklWatchlistItem[];
droppedShows: SimklWatchlistItem[];
// Collections - Movies
watchingMovies: SimklWatchlistItem[];
planToWatchMovies: SimklWatchlistItem[];
completedMovies: SimklWatchlistItem[];
onHoldMovies: SimklWatchlistItem[];
droppedMovies: SimklWatchlistItem[];
// Collections - Anime
watchingAnime: SimklWatchlistItem[];
planToWatchAnime: SimklWatchlistItem[];
completedAnime: SimklWatchlistItem[];
onHoldAnime: SimklWatchlistItem[];
droppedAnime: SimklWatchlistItem[];
// Special collections
continueWatching: SimklPlaybackData[];
ratedContent: SimklRatingItem[];
// Lookup Sets (for O(1) status checks)
watchingSet: Set<string>;
planToWatchSet: Set<string>;
completedSet: Set<string>;
onHoldSet: Set<string>;
droppedSet: Set<string>;
// Methods
checkAuthStatus: () => Promise<void>;
refreshAuthStatus: () => Promise<void>;
loadAllCollections: () => Promise<void>;
addToStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => Promise<boolean>;
removeFromStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => Promise<boolean>;
isInStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => boolean;
// Scrobbling methods (from existing hook)
startWatching?: (content: any, progress: number) => Promise<boolean>;
updateProgress?: (content: any, progress: number) => Promise<boolean>;
stopWatching?: (content: any, progress: number) => Promise<boolean>;
syncAllProgress?: () => Promise<boolean>;
fetchAndMergeSimklProgress?: () => Promise<boolean>;
}
const SimklContext = createContext<SimklContextProps | undefined>(undefined);
export function SimklProvider({ children }: { children: ReactNode }) {
const simklIntegration = useSimklIntegration();
return (
<SimklContext.Provider value={simklIntegration}>
{children}
</SimklContext.Provider>
);
}
export function useSimklContext() {
const context = useContext(SimklContext);
if (context === undefined) {
throw new Error('useSimklContext must be used within a SimklProvider');
}
return context;
}

View file

@ -6,36 +6,106 @@ import {
SimklPlaybackData,
SimklUserSettings,
SimklStats,
SimklActivities
SimklActivities,
SimklWatchlistItem,
SimklRatingItem,
SimklStatus
} from '../services/simklService';
import { storageService } from '../services/storageService';
import { mmkvStorage } from '../services/mmkvStorage';
import { logger } from '../utils/logger';
const simklService = SimklService.getInstance();
// Cache keys
const SIMKL_ACTIVITIES_CACHE = '@simkl:activities';
const SIMKL_COLLECTIONS_CACHE = '@simkl:collections';
const SIMKL_CACHE_TIMESTAMP = '@simkl:cache_timestamp';
let hasLoadedProfileOnce = false;
let cachedUserSettings: SimklUserSettings | null = null;
let cachedUserStats: SimklStats | null = null;
interface CollectionsCache {
timestamp: number;
watchingShows: SimklWatchlistItem[];
watchingMovies: SimklWatchlistItem[];
watchingAnime: SimklWatchlistItem[];
planToWatchShows: SimklWatchlistItem[];
planToWatchMovies: SimklWatchlistItem[];
planToWatchAnime: SimklWatchlistItem[];
completedShows: SimklWatchlistItem[];
completedMovies: SimklWatchlistItem[];
completedAnime: SimklWatchlistItem[];
onHoldShows: SimklWatchlistItem[];
onHoldMovies: SimklWatchlistItem[];
onHoldAnime: SimklWatchlistItem[];
droppedShows: SimklWatchlistItem[];
droppedMovies: SimklWatchlistItem[];
droppedAnime: SimklWatchlistItem[];
continueWatching: SimklPlaybackData[];
ratedContent: SimklRatingItem[];
}
export function useSimklIntegration() {
// Authentication state
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
// Basic lists
const [continueWatching, setContinueWatching] = useState<SimklPlaybackData[]>([]);
const [userSettings, setUserSettings] = useState<SimklUserSettings | null>(() => cachedUserSettings);
const [userStats, setUserStats] = useState<SimklStats | null>(() => cachedUserStats);
// Collection state - Shows
const [watchingShows, setWatchingShows] = useState<SimklWatchlistItem[]>([]);
const [planToWatchShows, setPlanToWatchShows] = useState<SimklWatchlistItem[]>([]);
const [completedShows, setCompletedShows] = useState<SimklWatchlistItem[]>([]);
const [onHoldShows, setOnHoldShows] = useState<SimklWatchlistItem[]>([]);
const [droppedShows, setDroppedShows] = useState<SimklWatchlistItem[]>([]);
// Collection state - Movies
const [watchingMovies, setWatchingMovies] = useState<SimklWatchlistItem[]>([]);
const [planToWatchMovies, setPlanToWatchMovies] = useState<SimklWatchlistItem[]>([]);
const [completedMovies, setCompletedMovies] = useState<SimklWatchlistItem[]>([]);
const [onHoldMovies, setOnHoldMovies] = useState<SimklWatchlistItem[]>([]);
const [droppedMovies, setDroppedMovies] = useState<SimklWatchlistItem[]>([]);
// Collection state - Anime
const [watchingAnime, setWatchingAnime] = useState<SimklWatchlistItem[]>([]);
const [planToWatchAnime, setPlanToWatchAnime] = useState<SimklWatchlistItem[]>([]);
const [completedAnime, setCompletedAnime] = useState<SimklWatchlistItem[]>([]);
const [onHoldAnime, setOnHoldAnime] = useState<SimklWatchlistItem[]>([]);
const [droppedAnime, setDroppedAnime] = useState<SimklWatchlistItem[]>([]);
// Special collections
const [continueWatching, setContinueWatching] = useState<SimklPlaybackData[]>([]);
const [ratedContent, setRatedContent] = useState<SimklRatingItem[]>([]);
// Lookup Sets for O(1) status checks (combined across types)
const [watchingSet, setWatchingSet] = useState<Set<string>>(new Set());
const [planToWatchSet, setPlanToWatchSet] = useState<Set<string>>(new Set());
const [completedSet, setCompletedSet] = useState<Set<string>>(new Set());
const [onHoldSet, setOnHoldSet] = useState<Set<string>>(new Set());
const [droppedSet, setDroppedSet] = useState<Set<string>>(new Set());
// Activity tracking for caching
const [lastActivityCheck, setLastActivityCheck] = useState<SimklActivities | null>(null);
const lastPlaybackFetchAt = useRef(0);
const lastActivitiesCheckAt = useRef(0);
const lastPlaybackActivityAt = useRef<number | null>(null);
// Helper: Normalize IMDB ID
const normalizeImdbId = (imdbId: string): string => {
return imdbId.replace('tt', '');
};
// Helper: Parse activity date
const parseActivityDate = (value?: string): number | null => {
if (!value) return null;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? null : parsed;
};
// Helper: Get latest playback activity timestamp
const getLatestPlaybackActivity = (activities: SimklActivities | null): number | null => {
if (!activities) return null;
@ -55,6 +125,127 @@ export function useSimklIntegration() {
return Math.max(...timestamps);
};
// Helper: Build lookup Sets
const buildLookupSets = useCallback((
watchingItems: SimklWatchlistItem[],
planItems: SimklWatchlistItem[],
completedItems: SimklWatchlistItem[],
holdItems: SimklWatchlistItem[],
droppedItems: SimklWatchlistItem[]
) => {
const buildSet = (items: SimklWatchlistItem[]): Set<string> => {
const set = new Set<string>();
items.forEach(item => {
const content = item.show || item.movie || item.anime;
if (content?.ids?.imdb) {
const type = item.show ? 'show' : item.movie ? 'movie' : 'anime';
const key = `${type}:${normalizeImdbId(content.ids.imdb)}`;
set.add(key);
}
});
return set;
};
setWatchingSet(buildSet(watchingItems));
setPlanToWatchSet(buildSet(planItems));
setCompletedSet(buildSet(completedItems));
setOnHoldSet(buildSet(holdItems));
setDroppedSet(buildSet(droppedItems));
}, []);
// Load collections from cache
const loadFromCache = useCallback(async (): Promise<boolean> => {
try {
const cachedData = await mmkvStorage.getItem(SIMKL_COLLECTIONS_CACHE);
if (!cachedData) return false;
const cache: CollectionsCache = JSON.parse(cachedData);
// Check cache age (5 minutes)
const age = Date.now() - cache.timestamp;
if (age > 5 * 60 * 1000) {
logger.log('[useSimklIntegration] Cache expired');
return false;
}
// Debug: Log cache sample to check poster data
if (cache.watchingShows && cache.watchingShows.length > 0) {
logger.log('[useSimklIntegration] Cache sample - first watching show:', JSON.stringify(cache.watchingShows[0], null, 2));
}
if (cache.watchingMovies && cache.watchingMovies.length > 0) {
logger.log('[useSimklIntegration] Cache sample - first watching movie:', JSON.stringify(cache.watchingMovies[0], null, 2));
}
// Load into state
setWatchingShows(cache.watchingShows || []);
setWatchingMovies(cache.watchingMovies || []);
setWatchingAnime(cache.watchingAnime || []);
setPlanToWatchShows(cache.planToWatchShows || []);
setPlanToWatchMovies(cache.planToWatchMovies || []);
setPlanToWatchAnime(cache.planToWatchAnime || []);
setCompletedShows(cache.completedShows || []);
setCompletedMovies(cache.completedMovies || []);
setCompletedAnime(cache.completedAnime || []);
setOnHoldShows(cache.onHoldShows || []);
setOnHoldMovies(cache.onHoldMovies || []);
setOnHoldAnime(cache.onHoldAnime || []);
setDroppedShows(cache.droppedShows || []);
setDroppedMovies(cache.droppedMovies || []);
setDroppedAnime(cache.droppedAnime || []);
setContinueWatching(cache.continueWatching || []);
setRatedContent(cache.ratedContent || []);
// Build lookup Sets
buildLookupSets(
[...cache.watchingShows, ...cache.watchingMovies, ...cache.watchingAnime],
[...cache.planToWatchShows, ...cache.planToWatchMovies, ...cache.planToWatchAnime],
[...cache.completedShows, ...cache.completedMovies, ...cache.completedAnime],
[...cache.onHoldShows, ...cache.onHoldMovies, ...cache.onHoldAnime],
[...cache.droppedShows, ...cache.droppedMovies, ...cache.droppedAnime]
);
logger.log('[useSimklIntegration] Loaded from cache');
return true;
} catch (error) {
logger.error('[useSimklIntegration] Failed to load from cache:', error);
return false;
}
}, [buildLookupSets]);
// Save collections to cache
const saveToCache = useCallback(async (collections: Omit<CollectionsCache, 'timestamp'>) => {
try {
const cache: CollectionsCache = {
...collections,
timestamp: Date.now()
};
await mmkvStorage.setItem(SIMKL_COLLECTIONS_CACHE, JSON.stringify(cache));
logger.log('[useSimklIntegration] Saved to cache');
} catch (error) {
logger.error('[useSimklIntegration] Failed to save to cache:', error);
}
}, []);
// Compare activities to check if refresh needed
const compareActivities = useCallback((
newActivities: SimklActivities | null,
cachedActivities: SimklActivities | null
): boolean => {
if (!cachedActivities) return true;
if (!newActivities) return false;
// Compare timestamps
const newAll = parseActivityDate(newActivities.all);
const cachedAll = parseActivityDate(cachedActivities.all);
if (newAll && cachedAll && newAll > cachedAll) {
return true;
}
return false;
}, []);
// Check authentication status
const checkAuthStatus = useCallback(async () => {
setIsLoading(true);
@ -73,7 +264,262 @@ export function useSimklIntegration() {
await checkAuthStatus();
}, [checkAuthStatus]);
// Load playback/continue watching
// Load all collections (main data loading method)
const loadAllCollections = useCallback(async () => {
if (!isAuthenticated) {
logger.log('[useSimklIntegration] Cannot load collections: not authenticated');
return;
}
setIsLoading(true);
try {
// 1. Check activities first (efficient timestamp check)
const activities = await simklService.getActivities();
// 2. Try to load from cache if activities haven't changed
const cachedActivitiesStr = await mmkvStorage.getItem(SIMKL_ACTIVITIES_CACHE);
const cachedActivities: SimklActivities | null = cachedActivitiesStr ? JSON.parse(cachedActivitiesStr) : null;
const needsRefresh = compareActivities(activities, cachedActivities);
if (!needsRefresh && cachedActivities) {
const cacheLoaded = await loadFromCache();
if (cacheLoaded) {
setLastActivityCheck(activities);
logger.log('[useSimklIntegration] Using cached collections');
return;
}
}
logger.log('[useSimklIntegration] Fetching fresh collections from API');
// 3. Fetch all collections in parallel
const [
watchingShowsData,
watchingMoviesData,
watchingAnimeData,
planToWatchShowsData,
planToWatchMoviesData,
planToWatchAnimeData,
completedShowsData,
completedMoviesData,
completedAnimeData,
onHoldShowsData,
onHoldMoviesData,
onHoldAnimeData,
droppedShowsData,
droppedMoviesData,
droppedAnimeData,
continueWatchingData,
ratingsData
] = await Promise.all([
simklService.getAllItems('shows', 'watching'),
simklService.getAllItems('movies', 'watching'),
simklService.getAllItems('anime', 'watching'),
simklService.getAllItems('shows', 'plantowatch'),
simklService.getAllItems('movies', 'plantowatch'),
simklService.getAllItems('anime', 'plantowatch'),
simklService.getAllItems('shows', 'completed'),
simklService.getAllItems('movies', 'completed'),
simklService.getAllItems('anime', 'completed'),
simklService.getAllItems('shows', 'hold'),
simklService.getAllItems('movies', 'hold'),
simklService.getAllItems('anime', 'hold'),
simklService.getAllItems('shows', 'dropped'),
simklService.getAllItems('movies', 'dropped'),
simklService.getAllItems('anime', 'dropped'),
simklService.getPlaybackStatus(),
simklService.getRatings()
]);
// 4. Update state
setWatchingShows(watchingShowsData);
setWatchingMovies(watchingMoviesData);
setWatchingAnime(watchingAnimeData);
setPlanToWatchShows(planToWatchShowsData);
setPlanToWatchMovies(planToWatchMoviesData);
setPlanToWatchAnime(planToWatchAnimeData);
setCompletedShows(completedShowsData);
setCompletedMovies(completedMoviesData);
setCompletedAnime(completedAnimeData);
setOnHoldShows(onHoldShowsData);
setOnHoldMovies(onHoldMoviesData);
setOnHoldAnime(onHoldAnimeData);
setDroppedShows(droppedShowsData);
setDroppedMovies(droppedMoviesData);
setDroppedAnime(droppedAnimeData);
setContinueWatching(continueWatchingData);
setRatedContent(ratingsData);
// 5. Build lookup Sets
buildLookupSets(
[...watchingShowsData, ...watchingMoviesData, ...watchingAnimeData],
[...planToWatchShowsData, ...planToWatchMoviesData, ...planToWatchAnimeData],
[...completedShowsData, ...completedMoviesData, ...completedAnimeData],
[...onHoldShowsData, ...onHoldMoviesData, ...onHoldAnimeData],
[...droppedShowsData, ...droppedMoviesData, ...droppedAnimeData]
);
// 6. Cache everything
await saveToCache({
watchingShows: watchingShowsData,
watchingMovies: watchingMoviesData,
watchingAnime: watchingAnimeData,
planToWatchShows: planToWatchShowsData,
planToWatchMovies: planToWatchMoviesData,
planToWatchAnime: planToWatchAnimeData,
completedShows: completedShowsData,
completedMovies: completedMoviesData,
completedAnime: completedAnimeData,
onHoldShows: onHoldShowsData,
onHoldMovies: onHoldMoviesData,
onHoldAnime: onHoldAnimeData,
droppedShows: droppedShowsData,
droppedMovies: droppedMoviesData,
droppedAnime: droppedAnimeData,
continueWatching: continueWatchingData,
ratedContent: ratingsData
});
// Save activities
if (activities) {
await mmkvStorage.setItem(SIMKL_ACTIVITIES_CACHE, JSON.stringify(activities));
setLastActivityCheck(activities);
}
logger.log('[useSimklIntegration] Collections loaded successfully');
} catch (error) {
logger.error('[useSimklIntegration] Error loading collections:', error);
} finally {
setIsLoading(false);
}
}, [isAuthenticated, buildLookupSets, compareActivities, loadFromCache, saveToCache]);
// Status management methods
const addToStatus = useCallback(async (
imdbId: string,
type: 'movie' | 'show' | 'anime',
status: SimklStatus
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const success = await simklService.addToList(imdbId, type, status);
if (success) {
// Optimistic Set update
const normalizedId = normalizeImdbId(imdbId);
const key = `${type}:${normalizedId}`;
// Update appropriate Set
switch (status) {
case 'watching':
setWatchingSet(prev => new Set(prev).add(key));
break;
case 'plantowatch':
setPlanToWatchSet(prev => new Set(prev).add(key));
break;
case 'completed':
setCompletedSet(prev => new Set(prev).add(key));
break;
case 'hold':
setOnHoldSet(prev => new Set(prev).add(key));
break;
case 'dropped':
setDroppedSet(prev => new Set(prev).add(key));
break;
}
// Reload collections to get fresh data
setTimeout(() => loadAllCollections(), 1000);
}
return success;
} catch (error) {
logger.error('[useSimklIntegration] Error adding to status:', error);
return false;
}
}, [isAuthenticated, loadAllCollections]);
const removeFromStatus = useCallback(async (
imdbId: string,
type: 'movie' | 'show' | 'anime',
status: SimklStatus
): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const success = await simklService.removeFromList(imdbId, type);
if (success) {
// Optimistic Set update
const normalizedId = normalizeImdbId(imdbId);
const key = `${type}:${normalizedId}`;
// Remove from all Sets
setWatchingSet(prev => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
setPlanToWatchSet(prev => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
setCompletedSet(prev => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
setOnHoldSet(prev => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
setDroppedSet(prev => {
const newSet = new Set(prev);
newSet.delete(key);
return newSet;
});
// Reload collections
setTimeout(() => loadAllCollections(), 1000);
}
return success;
} catch (error) {
logger.error('[useSimklIntegration] Error removing from status:', error);
return false;
}
}, [isAuthenticated, loadAllCollections]);
const isInStatus = useCallback((
imdbId: string,
type: 'movie' | 'show' | 'anime',
status: SimklStatus
): boolean => {
const normalizedId = normalizeImdbId(imdbId);
const key = `${type}:${normalizedId}`;
switch (status) {
case 'watching':
return watchingSet.has(key);
case 'plantowatch':
return planToWatchSet.has(key);
case 'completed':
return completedSet.has(key);
case 'hold':
return onHoldSet.has(key);
case 'dropped':
return droppedSet.has(key);
default:
return false;
}
}, [watchingSet, planToWatchSet, completedSet, onHoldSet, droppedSet]);
// Load playback/continue watching (kept from original)
const loadPlaybackStatus = useCallback(async () => {
if (!isAuthenticated) return;
try {
@ -84,7 +530,7 @@ export function useSimklIntegration() {
}
}, [isAuthenticated]);
// Load user settings and stats
// Load user settings and stats (kept from original)
const loadUserProfile = useCallback(async () => {
if (!isAuthenticated) return;
try {
@ -106,7 +552,7 @@ export function useSimklIntegration() {
}
}, [isAuthenticated]);
// Start watching (scrobble start)
// Scrobbling methods (kept from original)
const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
@ -118,7 +564,6 @@ export function useSimklIntegration() {
}
}, [isAuthenticated]);
// Update progress (scrobble pause)
const updateProgress = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
@ -130,7 +575,6 @@ export function useSimklIntegration() {
}
}, [isAuthenticated]);
// Stop watching (scrobble stop)
const stopWatching = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
@ -142,16 +586,12 @@ export function useSimklIntegration() {
}
}, [isAuthenticated]);
// Sync All Local Progress -> Simkl
// Sync methods (kept from original)
const syncAllProgress = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) return false;
try {
const unsynced = await storageService.getUnsyncedProgress();
// Filter for items that specifically need SIMKL sync (unsynced.filter(i => !i.progress.simklSynced...))
// storageService.getUnsyncedProgress currently returns items that need Trakt OR Simkl sync.
// We should check simklSynced specifically here.
const itemsToSync = unsynced.filter(i => !i.progress.simklSynced || (i.progress.simklLastSynced && i.progress.lastUpdated > i.progress.simklLastSynced));
if (itemsToSync.length === 0) return true;
@ -163,10 +603,9 @@ export function useSimklIntegration() {
const season = item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined;
const episode = item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined;
// Construct content data
const content: SimklContentData = {
type: item.type === 'series' ? 'episode' : 'movie',
title: 'Unknown', // Ideally storage has title, but it might not. Simkl needs IDs mainly.
title: 'Unknown',
ids: { imdb: item.id },
season,
episode
@ -174,21 +613,15 @@ export function useSimklIntegration() {
const progressPercent = (item.progress.currentTime / item.progress.duration) * 100;
// If completed (>=80% or 95% depending on logic, let's say 85% safe), add to history
// Simkl: Stop with >= 80% marks as watched.
// Or explicitly add to history.
let success = false;
if (progressPercent >= 85) {
// Add to history
if (content.type === 'movie') {
await simklService.addToHistory({ movies: [{ ids: { imdb: item.id } }] });
} else {
await simklService.addToHistory({ shows: [{ ids: { imdb: item.id }, seasons: [{ number: season, episodes: [{ number: episode }] }] }] });
}
success = true; // Assume success if no throw
success = true;
} else {
// Pause (scrobble)
const res = await simklService.scrobblePause(content, progressPercent);
success = !!res;
}
@ -207,7 +640,6 @@ export function useSimklIntegration() {
}
}, [isAuthenticated]);
// Fetch Simkl -> Merge Local
const fetchAndMergeSimklProgress = useCallback(async (): Promise<boolean> => {
if (!isAuthenticated) return false;
@ -263,7 +695,6 @@ export function useSimklIntegration() {
episodeId
);
// Mark as synced locally so we don't push it back
await storageService.updateSimklSyncStatus(id, type!, true, item.progress, episodeId);
}
}
@ -272,7 +703,7 @@ export function useSimklIntegration() {
logger.error('[useSimklIntegration] Error fetching/merging Simkl progress', e);
return false;
}
}, [isAuthenticated]);
}, [isAuthenticated, getLatestPlaybackActivity]);
// Effects
useEffect(() => {
@ -287,7 +718,7 @@ export function useSimklIntegration() {
loadUserProfile();
}
}
}, [isAuthenticated, loadPlaybackStatus, fetchAndMergeSimklProgress, loadUserProfile]);
}, [isAuthenticated, fetchAndMergeSimklProgress, loadUserProfile]);
// App state listener for sync
useEffect(() => {
@ -295,24 +726,64 @@ export function useSimklIntegration() {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') {
fetchAndMergeSimklProgress();
loadAllCollections();
}
});
return () => sub.remove();
}, [isAuthenticated, fetchAndMergeSimklProgress]);
}, [isAuthenticated, fetchAndMergeSimklProgress, loadAllCollections]);
return {
// Authentication
isAuthenticated,
isLoading,
userSettings,
userStats,
checkAuthStatus,
refreshAuthStatus,
// Collections - Shows
watchingShows,
planToWatchShows,
completedShows,
onHoldShows,
droppedShows,
// Collections - Movies
watchingMovies,
planToWatchMovies,
completedMovies,
onHoldMovies,
droppedMovies,
// Collections - Anime
watchingAnime,
planToWatchAnime,
completedAnime,
onHoldAnime,
droppedAnime,
// Special collections
continueWatching,
ratedContent,
// Lookup Sets
watchingSet,
planToWatchSet,
completedSet,
onHoldSet,
droppedSet,
// Methods
loadAllCollections,
addToStatus,
removeFromStatus,
isInStatus,
// Scrobbling (kept from original)
startWatching,
updateProgress,
stopWatching,
syncAllProgress,
fetchAndMergeSimklProgress,
continueWatching,
userSettings,
userStats,
};
}

View file

@ -19,6 +19,7 @@ import {
Platform,
ScrollView,
BackHandler,
Image,
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useNavigation } from '@react-navigation/native';
@ -34,6 +35,7 @@ import { logger } from '../utils/logger';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../contexts/ThemeContext';
import { useTraktContext } from '../contexts/TraktContext';
import { useSimklContext } from '../contexts/SimklContext';
import TraktIcon from '../../assets/rating-icons/trakt.svg';
import { traktService, TraktService, TraktImages } from '../services/traktService';
import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner';
@ -87,6 +89,8 @@ function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: nu
return { numColumns, itemWidth };
}
import { TMDBService } from '../services/tmdbService';
const TraktItem = React.memo(({
item,
width,
@ -109,12 +113,47 @@ const TraktItem = React.memo(({
const url = TraktService.getTraktPosterUrl(item.images);
if (isMounted && url) {
setPosterUrl(url);
return;
}
}
if (item.imdbId || item.traktId) {
try {
const tmdbService = TMDBService.getInstance();
let tmdbId: number | null = null;
if (item.imdbId) {
tmdbId = await tmdbService.findTMDBIdByIMDB(item.imdbId);
}
if (!tmdbId && item.traktId) {
}
if (tmdbId) {
let posterPath: string | null = null;
if (item.type === 'movie') {
const details = await tmdbService.getMovieDetails(String(tmdbId));
posterPath = details?.poster_path ?? null;
} else {
const details = await tmdbService.getTVShowDetails(tmdbId);
posterPath = details?.poster_path ?? null;
}
if (isMounted && posterPath) {
const url = tmdbService.getImageUrl(posterPath, 'w500');
setPosterUrl(url);
}
}
} catch (error) {
logger.debug('Failed to fetch poster from TMDB', error);
}
}
};
fetchPoster();
return () => { isMounted = false; };
}, [item.images]);
}, [item.images, item.imdbId, item.traktId, item.type]);
const handlePress = useCallback(() => {
if (item.imdbId) {
@ -219,9 +258,11 @@ const LibraryScreen = () => {
const { numColumns, itemWidth } = useMemo(() => getGridLayout(width), [width]);
const [loading, setLoading] = useState(true);
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
const [filter, setFilter] = useState<'trakt' | 'movies' | 'series'>('movies');
const [filter, setFilter] = useState<'trakt' | 'simkl' | 'movies' | 'series'>('movies');
const [showTraktContent, setShowTraktContent] = useState(false);
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
const [showSimklContent, setShowSimklContent] = useState(false);
const [selectedSimklFolder, setSelectedSimklFolder] = useState<string | null>(null);
const { showInfo, showError } = useToast();
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
@ -230,7 +271,6 @@ const LibraryScreen = () => {
const { settings } = useSettings();
const flashListRef = useRef<any>(null);
// Scroll to top handler
const scrollToTop = useCallback(() => {
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
}, []);
@ -252,6 +292,29 @@ const LibraryScreen = () => {
loadAllCollections
} = useTraktContext();
const {
isAuthenticated: simklAuthenticated,
isLoading: simklLoading,
watchingShows,
watchingMovies,
watchingAnime,
planToWatchShows,
planToWatchMovies,
planToWatchAnime,
completedShows,
completedMovies,
completedAnime,
onHoldShows,
onHoldMovies,
onHoldAnime,
droppedShows,
droppedMovies,
droppedAnime,
continueWatching: simklContinueWatching,
ratedContent: simklRatedContent,
loadAllCollections: loadSimklCollections
} = useSimklContext();
useEffect(() => {
const applyStatusBarConfig = () => {
StatusBar.setBarStyle('light-content');
@ -276,12 +339,20 @@ const LibraryScreen = () => {
}
return true;
}
if (showSimklContent) {
if (selectedSimklFolder) {
setSelectedSimklFolder(null);
} else {
setShowSimklContent(false);
}
return true;
}
return false;
};
const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction);
return () => backHandler.remove();
}, [showTraktContent, selectedTraktFolder]);
}, [showTraktContent, showSimklContent, selectedTraktFolder, selectedSimklFolder]);
useEffect(() => {
const loadLibrary = async () => {
@ -396,6 +467,117 @@ const LibraryScreen = () => {
return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const simklFolders = useMemo((): TraktFolder[] => {
if (!simklAuthenticated) return [];
const folders: TraktFolder[] = [
{
id: 'continue-watching',
name: t('library.continue'),
icon: 'play-circle-outline',
itemCount: simklContinueWatching?.length || 0,
},
{
id: 'watching-shows',
name: 'Watching Shows',
icon: 'visibility',
itemCount: watchingShows?.length || 0,
},
{
id: 'watching-movies',
name: 'Watching Movies',
icon: 'visibility',
itemCount: watchingMovies?.length || 0,
},
{
id: 'watching-anime',
name: 'Watching Anime',
icon: 'visibility',
itemCount: watchingAnime?.length || 0,
},
{
id: 'plantowatch-shows',
name: 'Plan to Watch Shows',
icon: 'bookmark-border',
itemCount: planToWatchShows?.length || 0,
},
{
id: 'plantowatch-movies',
name: 'Plan to Watch Movies',
icon: 'bookmark-border',
itemCount: planToWatchMovies?.length || 0,
},
{
id: 'plantowatch-anime',
name: 'Plan to Watch Anime',
icon: 'bookmark-border',
itemCount: planToWatchAnime?.length || 0,
},
{
id: 'completed-shows',
name: 'Completed Shows',
icon: 'check-circle',
itemCount: completedShows?.length || 0,
},
{
id: 'completed-movies',
name: 'Completed Movies',
icon: 'check-circle',
itemCount: completedMovies?.length || 0,
},
{
id: 'completed-anime',
name: 'Completed Anime',
icon: 'check-circle',
itemCount: completedAnime?.length || 0,
},
{
id: 'onhold-shows',
name: 'On Hold Shows',
icon: 'pause-circle-outline',
itemCount: onHoldShows?.length || 0,
},
{
id: 'onhold-movies',
name: 'On Hold Movies',
icon: 'pause-circle-outline',
itemCount: onHoldMovies?.length || 0,
},
{
id: 'onhold-anime',
name: 'On Hold Anime',
icon: 'pause-circle-outline',
itemCount: onHoldAnime?.length || 0,
},
{
id: 'dropped-shows',
name: 'Dropped Shows',
icon: 'cancel',
itemCount: droppedShows?.length || 0,
},
{
id: 'dropped-movies',
name: 'Dropped Movies',
icon: 'cancel',
itemCount: droppedMovies?.length || 0,
},
{
id: 'dropped-anime',
name: 'Dropped Anime',
icon: 'cancel',
itemCount: droppedAnime?.length || 0,
},
{
id: 'ratings',
name: t('library.rated'),
icon: 'star',
itemCount: simklRatedContent?.length || 0,
}
];
return folders.filter(folder => folder.itemCount > 0);
}, [simklAuthenticated, watchingShows, watchingMovies, watchingAnime, planToWatchShows, planToWatchMovies, planToWatchAnime, completedShows, completedMovies, completedAnime, onHoldShows, onHoldMovies, onHoldAnime, droppedShows, droppedMovies, droppedAnime, simklContinueWatching, simklRatedContent, t]);
const renderItem = ({ item }: { item: LibraryItem }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
@ -712,6 +894,233 @@ const LibraryScreen = () => {
});
}, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const getSimklFolderItems = useCallback((folderId: string): TraktDisplayItem[] => {
const items: TraktDisplayItem[] = [];
switch (folderId) {
case 'continue-watching':
return (simklContinueWatching || []).map(item => {
const content = item.show || item.movie;
return {
id: String(content?.ids?.simkl || Math.random()),
name: content?.title || 'Unknown',
type: item.show ? 'series' : 'movie',
poster: '',
year: content?.year,
lastWatched: item.paused_at,
imdbId: content?.ids?.imdb,
traktId: content?.ids?.simkl || 0,
};
});
case 'watching-shows':
return (watchingShows || []).map(item => ({
id: String(item.show?.ids?.simkl || Math.random()),
name: item.show?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.show?.year,
lastWatched: item.last_watched_at,
rating: item.user_rating,
imdbId: item.show?.ids?.imdb,
traktId: item.show?.ids?.simkl || 0,
}));
case 'watching-movies':
return (watchingMovies || []).map(item => ({
id: String(item.movie?.ids?.simkl || Math.random()),
name: item.movie?.title || 'Unknown',
type: 'movie' as const,
poster: '',
year: item.movie?.year,
lastWatched: item.last_watched_at,
rating: item.user_rating,
imdbId: item.movie?.ids?.imdb,
traktId: item.movie?.ids?.simkl || 0,
}));
case 'watching-anime':
return (watchingAnime || []).map(item => ({
id: String(item.anime?.ids?.simkl || Math.random()),
name: item.anime?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.anime?.year,
lastWatched: item.last_watched_at,
rating: item.user_rating,
imdbId: item.anime?.ids?.imdb,
traktId: item.anime?.ids?.simkl || 0,
}));
case 'plantowatch-shows':
return (planToWatchShows || []).map(item => ({
id: String(item.show?.ids?.simkl || Math.random()),
name: item.show?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.show?.year,
lastWatched: item.added_to_watchlist_at,
imdbId: item.show?.ids?.imdb,
traktId: item.show?.ids?.simkl || 0,
}));
case 'plantowatch-movies':
return (planToWatchMovies || []).map(item => ({
id: String(item.movie?.ids?.simkl || Math.random()),
name: item.movie?.title || 'Unknown',
type: 'movie' as const,
poster: '',
year: item.movie?.year,
lastWatched: item.added_to_watchlist_at,
imdbId: item.movie?.ids?.imdb,
traktId: item.movie?.ids?.simkl || 0,
}));
case 'plantowatch-anime':
return (planToWatchAnime || []).map(item => ({
id: String(item.anime?.ids?.simkl || Math.random()),
name: item.anime?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.anime?.year,
lastWatched: item.added_to_watchlist_at,
imdbId: item.anime?.ids?.imdb,
traktId: item.anime?.ids?.simkl || 0,
}));
case 'completed-shows':
return (completedShows || []).map(item => ({
id: String(item.show?.ids?.simkl || Math.random()),
name: item.show?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.show?.year,
lastWatched: item.last_watched_at,
imdbId: item.show?.ids?.imdb,
traktId: item.show?.ids?.simkl || 0,
}));
case 'completed-movies':
return (completedMovies || []).map(item => ({
id: String(item.movie?.ids?.simkl || Math.random()),
name: item.movie?.title || 'Unknown',
type: 'movie' as const,
poster: '',
year: item.movie?.year,
lastWatched: item.last_watched_at,
imdbId: item.movie?.ids?.imdb,
traktId: item.movie?.ids?.simkl || 0,
}));
case 'completed-anime':
return (completedAnime || []).map(item => ({
id: String(item.anime?.ids?.simkl || Math.random()),
name: item.anime?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.anime?.year,
lastWatched: item.last_watched_at,
imdbId: item.anime?.ids?.imdb,
traktId: item.anime?.ids?.simkl || 0,
}));
case 'onhold-shows':
return (onHoldShows || []).map(item => ({
id: String(item.show?.ids?.simkl || Math.random()),
name: item.show?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.show?.year,
lastWatched: item.last_watched_at,
imdbId: item.show?.ids?.imdb,
traktId: item.show?.ids?.simkl || 0,
}));
case 'onhold-movies':
return (onHoldMovies || []).map(item => ({
id: String(item.movie?.ids?.simkl || Math.random()),
name: item.movie?.title || 'Unknown',
type: 'movie' as const,
poster: '',
year: item.movie?.year,
lastWatched: item.last_watched_at,
imdbId: item.movie?.ids?.imdb,
traktId: item.movie?.ids?.simkl || 0,
}));
case 'onhold-anime':
return (onHoldAnime || []).map(item => ({
id: String(item.anime?.ids?.simkl || Math.random()),
name: item.anime?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.anime?.year,
lastWatched: item.last_watched_at,
imdbId: item.anime?.ids?.imdb,
traktId: item.anime?.ids?.simkl || 0,
}));
case 'dropped-shows':
return (droppedShows || []).map(item => ({
id: String(item.show?.ids?.simkl || Math.random()),
name: item.show?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.show?.year,
lastWatched: item.last_watched_at,
imdbId: item.show?.ids?.imdb,
traktId: item.show?.ids?.simkl || 0,
}));
case 'dropped-movies':
return (droppedMovies || []).map(item => ({
id: String(item.movie?.ids?.simkl || Math.random()),
name: item.movie?.title || 'Unknown',
type: 'movie' as const,
poster: '',
year: item.movie?.year,
lastWatched: item.last_watched_at,
imdbId: item.movie?.ids?.imdb,
traktId: item.movie?.ids?.simkl || 0,
}));
case 'dropped-anime':
return (droppedAnime || []).map(item => ({
id: String(item.anime?.ids?.simkl || Math.random()),
name: item.anime?.title || 'Unknown',
type: 'series' as const,
poster: '',
year: item.anime?.year,
lastWatched: item.last_watched_at,
imdbId: item.anime?.ids?.imdb,
traktId: item.anime?.ids?.simkl || 0,
}));
case 'ratings':
return (simklRatedContent || []).map(item => {
const content = item.show || item.movie || item.anime;
const type = item.show ? 'series' : item.movie ? 'movie' : 'series';
return {
id: String(content?.ids?.simkl || Math.random()),
name: content?.title || 'Unknown',
type,
poster: '',
year: content?.year,
lastWatched: item.rated_at,
rating: item.rating,
imdbId: content?.ids?.imdb,
traktId: content?.ids?.simkl || 0,
};
});
}
return items.sort((a, b) => {
const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0;
const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0;
return dateB - dateA;
});
}, [simklContinueWatching, watchingShows, watchingMovies, watchingAnime, planToWatchShows, planToWatchMovies, planToWatchAnime, completedShows, completedMovies, completedAnime, onHoldShows, onHoldMovies, onHoldAnime, droppedShows, droppedMovies, droppedAnime, simklRatedContent]);
const renderTraktContent = () => {
if (traktLoading) {
return <TraktLoadingSpinner />;
@ -800,7 +1209,133 @@ const LibraryScreen = () => {
);
};
const renderFilter = (filterType: 'trakt' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => {
const renderSimklCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => {
setSelectedSimklFolder(folder.id);
loadSimklCollections();
}}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black, backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.folderGradient}>
<MaterialIcons
name={folder.icon}
size={48}
color={currentTheme.colors.white}
style={{ marginBottom: 8 }}
/>
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
{folder.name}
</Text>
<Text style={styles.folderCount}>
{folder.itemCount} {t('library.items')}
</Text>
</View>
</View>
</TouchableOpacity>
);
const renderSimklContent = () => {
if (simklLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', paddingBottom: 80 }}>
<Image
source={require('../../assets/simkl-logo.png')}
style={{ width: 120, height: 40, tintColor: currentTheme.colors.text }}
resizeMode="contain"
/>
</View>
);
}
if (!selectedSimklFolder) {
if (simklFolders.length === 0) {
return (
<View style={styles.emptyContainer}>
<MaterialIcons name="video-library" size={80} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
{t('library.no_trakt')}
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
{t('library.no_trakt_desc')}
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => {
loadSimklCollections();
}}
activeOpacity={0.7}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>{t('library.load_collections')}</Text>
</TouchableOpacity>
</View>
);
}
return (
<FlashList
ref={flashListRef}
data={simklFolders}
renderItem={({ item }) => renderSimklCollectionFolder({ folder: item })}
keyExtractor={item => item.id}
numColumns={numColumns}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
onEndReachedThreshold={0.7}
onEndReached={() => { }}
/>
);
}
const folderItems = getSimklFolderItems(selectedSimklFolder);
if (folderItems.length === 0) {
const folderName = simklFolders.find(f => f.id === selectedSimklFolder)?.name || t('library.collection');
return (
<View style={styles.emptyContainer}>
<MaterialIcons name="video-library" size={80} color={currentTheme.colors.lightGray} />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>{t('library.empty_folder', { folder: folderName })}</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
{t('library.empty_folder_desc')}
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => {
loadSimklCollections();
}}
activeOpacity={0.7}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Refresh</Text>
</TouchableOpacity>
</View>
);
}
return (
<FlashList
ref={flashListRef}
data={folderItems}
renderItem={({ item }) => renderTraktItem({ item })}
keyExtractor={(item) => `${item.type}-${item.id}`}
numColumns={numColumns}
style={styles.traktContainer}
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
showsVerticalScrollIndicator={false}
onEndReachedThreshold={0.7}
onEndReached={() => { }}
/>
);
};
const renderFilter = (filterType: 'trakt' | 'simkl' | 'movies' | 'series', label: string) => {
const isActive = filter === filterType;
return (
@ -821,22 +1356,20 @@ const LibraryScreen = () => {
}
return;
}
if (filterType === 'simkl') {
if (!simklAuthenticated) {
navigation.navigate('Settings');
} else {
setShowSimklContent(true);
setSelectedSimklFolder(null);
loadSimklCollections();
}
return;
}
setFilter(filterType);
}}
activeOpacity={0.7}
>
{filterType === 'trakt' ? (
<View style={[styles.filterIcon, { justifyContent: 'center', alignItems: 'center' }]}>
<TraktIcon width={18} height={18} style={{ opacity: isActive ? 1 : 0.6 }} />
</View>
) : (
<MaterialIcons
name={iconName}
size={22}
color={isActive ? currentTheme.colors.white : currentTheme.colors.mediumGray}
style={styles.filterIcon}
/>
)}
<Text
style={[
styles.filterText,
@ -912,14 +1445,26 @@ const LibraryScreen = () => {
? (selectedTraktFolder
? traktFolders.find(f => f.id === selectedTraktFolder)?.name || t('library.collection')
: t('library.trakt_collection'))
: t('library.title')
: showSimklContent
? (selectedSimklFolder
? simklFolders.find(f => f.id === selectedSimklFolder)?.name || t('library.collection')
: 'SIMKL Collections')
: t('library.title')
}
showBackButton={showTraktContent}
onBackPress={showTraktContent ? () => {
if (selectedTraktFolder) {
setSelectedTraktFolder(null);
} else {
setShowTraktContent(false);
showBackButton={showTraktContent || showSimklContent}
onBackPress={(showTraktContent || showSimklContent) ? () => {
if (showTraktContent) {
if (selectedTraktFolder) {
setSelectedTraktFolder(null);
} else {
setShowTraktContent(false);
}
} else if (showSimklContent) {
if (selectedSimklFolder) {
setSelectedSimklFolder(null);
} else {
setShowSimklContent(false);
}
}
} : undefined}
useMaterialIcons={showTraktContent}
@ -929,15 +1474,16 @@ const LibraryScreen = () => {
/>
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{!showTraktContent && (
{!showTraktContent && !showSimklContent && (
<View style={styles.filtersContainer}>
{renderFilter('trakt', 'Trakt', 'pan-tool')}
{renderFilter('movies', t('search.movies'), 'movie')}
{renderFilter('series', t('search.tv_shows'), 'live-tv')}
{renderFilter('trakt', 'Trakt')}
{renderFilter('simkl', 'SIMKL')}
{renderFilter('movies', t('search.movies'))}
{renderFilter('series', t('search.tv_shows'))}
</View>
)}
{showTraktContent ? renderTraktContent() : renderContent()}
{showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()}
</View>
{selectedItem && (
@ -1065,13 +1611,11 @@ const styles = StyleSheet.create({
overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.03)',
aspectRatio: 2 / 3,
// Consistent shadow/elevation matching ContentItem
elevation: Platform.OS === 'android' ? 1 : 0,
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 1,
// Consistent border styling
borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.15)',
},

View file

@ -66,11 +66,13 @@ export interface SimklPlaybackData {
movie?: {
title: string;
year: number;
poster?: string;
ids: SimklIds;
};
show?: {
title: string;
year: number;
poster?: string;
ids: SimklIds;
};
episode?: {
@ -130,9 +132,95 @@ export interface SimklActivities {
anime?: string;
[key: string]: string | undefined;
};
movies?: {
all?: string;
rated_at?: string;
plantowatch?: string;
completed?: string;
dropped?: string;
[key: string]: string | undefined;
};
shows?: {
all?: string;
rated_at?: string;
playback?: string;
plantowatch?: string;
watching?: string;
completed?: string;
hold?: string;
dropped?: string;
[key: string]: string | undefined;
};
anime?: {
all?: string;
rated_at?: string;
playback?: string;
plantowatch?: string;
watching?: string;
completed?: string;
hold?: string;
dropped?: string;
[key: string]: string | undefined;
};
[key: string]: any;
}
export interface SimklWatchlistItem {
movie?: {
title: string;
year: number;
poster?: string;
ids: SimklIds;
};
show?: {
title: string;
year: number;
poster?: string;
ids: SimklIds;
};
anime?: {
title: string;
year: number;
poster?: string;
ids: SimklIds;
};
status?: 'watching' | 'plantowatch' | 'completed' | 'hold' | 'dropped';
last_watched_at?: string;
user_rating?: number;
watched_episodes_count?: number;
total_episodes_count?: number;
last_watched?: string;
next_to_watch?: string;
added_to_watchlist_at?: string;
}
export interface SimklRatingItem {
movie?: {
title: string;
year: number;
poster?: string;
ids: SimklIds;
};
show?: {
title: string;
year: number;
poster?: string;
ids: SimklIds;
};
anime?: {
title: string;
year: number;
poster?: string;
ids: SimklIds;
};
rating: number;
rated_at: string;
last_watched_at?: string;
user_rated_at?: string;
}
export type SimklStatus = 'watching' | 'plantowatch' | 'completed' | 'hold' | 'dropped';
export class SimklService {
private static instance: SimklService;
private accessToken: string | null = null;
@ -603,16 +691,201 @@ export class SimklService {
}
/**
* SYNC: Get Full Watch History (summary)
* Optimization: Check /sync/activities first in real usage.
* For now, we implement simple fetch.
* SYNC: Get all items from user's lists
* Enhanced version with type and status filtering
*/
public async getAllItems(dateFrom?: string): Promise<any> {
public async getAllItems(
type?: 'movies' | 'shows' | 'anime',
status?: SimklStatus,
dateFrom?: string
): Promise<SimklWatchlistItem[]> {
let url = '/sync/all-items/';
if (dateFrom) {
url += `?date_from=${dateFrom}`;
if (type) {
url += `${type}/`;
}
return await this.apiRequest(url);
if (status) {
url += status;
}
const params: string[] = [];
if (dateFrom) {
params.push(`date_from=${dateFrom}`);
}
if (params.length > 0) {
url += `?${params.join('&')}`;
}
try {
logger.log(`[SimklService] getAllItems: Fetching ${url}`);
const response = await this.apiRequest<any>(url);
if (!response) {
logger.log('[SimklService] getAllItems: No response from API');
return [];
}
logger.log('[SimklService] getAllItems: Response keys:', Object.keys(response));
// Parse response based on type
const items: SimklWatchlistItem[] = [];
if (response.movies && type === 'movies') {
logger.log(`[SimklService] getAllItems: Returning ${response.movies.length} movies`);
if (response.movies.length > 0) {
logger.log('[SimklService] getAllItems: First movie sample:', JSON.stringify(response.movies[0], null, 2));
}
return response.movies || [];
}
if (response.shows && type === 'shows') {
logger.log(`[SimklService] getAllItems: Returning ${response.shows.length} shows`);
if (response.shows.length > 0) {
logger.log('[SimklService] getAllItems: First show sample:', JSON.stringify(response.shows[0], null, 2));
}
return response.shows || [];
}
if (response.anime && type === 'anime') {
logger.log(`[SimklService] getAllItems: Returning ${response.anime.length} anime`);
if (response.anime.length > 0) {
logger.log('[SimklService] getAllItems: First anime sample:', JSON.stringify(response.anime[0], null, 2));
}
return response.anime || [];
}
// If no type specified, return all
if (!type) {
if (response.movies) items.push(...response.movies);
if (response.shows) items.push(...response.shows);
if (response.anime) items.push(...response.anime);
logger.log(`[SimklService] getAllItems: Returning ${items.length} total items`);
return items;
}
logger.log('[SimklService] getAllItems: No matching type found, returning empty array');
return [];
} catch (error) {
logger.error('[SimklService] Failed to get all items:', error);
return [];
}
}
/**
* SYNC: Get user ratings
*/
public async getRatings(
type?: 'movies' | 'shows' | 'anime',
ratingFilter?: string
): Promise<SimklRatingItem[]> {
let url = '/sync/ratings/';
if (type) {
url += type;
if (ratingFilter) {
url += `/${ratingFilter}`;
}
}
try {
const response = await this.apiRequest<any>(url);
if (!response) return [];
const items: SimklRatingItem[] = [];
// Aggregate ratings from all types
if (response.movies) items.push(...response.movies);
if (response.shows) items.push(...response.shows);
if (response.anime) items.push(...response.anime);
return items;
} catch (error) {
logger.error('[SimklService] Failed to get ratings:', error);
return [];
}
}
/**
* Add item to a specific status list
*/
public async addToList(
imdbId: string,
type: 'movie' | 'show' | 'anime',
status: SimklStatus
): Promise<boolean> {
const contentType = type === 'movie' ? 'movies' : type === 'show' ? 'shows' : 'anime';
const payload = {
[contentType]: [{
to: status,
ids: { imdb: imdbId }
}]
};
try {
const response = await this.apiRequest('/sync/add-to-list', 'POST', payload);
return !!response;
} catch (error) {
logger.error('[SimklService] Failed to add to list:', error);
return false;
}
}
/**
* Remove item from list (marks as not interested)
*/
public async removeFromList(
imdbId: string,
type: 'movie' | 'show' | 'anime'
): Promise<boolean> {
const contentType = type === 'movie' ? 'movies' : type === 'show' ? 'shows' : 'anime';
const payload = {
[contentType]: [{
ids: { imdb: imdbId }
}]
};
try {
const response = await this.apiRequest('/sync/remove-from-list', 'POST', payload);
return !!response;
} catch (error) {
logger.error('[SimklService] Failed to remove from list:', error);
return false;
}
}
/**
* Add rating for item
*/
public async addRating(
imdbId: string,
type: 'movie' | 'show' | 'anime',
rating: number
): Promise<boolean> {
const contentType = type === 'movie' ? 'movies' : type === 'show' ? 'shows' : 'anime';
const payload = {
[contentType]: [{
ids: { imdb: imdbId },
rating: Math.max(1, Math.min(10, rating))
}]
};
try {
const response = await this.apiRequest('/sync/ratings', 'POST', payload);
return !!response;
} catch (error) {
logger.error('[SimklService] Failed to add rating:', error);
return false;
}
}
/**
* Get poster URL - returns empty string to let app's existing poster infrastructure handle it
* The app will use IMDB ID or TMDB ID to fetch posters through existing metadata services
*/
public static getPosterUrl(): string {
return '';
}
/**