From c36210e9c2406380468de6c9241cf17ce1970e16 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Mon, 26 Jan 2026 12:53:14 +0530
Subject: [PATCH] added simkl watchlists
---
App.tsx | 17 +-
src/contexts/SimklContext.tsx | 85 +++++
src/hooks/useSimklIntegration.ts | 535 +++++++++++++++++++++++++--
src/screens/LibraryScreen.tsx | 606 +++++++++++++++++++++++++++++--
src/services/simklService.ts | 287 ++++++++++++++-
5 files changed, 1453 insertions(+), 77 deletions(-)
create mode 100644 src/contexts/SimklContext.tsx
diff --git a/App.tsx b/App.tsx
index 4be43b96..ffe6425f 100644
--- a/App.tsx
+++ b/App.tsx
@@ -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 {
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/contexts/SimklContext.tsx b/src/contexts/SimklContext.tsx
new file mode 100644
index 00000000..b0384009
--- /dev/null
+++ b/src/contexts/SimklContext.tsx
@@ -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;
+ planToWatchSet: Set;
+ completedSet: Set;
+ onHoldSet: Set;
+ droppedSet: Set;
+
+ // Methods
+ checkAuthStatus: () => Promise;
+ refreshAuthStatus: () => Promise;
+ loadAllCollections: () => Promise;
+ addToStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => Promise;
+ removeFromStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => Promise;
+ isInStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => boolean;
+
+ // Scrobbling methods (from existing hook)
+ startWatching?: (content: any, progress: number) => Promise;
+ updateProgress?: (content: any, progress: number) => Promise;
+ stopWatching?: (content: any, progress: number) => Promise;
+ syncAllProgress?: () => Promise;
+ fetchAndMergeSimklProgress?: () => Promise;
+}
+
+const SimklContext = createContext(undefined);
+
+export function SimklProvider({ children }: { children: ReactNode }) {
+ const simklIntegration = useSimklIntegration();
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useSimklContext() {
+ const context = useContext(SimklContext);
+ if (context === undefined) {
+ throw new Error('useSimklContext must be used within a SimklProvider');
+ }
+ return context;
+}
diff --git a/src/hooks/useSimklIntegration.ts b/src/hooks/useSimklIntegration.ts
index 3fe37d53..e073951c 100644
--- a/src/hooks/useSimklIntegration.ts
+++ b/src/hooks/useSimklIntegration.ts
@@ -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(false);
const [isLoading, setIsLoading] = useState(true);
-
- // Basic lists
- const [continueWatching, setContinueWatching] = useState([]);
const [userSettings, setUserSettings] = useState(() => cachedUserSettings);
const [userStats, setUserStats] = useState(() => cachedUserStats);
+ // Collection state - Shows
+ const [watchingShows, setWatchingShows] = useState([]);
+ const [planToWatchShows, setPlanToWatchShows] = useState([]);
+ const [completedShows, setCompletedShows] = useState([]);
+ const [onHoldShows, setOnHoldShows] = useState([]);
+ const [droppedShows, setDroppedShows] = useState([]);
+
+ // Collection state - Movies
+ const [watchingMovies, setWatchingMovies] = useState([]);
+ const [planToWatchMovies, setPlanToWatchMovies] = useState([]);
+ const [completedMovies, setCompletedMovies] = useState([]);
+ const [onHoldMovies, setOnHoldMovies] = useState([]);
+ const [droppedMovies, setDroppedMovies] = useState([]);
+
+ // Collection state - Anime
+ const [watchingAnime, setWatchingAnime] = useState([]);
+ const [planToWatchAnime, setPlanToWatchAnime] = useState([]);
+ const [completedAnime, setCompletedAnime] = useState([]);
+ const [onHoldAnime, setOnHoldAnime] = useState([]);
+ const [droppedAnime, setDroppedAnime] = useState([]);
+
+ // Special collections
+ const [continueWatching, setContinueWatching] = useState([]);
+ const [ratedContent, setRatedContent] = useState([]);
+
+ // Lookup Sets for O(1) status checks (combined across types)
+ const [watchingSet, setWatchingSet] = useState>(new Set());
+ const [planToWatchSet, setPlanToWatchSet] = useState>(new Set());
+ const [completedSet, setCompletedSet] = useState>(new Set());
+ const [onHoldSet, setOnHoldSet] = useState>(new Set());
+ const [droppedSet, setDroppedSet] = useState>(new Set());
+
+ // Activity tracking for caching
+ const [lastActivityCheck, setLastActivityCheck] = useState(null);
+
const lastPlaybackFetchAt = useRef(0);
const lastActivitiesCheckAt = useRef(0);
const lastPlaybackActivityAt = useRef(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 => {
+ const set = new Set();
+ 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 => {
+ 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) => {
+ 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 => {
+ 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 => {
+ 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 => {
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 => {
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 => {
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 => {
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 => {
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,
};
}
diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx
index 984310f1..fe37c9cc 100644
--- a/src/screens/LibraryScreen.tsx
+++ b/src/screens/LibraryScreen.tsx
@@ -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([]);
- 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(null);
+ const [showSimklContent, setShowSimklContent] = useState(false);
+ const [selectedSimklFolder, setSelectedSimklFolder] = useState(null);
const { showInfo, showError } = useToast();
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState(null);
@@ -230,7 +271,6 @@ const LibraryScreen = () => {
const { settings } = useSettings();
const flashListRef = useRef(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 }) => (
{
});
}, [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 ;
@@ -800,7 +1209,133 @@ const LibraryScreen = () => {
);
};
- const renderFilter = (filterType: 'trakt' | 'movies' | 'series', label: string, iconName: keyof typeof MaterialIcons.glyphMap) => {
+ const renderSimklCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
+ {
+ setSelectedSimklFolder(folder.id);
+ loadSimklCollections();
+ }}
+ activeOpacity={0.7}
+ >
+
+
+
+
+ {folder.name}
+
+
+ {folder.itemCount} {t('library.items')}
+
+
+
+
+ );
+
+ const renderSimklContent = () => {
+ if (simklLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!selectedSimklFolder) {
+ if (simklFolders.length === 0) {
+ return (
+
+
+
+ {t('library.no_trakt')}
+
+
+ {t('library.no_trakt_desc')}
+
+ {
+ loadSimklCollections();
+ }}
+ activeOpacity={0.7}
+ >
+ {t('library.load_collections')}
+
+
+ );
+ }
+
+ return (
+ 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 (
+
+
+ {t('library.empty_folder', { folder: folderName })}
+
+ {t('library.empty_folder_desc')}
+
+ {
+ loadSimklCollections();
+ }}
+ activeOpacity={0.7}
+ >
+ Refresh
+
+
+ );
+ }
+
+ return (
+ 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' ? (
-
-
-
- ) : (
-
- )}
{
? (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 = () => {
/>
- {!showTraktContent && (
+ {!showTraktContent && !showSimklContent && (
- {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'))}
)}
- {showTraktContent ? renderTraktContent() : renderContent()}
+ {showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()}
{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)',
},
diff --git a/src/services/simklService.ts b/src/services/simklService.ts
index b824a138..ef027ffe 100644
--- a/src/services/simklService.ts
+++ b/src/services/simklService.ts
@@ -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 {
+ public async getAllItems(
+ type?: 'movies' | 'shows' | 'anime',
+ status?: SimklStatus,
+ dateFrom?: string
+ ): Promise {
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(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 {
+ let url = '/sync/ratings/';
+
+ if (type) {
+ url += type;
+ if (ratingFilter) {
+ url += `/${ratingFilter}`;
+ }
+ }
+
+ try {
+ const response = await this.apiRequest(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 {
+ 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 {
+ 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 {
+ 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 '';
}
/**