Ios #14

Merged
tapframe merged 88 commits from ios into main 2025-06-20 13:54:29 +00:00
4 changed files with 563 additions and 17 deletions
Showing only changes of commit 237c1fae3d - Show all commits

View file

@ -1,6 +1,13 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useTraktIntegration } from '../hooks/useTraktIntegration';
import { TraktUser, TraktWatchedItem } from '../services/traktService';
import {
TraktUser,
TraktWatchedItem,
TraktWatchlistItem,
TraktCollectionItem,
TraktRatingItem,
TraktPlaybackItem
} from '../services/traktService';
interface TraktContextProps {
isAuthenticated: boolean;
@ -8,9 +15,16 @@ interface TraktContextProps {
userProfile: TraktUser | null;
watchedMovies: TraktWatchedItem[];
watchedShows: TraktWatchedItem[];
watchlistMovies: TraktWatchlistItem[];
watchlistShows: TraktWatchlistItem[];
collectionMovies: TraktCollectionItem[];
collectionShows: TraktCollectionItem[];
continueWatching: TraktPlaybackItem[];
ratedContent: TraktRatingItem[];
checkAuthStatus: () => Promise<void>;
refreshAuthStatus: () => Promise<void>;
loadWatchedItems: () => Promise<void>;
loadAllCollections: () => Promise<void>;
isMovieWatched: (imdbId: string) => Promise<boolean>;
isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>;
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>;

View file

@ -1,6 +1,15 @@
import { useState, useEffect, useCallback } from 'react';
import { AppState, AppStateStatus } from 'react-native';
import { traktService, TraktUser, TraktWatchedItem, TraktContentData, TraktPlaybackItem } from '../services/traktService';
import {
traktService,
TraktUser,
TraktWatchedItem,
TraktWatchlistItem,
TraktCollectionItem,
TraktRatingItem,
TraktContentData,
TraktPlaybackItem
} from '../services/traktService';
import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
@ -10,6 +19,12 @@ export function useTraktIntegration() {
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]);
const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]);
const [watchlistMovies, setWatchlistMovies] = useState<TraktWatchlistItem[]>([]);
const [watchlistShows, setWatchlistShows] = useState<TraktWatchlistItem[]>([]);
const [collectionMovies, setCollectionMovies] = useState<TraktCollectionItem[]>([]);
const [collectionShows, setCollectionShows] = useState<TraktCollectionItem[]>([]);
const [continueWatching, setContinueWatching] = useState<TraktPlaybackItem[]>([]);
const [ratedContent, setRatedContent] = useState<TraktRatingItem[]>([]);
const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now());
// Check authentication status
@ -65,6 +80,41 @@ export function useTraktIntegration() {
}
}, [isAuthenticated]);
// Load all collections (watchlist, collection, continue watching, ratings)
const loadAllCollections = useCallback(async () => {
if (!isAuthenticated) return;
setIsLoading(true);
try {
const [
watchlistMovies,
watchlistShows,
collectionMovies,
collectionShows,
continueWatching,
ratings
] = await Promise.all([
traktService.getWatchlistMovies(),
traktService.getWatchlistShows(),
traktService.getCollectionMovies(),
traktService.getCollectionShows(),
traktService.getPlaybackProgress(),
traktService.getRatings()
]);
setWatchlistMovies(watchlistMovies);
setWatchlistShows(watchlistShows);
setCollectionMovies(collectionMovies);
setCollectionShows(collectionShows);
setContinueWatching(continueWatching);
setRatedContent(ratings);
} catch (error) {
logger.error('[useTraktIntegration] Error loading all collections:', error);
} finally {
setIsLoading(false);
}
}, [isAuthenticated]);
// Check if a movie is watched
const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => {
if (!isAuthenticated) return false;
@ -436,8 +486,15 @@ export function useTraktIntegration() {
userProfile,
watchedMovies,
watchedShows,
watchlistMovies,
watchlistShows,
collectionMovies,
collectionShows,
continueWatching,
ratedContent,
checkAuthStatus,
loadWatchedItems,
loadAllCollections,
isMovieWatched,
isEpisodeWatched,
markMovieAsWatched,

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
View,
Text,
@ -42,12 +42,22 @@ interface TraktDisplayItem {
type: 'movie' | 'series';
poster: string;
year?: number;
lastWatched: string;
plays: number;
lastWatched?: string;
plays?: number;
rating?: number;
imdbId?: string;
traktId: number;
}
interface TraktFolder {
id: string;
name: string;
icon: keyof typeof MaterialIcons.glyphMap;
description: string;
itemCount: number;
gradient: [string, string];
}
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const SkeletonLoader = () => {
@ -116,6 +126,7 @@ const LibraryScreen = () => {
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all');
const [showTraktContent, setShowTraktContent] = useState(false);
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
@ -125,7 +136,14 @@ const LibraryScreen = () => {
isLoading: traktLoading,
watchedMovies,
watchedShows,
loadWatchedItems
watchlistMovies,
watchlistShows,
collectionMovies,
collectionShows,
continueWatching,
ratedContent,
loadWatchedItems,
loadAllCollections
} = useTraktContext();
// Force consistent status bar settings
@ -177,6 +195,57 @@ const LibraryScreen = () => {
return true;
});
// Generate Trakt collection folders
const traktFolders = useMemo((): TraktFolder[] => {
if (!traktAuthenticated) return [];
const folders: TraktFolder[] = [
{
id: 'watched',
name: 'Watched',
icon: 'visibility',
description: 'Your watched content',
itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0),
gradient: ['#4CAF50', '#2E7D32']
},
{
id: 'continue-watching',
name: 'Continue Watching',
icon: 'play-circle-outline',
description: 'Resume your progress',
itemCount: continueWatching?.length || 0,
gradient: ['#FF9800', '#F57C00']
},
{
id: 'watchlist',
name: 'Watchlist',
icon: 'bookmark',
description: 'Want to watch',
itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0),
gradient: ['#2196F3', '#1976D2']
},
{
id: 'collection',
name: 'Collection',
icon: 'library-add',
description: 'Your collection',
itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0),
gradient: ['#9C27B0', '#7B1FA2']
},
{
id: 'ratings',
name: 'Rated',
icon: 'star',
description: 'Your ratings',
itemCount: ratedContent?.length || 0,
gradient: ['#FF5722', '#D84315']
}
];
// Only return folders that have content
return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
// Prepare Trakt items with proper poster URLs
const traktItems = useMemo(() => {
if (!traktAuthenticated || (!watchedMovies?.length && !watchedShows?.length)) {
@ -226,7 +295,11 @@ const LibraryScreen = () => {
}
// Sort by last watched date (most recent first)
return items.sort((a, b) => new Date(b.lastWatched).getTime() - new Date(a.lastWatched).getTime());
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;
});
}, [traktAuthenticated, watchedMovies, watchedShows]);
// State for tracking poster URLs
@ -348,6 +421,41 @@ const LibraryScreen = () => {
</TouchableOpacity>
);
// Render individual Trakt collection folder
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => {
setSelectedTraktFolder(folder.id);
loadAllCollections(); // Load all collections when entering a specific folder
}}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}>
<LinearGradient
colors={folder.gradient}
style={styles.folderGradient}
>
<MaterialIcons
name={folder.icon}
size={60}
color={currentTheme.colors.white}
style={{ marginBottom: 12 }}
/>
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
{folder.name}
</Text>
<Text style={styles.folderCount}>
{folder.itemCount} items
</Text>
<Text style={styles.folderSubtitle}>
{folder.description}
</Text>
</LinearGradient>
</View>
</TouchableOpacity>
);
const renderTraktFolder = () => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
@ -356,6 +464,8 @@ const LibraryScreen = () => {
navigation.navigate('TraktSettings');
} else {
setShowTraktContent(true);
setSelectedTraktFolder(null); // Reset to folder view
loadAllCollections(); // Load all collections when opening
}
}}
activeOpacity={0.7}
@ -427,7 +537,7 @@ const LibraryScreen = () => {
<Text style={styles.lastWatched}>
Last watched: {item.lastWatched}
</Text>
{item.plays > 1 && (
{item.plays && item.plays > 1 && (
<Text style={styles.playsCount}>
{item.plays} plays
</Text>
@ -446,18 +556,265 @@ const LibraryScreen = () => {
);
};
// Get items for a specific Trakt folder
const getTraktFolderItems = useCallback((folderId: string): TraktDisplayItem[] => {
const items: TraktDisplayItem[] = [];
switch (folderId) {
case 'watched':
// Add watched movies
if (watchedMovies) {
for (const watchedMovie of watchedMovies) {
const movie = watchedMovie.movie;
if (movie) {
items.push({
id: String(movie.ids.trakt),
name: movie.title,
type: 'movie',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: movie.year,
lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(),
plays: watchedMovie.plays,
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
});
}
}
}
// Add watched shows
if (watchedShows) {
for (const watchedShow of watchedShows) {
const show = watchedShow.show;
if (show) {
items.push({
id: String(show.ids.trakt),
name: show.title,
type: 'series',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: show.year,
lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(),
plays: watchedShow.plays,
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
});
}
}
}
break;
case 'continue-watching':
// Add continue watching items
if (continueWatching) {
for (const item of continueWatching) {
if (item.type === 'movie' && item.movie) {
items.push({
id: String(item.movie.ids.trakt),
name: item.movie.title,
type: 'movie',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: item.movie.year,
lastWatched: new Date(item.paused_at).toLocaleDateString(),
imdbId: item.movie.ids.imdb,
traktId: item.movie.ids.trakt,
});
} else if (item.type === 'episode' && item.show && item.episode) {
items.push({
id: `${item.show.ids.trakt}:${item.episode.season}:${item.episode.number}`,
name: `${item.show.title} S${item.episode.season}E${item.episode.number}`,
type: 'series',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: item.show.year,
lastWatched: new Date(item.paused_at).toLocaleDateString(),
imdbId: item.show.ids.imdb,
traktId: item.show.ids.trakt,
});
}
}
}
break;
case 'watchlist':
// Add watchlist movies
if (watchlistMovies) {
for (const watchlistMovie of watchlistMovies) {
const movie = watchlistMovie.movie;
if (movie) {
items.push({
id: String(movie.ids.trakt),
name: movie.title,
type: 'movie',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: movie.year,
lastWatched: new Date(watchlistMovie.listed_at).toLocaleDateString(),
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
});
}
}
}
// Add watchlist shows
if (watchlistShows) {
for (const watchlistShow of watchlistShows) {
const show = watchlistShow.show;
if (show) {
items.push({
id: String(show.ids.trakt),
name: show.title,
type: 'series',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: show.year,
lastWatched: new Date(watchlistShow.listed_at).toLocaleDateString(),
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
});
}
}
}
break;
case 'collection':
// Add collection movies
if (collectionMovies) {
for (const collectionMovie of collectionMovies) {
const movie = collectionMovie.movie;
if (movie) {
items.push({
id: String(movie.ids.trakt),
name: movie.title,
type: 'movie',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: movie.year,
lastWatched: new Date(collectionMovie.collected_at).toLocaleDateString(),
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
});
}
}
}
// Add collection shows
if (collectionShows) {
for (const collectionShow of collectionShows) {
const show = collectionShow.show;
if (show) {
items.push({
id: String(show.ids.trakt),
name: show.title,
type: 'series',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: show.year,
lastWatched: new Date(collectionShow.collected_at).toLocaleDateString(),
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
});
}
}
}
break;
case 'ratings':
// Add rated content
if (ratedContent) {
for (const ratedItem of ratedContent) {
if (ratedItem.movie) {
const movie = ratedItem.movie;
items.push({
id: String(movie.ids.trakt),
name: movie.title,
type: 'movie',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: movie.year,
lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(),
rating: ratedItem.rating,
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
});
} else if (ratedItem.show) {
const show = ratedItem.show;
items.push({
id: String(show.ids.trakt),
name: show.title,
type: 'series',
poster: 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
year: show.year,
lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(),
rating: ratedItem.rating,
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
});
}
}
}
break;
}
// Sort by last watched/added date (most recent first)
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;
});
}, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const renderTraktContent = () => {
if (traktLoading) {
return <SkeletonLoader />;
}
if (traktItems.length === 0) {
// If no specific folder is selected, show the folder structure
if (!selectedTraktFolder) {
if (traktFolders.length === 0) {
return (
<View style={styles.emptyContainer}>
<TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No Trakt collections</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Your Trakt collections will appear here once you start using Trakt
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
backgroundColor: currentTheme.colors.primary,
shadowColor: currentTheme.colors.black
}]}
onPress={() => {
loadAllCollections();
}}
activeOpacity={0.7}
>
<Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Load Collections</Text>
</TouchableOpacity>
</View>
);
}
// Show collection folders
return (
<FlatList
data={traktFolders}
renderItem={({ item }) => renderTraktCollectionFolder({ folder: item })}
keyExtractor={item => item.id}
numColumns={2}
contentContainerStyle={styles.listContainer}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
/>
);
}
// Show content for specific folder
const folderItems = getTraktFolderItems(selectedTraktFolder);
if (folderItems.length === 0) {
const folderName = traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection';
return (
<View style={styles.emptyContainer}>
<TraktIcon width={80} height={80} style={{ opacity: 0.7, marginBottom: 16 }} />
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No watched content</Text>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>No content in {folderName}</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.mediumGray }]}>
Your Trakt watched history will appear here
This collection is empty
</Text>
<TouchableOpacity
style={[styles.exploreButton, {
@ -465,7 +822,7 @@ const LibraryScreen = () => {
shadowColor: currentTheme.colors.black
}]}
onPress={() => {
loadWatchedItems();
loadAllCollections();
}}
activeOpacity={0.7}
>
@ -475,9 +832,9 @@ const LibraryScreen = () => {
);
}
// Separate movies and shows
const movies = traktItems.filter(item => item.type === 'movie');
const shows = traktItems.filter(item => item.type === 'series');
// Separate movies and shows for the selected folder
const movies = folderItems.filter(item => item.type === 'movie');
const shows = folderItems.filter(item => item.type === 'series');
return (
<ScrollView
@ -656,7 +1013,13 @@ const LibraryScreen = () => {
<>
<TouchableOpacity
style={styles.backButton}
onPress={() => setShowTraktContent(false)}
onPress={() => {
if (selectedTraktFolder) {
setSelectedTraktFolder(null);
} else {
setShowTraktContent(false);
}
}}
activeOpacity={0.7}
>
<MaterialIcons
@ -667,7 +1030,10 @@ const LibraryScreen = () => {
</TouchableOpacity>
<View style={styles.headerTitleContainer}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>
Trakt Collection
{selectedTraktFolder
? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection'
: 'Trakt Collection'
}
</Text>
</View>
<View style={styles.headerSpacer} />

View file

@ -47,6 +47,79 @@ export interface TraktWatchedItem {
last_watched_at: string;
}
export interface TraktWatchlistItem {
movie?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
imdb: string;
tmdb: number;
};
};
show?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
imdb: string;
tmdb: number;
};
};
listed_at: string;
}
export interface TraktCollectionItem {
movie?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
imdb: string;
tmdb: number;
};
};
show?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
imdb: string;
tmdb: number;
};
};
collected_at: string;
}
export interface TraktRatingItem {
movie?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
imdb: string;
tmdb: number;
};
};
show?: {
title: string;
year: number;
ids: {
trakt: number;
slug: string;
imdb: string;
tmdb: number;
};
};
rating: number;
rated_at: string;
}
// New types for scrobbling
export interface TraktPlaybackItem {
progress: number;
@ -578,6 +651,42 @@ export class TraktService {
return this.apiRequest<TraktWatchedItem[]>('/sync/watched/shows');
}
/**
* Get the user's watchlist movies
*/
public async getWatchlistMovies(): Promise<TraktWatchlistItem[]> {
return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/movies');
}
/**
* Get the user's watchlist shows
*/
public async getWatchlistShows(): Promise<TraktWatchlistItem[]> {
return this.apiRequest<TraktWatchlistItem[]>('/sync/watchlist/shows');
}
/**
* Get the user's collection movies
*/
public async getCollectionMovies(): Promise<TraktCollectionItem[]> {
return this.apiRequest<TraktCollectionItem[]>('/sync/collection/movies');
}
/**
* Get the user's collection shows
*/
public async getCollectionShows(): Promise<TraktCollectionItem[]> {
return this.apiRequest<TraktCollectionItem[]>('/sync/collection/shows');
}
/**
* Get the user's ratings
*/
public async getRatings(type?: 'movies' | 'shows'): Promise<TraktRatingItem[]> {
const endpoint = type ? `/sync/ratings/${type}` : '/sync/ratings';
return this.apiRequest<TraktRatingItem[]>(endpoint);
}
/**
* Get trakt id from IMDb id
*/