mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-25 10:33:07 +00:00
Merge pull request #468 from Judzim/main
Added option to sync Trakt watchlist into local library of Nuvio
This commit is contained in:
commit
eb189d88c1
2 changed files with 511 additions and 11 deletions
|
|
@ -42,6 +42,7 @@ import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner';
|
||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from '../hooks/useSettings';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
import { useScrollToTop } from '../contexts/ScrollToTopContext';
|
||||||
|
import { TMDBService } from '../services/tmdbService';
|
||||||
|
|
||||||
interface LibraryItem extends StreamingContent {
|
interface LibraryItem extends StreamingContent {
|
||||||
progress?: number;
|
progress?: number;
|
||||||
|
|
@ -75,6 +76,7 @@ interface TraktFolder {
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||||
|
const TRAKT_LIBRARY_SYNC_MODE_KEY = 'trakt_library_sync_mode';
|
||||||
|
|
||||||
function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: number } {
|
function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: number } {
|
||||||
const horizontalPadding = 26;
|
const horizontalPadding = 26;
|
||||||
|
|
@ -89,8 +91,6 @@ function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: nu
|
||||||
return { numColumns, itemWidth };
|
return { numColumns, itemWidth };
|
||||||
}
|
}
|
||||||
|
|
||||||
import { TMDBService } from '../services/tmdbService';
|
|
||||||
|
|
||||||
const TraktItem = React.memo(({
|
const TraktItem = React.memo(({
|
||||||
item,
|
item,
|
||||||
width,
|
width,
|
||||||
|
|
@ -126,10 +126,6 @@ const TraktItem = React.memo(({
|
||||||
tmdbId = await tmdbService.findTMDBIdByIMDB(item.imdbId);
|
tmdbId = await tmdbService.findTMDBIdByIMDB(item.imdbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tmdbId && item.traktId) {
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tmdbId) {
|
if (tmdbId) {
|
||||||
let posterPath: string | null = null;
|
let posterPath: string | null = null;
|
||||||
|
|
||||||
|
|
@ -270,6 +266,9 @@ const LibraryScreen = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const flashListRef = useRef<any>(null);
|
const flashListRef = useRef<any>(null);
|
||||||
|
const [isSyncing, setIsSyncing] = useState(false);
|
||||||
|
const [traktSyncMode, setTraktSyncMode] = useState<'off' | 'manual' | 'automatic'>('off');
|
||||||
|
const hasAutoSyncedThisSession = useRef(false);
|
||||||
|
|
||||||
const scrollToTop = useCallback(() => {
|
const scrollToTop = useCallback(() => {
|
||||||
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
flashListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
|
@ -315,6 +314,29 @@ const LibraryScreen = () => {
|
||||||
loadAllCollections: loadSimklCollections
|
loadAllCollections: loadSimklCollections
|
||||||
} = useSimklContext();
|
} = useSimklContext();
|
||||||
|
|
||||||
|
// Load Trakt sync mode preferences
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSyncMode = async () => {
|
||||||
|
try {
|
||||||
|
const mode = await mmkvStorage.getItem(TRAKT_LIBRARY_SYNC_MODE_KEY);
|
||||||
|
if (mode === 'manual' || mode === 'automatic') {
|
||||||
|
setTraktSyncMode(mode);
|
||||||
|
} else {
|
||||||
|
setTraktSyncMode('off');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[LibraryScreen] Failed to load sync mode:', error);
|
||||||
|
setTraktSyncMode('off');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadSyncMode();
|
||||||
|
|
||||||
|
// Reload when screen is focused (to pick up changes from settings)
|
||||||
|
const unsubscribe = navigation.addListener('focus', loadSyncMode);
|
||||||
|
return unsubscribe;
|
||||||
|
}, [navigation]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const applyStatusBarConfig = () => {
|
const applyStatusBarConfig = () => {
|
||||||
StatusBar.setBarStyle('light-content');
|
StatusBar.setBarStyle('light-content');
|
||||||
|
|
@ -422,6 +444,248 @@ const LibraryScreen = () => {
|
||||||
};
|
};
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
|
// Refs to always have access to latest context values (avoids stale closure)
|
||||||
|
const watchlistMoviesRef = useRef(watchlistMovies);
|
||||||
|
const watchlistShowsRef = useRef(watchlistShows);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
watchlistMoviesRef.current = watchlistMovies;
|
||||||
|
watchlistShowsRef.current = watchlistShows;
|
||||||
|
}, [watchlistMovies, watchlistShows]);
|
||||||
|
|
||||||
|
// Sync Trakt watchlist to local library
|
||||||
|
const syncTraktWatchlistToLibrary = useCallback(async () => {
|
||||||
|
if (!traktAuthenticated) {
|
||||||
|
showError('Sync Failed', 'Please connect to Trakt first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSyncing(true);
|
||||||
|
logger.log('[LibraryScreen] Starting Trakt watchlist sync...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load Trakt data fresh before syncing
|
||||||
|
logger.log('[LibraryScreen] Loading Trakt collections...');
|
||||||
|
await loadAllCollections();
|
||||||
|
|
||||||
|
// Wait for React to process state updates
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
// Access FRESH values from refs (updated by useEffect)
|
||||||
|
const currentMovies = watchlistMoviesRef.current;
|
||||||
|
const currentShows = watchlistShowsRef.current;
|
||||||
|
|
||||||
|
logger.log(`[LibraryScreen] Syncing ${currentMovies?.length || 0} movies and ${currentShows?.length || 0} shows`);
|
||||||
|
|
||||||
|
const hasMovies = currentMovies && currentMovies.length > 0;
|
||||||
|
const hasShows = currentShows && currentShows.length > 0;
|
||||||
|
|
||||||
|
if (!hasMovies && !hasShows) {
|
||||||
|
logger.error('[LibraryScreen] No Trakt watchlist data available');
|
||||||
|
showError(
|
||||||
|
'Sync Failed',
|
||||||
|
'No items found in your Trakt watchlist. Add some movies or shows to your Trakt watchlist first.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmdbService = TMDBService.getInstance();
|
||||||
|
let addedCount = 0;
|
||||||
|
let updatedCount = 0;
|
||||||
|
let removedCount = 0;
|
||||||
|
|
||||||
|
const currentLibraryItems = await catalogService.getLibraryItems();
|
||||||
|
const traktWatchlistImdbIds = new Set<string>();
|
||||||
|
|
||||||
|
// Collect IMDb IDs from watchlist (using FRESH refs)
|
||||||
|
if (currentMovies) {
|
||||||
|
currentMovies.forEach(item => {
|
||||||
|
if (item.movie?.ids?.imdb) {
|
||||||
|
traktWatchlistImdbIds.add(item.movie.ids.imdb);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentShows) {
|
||||||
|
currentShows.forEach(item => {
|
||||||
|
if (item.show?.ids?.imdb) {
|
||||||
|
traktWatchlistImdbIds.add(item.show.ids.imdb);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove items not in watchlist
|
||||||
|
for (const libraryItem of currentLibraryItems) {
|
||||||
|
const imdbId = libraryItem.id;
|
||||||
|
if (imdbId.startsWith('tt') && !traktWatchlistImdbIds.has(imdbId)) {
|
||||||
|
logger.log(`[LibraryScreen] Removing: ${libraryItem.name}`);
|
||||||
|
await catalogService.removeFromLibrary(libraryItem.type, imdbId);
|
||||||
|
removedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update movies (using FRESH refs)
|
||||||
|
if (currentMovies) {
|
||||||
|
for (const watchlistItem of currentMovies) {
|
||||||
|
const movie = watchlistItem.movie;
|
||||||
|
if (!movie?.ids?.imdb) continue;
|
||||||
|
|
||||||
|
const imdbId = movie.ids.imdb;
|
||||||
|
const existingItem = currentLibraryItems.find(
|
||||||
|
item => item.id === imdbId && item.type === 'movie'
|
||||||
|
);
|
||||||
|
|
||||||
|
let posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image';
|
||||||
|
let overview = '';
|
||||||
|
let genres: string[] = [];
|
||||||
|
let year = movie.year;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmdbId = await tmdbService.findTMDBIdByIMDB(imdbId);
|
||||||
|
if (tmdbId) {
|
||||||
|
const details = await tmdbService.getMovieDetails(String(tmdbId));
|
||||||
|
if (details) {
|
||||||
|
if (details.poster_path) {
|
||||||
|
posterUrl = tmdbService.getImageUrl(details.poster_path, 'w500') || posterUrl;
|
||||||
|
}
|
||||||
|
overview = details.overview || '';
|
||||||
|
genres = details.genres?.map((g: any) => g.name) || [];
|
||||||
|
year = details.release_date ? new Date(details.release_date).getFullYear() : year;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to fetch TMDB data for ${movie.title}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posterUrl === 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' && movie.images) {
|
||||||
|
const traktPosterUrl = TraktService.getTraktPosterUrl(movie.images);
|
||||||
|
if (traktPosterUrl) posterUrl = traktPosterUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentToAdd: StreamingContent = {
|
||||||
|
id: imdbId,
|
||||||
|
type: 'movie',
|
||||||
|
name: movie.title,
|
||||||
|
poster: posterUrl,
|
||||||
|
posterShape: 'poster',
|
||||||
|
year,
|
||||||
|
description: overview,
|
||||||
|
genres,
|
||||||
|
imdbRating: undefined,
|
||||||
|
inLibrary: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
if (existingItem.poster !== posterUrl) {
|
||||||
|
await catalogService.addToLibrary(contentToAdd);
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await catalogService.addToLibrary(contentToAdd);
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add/update shows (using FRESH refs)
|
||||||
|
if (currentShows) {
|
||||||
|
for (const watchlistItem of currentShows) {
|
||||||
|
const show = watchlistItem.show;
|
||||||
|
if (!show?.ids?.imdb) continue;
|
||||||
|
|
||||||
|
const imdbId = show.ids.imdb;
|
||||||
|
const existingItem = currentLibraryItems.find(
|
||||||
|
item => item.id === imdbId && item.type === 'series'
|
||||||
|
);
|
||||||
|
|
||||||
|
let posterUrl = 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image';
|
||||||
|
let overview = '';
|
||||||
|
let genres: string[] = [];
|
||||||
|
let year = show.year;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tmdbId = await tmdbService.findTMDBIdByIMDB(imdbId);
|
||||||
|
if (tmdbId) {
|
||||||
|
const details = await tmdbService.getTVShowDetails(tmdbId);
|
||||||
|
if (details) {
|
||||||
|
if (details.poster_path) {
|
||||||
|
posterUrl = tmdbService.getImageUrl(details.poster_path, 'w500') || posterUrl;
|
||||||
|
}
|
||||||
|
overview = details.overview || '';
|
||||||
|
genres = details.genres?.map((g: any) => g.name) || [];
|
||||||
|
year = details.first_air_date ? new Date(details.first_air_date).getFullYear() : year;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to fetch TMDB data for ${show.title}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (posterUrl === 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image' && show.images) {
|
||||||
|
const traktPosterUrl = TraktService.getTraktPosterUrl(show.images);
|
||||||
|
if (traktPosterUrl) posterUrl = traktPosterUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentToAdd: StreamingContent = {
|
||||||
|
id: imdbId,
|
||||||
|
type: 'series',
|
||||||
|
name: show.title,
|
||||||
|
poster: posterUrl,
|
||||||
|
posterShape: 'poster',
|
||||||
|
year,
|
||||||
|
description: overview,
|
||||||
|
genres,
|
||||||
|
imdbRating: undefined,
|
||||||
|
inLibrary: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
if (existingItem.poster !== posterUrl) {
|
||||||
|
await catalogService.addToLibrary(contentToAdd);
|
||||||
|
updatedCount++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await catalogService.addToLibrary(contentToAdd);
|
||||||
|
addedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show result
|
||||||
|
if (addedCount > 0 || updatedCount > 0 || removedCount > 0) {
|
||||||
|
let message = '';
|
||||||
|
if (addedCount > 0) message += `Added ${addedCount}`;
|
||||||
|
if (updatedCount > 0) message += `${message ? ', updated ' : 'Updated '}${updatedCount}`;
|
||||||
|
if (removedCount > 0) message += `${message ? ', removed ' : 'Removed '}${removedCount}`;
|
||||||
|
showInfo('Sync Complete', message);
|
||||||
|
logger.log(`[LibraryScreen] Sync complete: ${message}`);
|
||||||
|
} else {
|
||||||
|
showInfo('Sync Complete', 'Library is up to date');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[LibraryScreen] Sync failed:', error);
|
||||||
|
showError('Sync Failed', 'Unable to sync. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSyncing(false);
|
||||||
|
}
|
||||||
|
}, [traktAuthenticated, loadAllCollections, showInfo, showError]);
|
||||||
|
// Removed watchlistMovies and watchlistShows from deps - we access via refs!
|
||||||
|
|
||||||
|
// Automatic sync on first visit
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
traktSyncMode === 'automatic' &&
|
||||||
|
traktAuthenticated &&
|
||||||
|
!hasAutoSyncedThisSession.current &&
|
||||||
|
!showTraktContent &&
|
||||||
|
!showSimklContent
|
||||||
|
) {
|
||||||
|
hasAutoSyncedThisSession.current = true;
|
||||||
|
logger.log('[LibraryScreen] Performing automatic sync');
|
||||||
|
syncTraktWatchlistToLibrary();
|
||||||
|
}
|
||||||
|
|
||||||
|
}, [traktSyncMode, traktAuthenticated, showTraktContent, showSimklContent, syncTraktWatchlistToLibrary]);
|
||||||
|
|
||||||
const filteredItems = libraryItems.filter(item => {
|
const filteredItems = libraryItems.filter(item => {
|
||||||
if (filter === 'movies') return item.type === 'movie';
|
if (filter === 'movies') return item.type === 'movie';
|
||||||
if (filter === 'series') return item.type === 'series';
|
if (filter === 'series') return item.type === 'series';
|
||||||
|
|
@ -465,7 +729,7 @@ const LibraryScreen = () => {
|
||||||
];
|
];
|
||||||
|
|
||||||
return folders.filter(folder => folder.itemCount > 0);
|
return folders.filter(folder => folder.itemCount > 0);
|
||||||
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
|
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent, t]);
|
||||||
|
|
||||||
const simklFolders = useMemo((): TraktFolder[] => {
|
const simklFolders = useMemo((): TraktFolder[] => {
|
||||||
if (!simklAuthenticated) return [];
|
if (!simklAuthenticated) return [];
|
||||||
|
|
@ -1438,6 +1702,9 @@ const LibraryScreen = () => {
|
||||||
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
|
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
|
||||||
}, [width, height]);
|
}, [width, height]);
|
||||||
|
|
||||||
|
// Show sync button only when mode is 'manual' and viewing local library
|
||||||
|
const shouldShowSyncButton = traktSyncMode === 'manual' && !showTraktContent && !showSimklContent && traktAuthenticated;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
|
|
@ -1486,6 +1753,29 @@ const LibraryScreen = () => {
|
||||||
{showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()}
|
{showTraktContent ? renderTraktContent() : showSimklContent ? renderSimklContent() : renderContent()}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Sync FAB - Bottom Right (only in manual mode) */}
|
||||||
|
{shouldShowSyncButton && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.syncFab,
|
||||||
|
{
|
||||||
|
backgroundColor: currentTheme.colors.primary,
|
||||||
|
bottom: insets.bottom + 80,
|
||||||
|
shadowColor: currentTheme.colors.black,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={syncTraktWatchlistToLibrary}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
disabled={isSyncing}
|
||||||
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<ActivityIndicator color={currentTheme.colors.white} size="small" />
|
||||||
|
) : (
|
||||||
|
<MaterialIcons name="sync" size={24} color={currentTheme.colors.white} />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
{selectedItem && (
|
{selectedItem && (
|
||||||
<DropUpMenu
|
<DropUpMenu
|
||||||
visible={menuVisible}
|
visible={menuVisible}
|
||||||
|
|
@ -1831,6 +2121,19 @@ const styles = StyleSheet.create({
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
syncFab: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 16,
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
elevation: 6,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default LibraryScreen;
|
export default LibraryScreen;
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState, useRef, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -27,6 +27,8 @@ import { useSimklIntegration } from '../hooks/useSimklIntegration';
|
||||||
import { colors } from '../styles';
|
import { colors } from '../styles';
|
||||||
import CustomAlert from '../components/CustomAlert';
|
import CustomAlert from '../components/CustomAlert';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
||||||
|
import { mmkvStorage } from '../services/mmkvStorage';
|
||||||
|
|
||||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||||
|
|
||||||
|
|
@ -36,6 +38,7 @@ const TRAKT_CLIENT_ID = process.env.EXPO_PUBLIC_TRAKT_CLIENT_ID as string;
|
||||||
if (!TRAKT_CLIENT_ID) {
|
if (!TRAKT_CLIENT_ID) {
|
||||||
throw new Error('Missing EXPO_PUBLIC_TRAKT_CLIENT_ID environment variable');
|
throw new Error('Missing EXPO_PUBLIC_TRAKT_CLIENT_ID environment variable');
|
||||||
}
|
}
|
||||||
|
|
||||||
const discovery = {
|
const discovery = {
|
||||||
authorizationEndpoint: 'https://trakt.tv/oauth/authorize',
|
authorizationEndpoint: 'https://trakt.tv/oauth/authorize',
|
||||||
tokenEndpoint: 'https://api.trakt.tv/oauth/token',
|
tokenEndpoint: 'https://api.trakt.tv/oauth/token',
|
||||||
|
|
@ -47,6 +50,15 @@ const redirectUri = makeRedirectUri({
|
||||||
path: 'auth/trakt',
|
path: 'auth/trakt',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Library Sync Mode constants
|
||||||
|
const TRAKT_LIBRARY_SYNC_MODE_KEY = 'trakt_library_sync_mode';
|
||||||
|
|
||||||
|
const LIBRARY_SYNC_MODE_OPTIONS = [
|
||||||
|
{ value: 'off', label: 'Off', description: 'Disable Trakt library sync completely' },
|
||||||
|
{ value: 'manual', label: 'Manual', description: 'Sync only when you tap the sync button' },
|
||||||
|
{ value: 'automatic', label: 'Automatic', description: 'Sync automatically when you open Library' },
|
||||||
|
];
|
||||||
|
|
||||||
const TraktSettingsScreen: React.FC = () => {
|
const TraktSettingsScreen: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { settings, updateSetting } = useSettings();
|
const { settings, updateSetting } = useSettings();
|
||||||
|
|
@ -80,6 +92,11 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
{ label: t('common.ok'), onPress: () => setAlertVisible(false) },
|
{ label: t('common.ok'), onPress: () => setAlertVisible(false) },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Library Sync Mode state
|
||||||
|
const [librarySyncMode, setLibrarySyncMode] = useState<'off' | 'manual' | 'automatic'>('off');
|
||||||
|
const librarySyncSheetRef = useRef<BottomSheetModal>(null);
|
||||||
|
const librarySyncSnapPoints = useMemo(() => ['45%'], []);
|
||||||
|
|
||||||
const openAlert = (
|
const openAlert = (
|
||||||
title: string,
|
title: string,
|
||||||
message: string,
|
message: string,
|
||||||
|
|
@ -132,6 +149,26 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
}, [checkAuthStatus]);
|
}, [checkAuthStatus]);
|
||||||
|
|
||||||
|
// Load library sync mode on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadLibrarySyncMode = async () => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
try {
|
||||||
|
const mode = await mmkvStorage.getItem(TRAKT_LIBRARY_SYNC_MODE_KEY);
|
||||||
|
if (mode === 'manual' || mode === 'automatic') {
|
||||||
|
setLibrarySyncMode(mode);
|
||||||
|
} else {
|
||||||
|
setLibrarySyncMode('off');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktSettingsScreen] Failed to load library sync mode:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadLibrarySyncMode();
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
// Setup expo-auth-session hook with PKCE
|
// Setup expo-auth-session hook with PKCE
|
||||||
const [request, response, promptAsync] = useAuthRequest(
|
const [request, response, promptAsync] = useAuthRequest(
|
||||||
{
|
{
|
||||||
|
|
@ -230,6 +267,42 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Library Sync Mode handlers
|
||||||
|
const renderBackdrop = useCallback(
|
||||||
|
(props: any) => (
|
||||||
|
<BottomSheetBackdrop
|
||||||
|
{...props}
|
||||||
|
disappearsOnIndex={-1}
|
||||||
|
appearsOnIndex={0}
|
||||||
|
opacity={0.5}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectLibrarySyncMode = async (mode: 'off' | 'manual' | 'automatic') => {
|
||||||
|
try {
|
||||||
|
setLibrarySyncMode(mode);
|
||||||
|
await mmkvStorage.setItem(TRAKT_LIBRARY_SYNC_MODE_KEY, mode);
|
||||||
|
librarySyncSheetRef.current?.dismiss();
|
||||||
|
|
||||||
|
// Show confirmation
|
||||||
|
const modeLabel = LIBRARY_SYNC_MODE_OPTIONS.find(o => o.value === mode)?.label || mode;
|
||||||
|
openAlert(
|
||||||
|
'Library Sync Mode Updated',
|
||||||
|
`Trakt library sync is now set to: ${modeLabel}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[TraktSettingsScreen] Failed to save library sync mode:', error);
|
||||||
|
openAlert('Error', 'Failed to update library sync mode');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLibrarySyncModeLabel = (mode: string): string => {
|
||||||
|
const option = LIBRARY_SYNC_MODE_OPTIONS.find(o => o.value === mode);
|
||||||
|
return option?.label || 'Off';
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={[
|
<SafeAreaView style={[
|
||||||
styles.container,
|
styles.container,
|
||||||
|
|
@ -502,6 +575,35 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Library Sync Mode Setting */}
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.settingContent}
|
||||||
|
onPress={() => librarySyncSheetRef.current?.present()}
|
||||||
|
>
|
||||||
|
<View style={styles.settingTextContainer}>
|
||||||
|
<Text style={[
|
||||||
|
styles.settingLabel,
|
||||||
|
{ color: currentTheme.colors.highEmphasis }
|
||||||
|
]}>
|
||||||
|
Library Sync Mode
|
||||||
|
</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.settingDescription,
|
||||||
|
{ color: currentTheme.colors.mediumEmphasis }
|
||||||
|
]}>
|
||||||
|
{getLibrarySyncModeLabel(librarySyncMode)} - Sync your Trakt watchlist to local library
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<MaterialIcons
|
||||||
|
name="chevron-right"
|
||||||
|
size={24}
|
||||||
|
color={currentTheme.colors.mediumEmphasis}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.settingItem}>
|
<View style={styles.settingItem}>
|
||||||
<View style={styles.settingContent}>
|
<View style={styles.settingContent}>
|
||||||
<View style={styles.settingTextContainer}>
|
<View style={styles.settingTextContainer}>
|
||||||
|
|
@ -589,8 +691,6 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
@ -606,6 +706,64 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
onClose={() => setAlertVisible(false)}
|
onClose={() => setAlertVisible(false)}
|
||||||
actions={alertActions}
|
actions={alertActions}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Library Sync Mode Bottom Sheet */}
|
||||||
|
<BottomSheetModal
|
||||||
|
ref={librarySyncSheetRef}
|
||||||
|
index={0}
|
||||||
|
snapPoints={librarySyncSnapPoints}
|
||||||
|
enableDynamicSizing={false}
|
||||||
|
enablePanDownToClose={true}
|
||||||
|
backdropComponent={renderBackdrop}
|
||||||
|
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
|
||||||
|
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
|
||||||
|
>
|
||||||
|
<View style={styles.sheetHeader}>
|
||||||
|
<Text style={[styles.sheetTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
|
Library Sync Mode
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
|
||||||
|
{LIBRARY_SYNC_MODE_OPTIONS.map((option) => {
|
||||||
|
const isSelected = option.value === librarySyncMode;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={option.value}
|
||||||
|
style={[
|
||||||
|
styles.sourceItem,
|
||||||
|
isSelected && {
|
||||||
|
backgroundColor: currentTheme.colors.primary + '20',
|
||||||
|
borderColor: currentTheme.colors.primary
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={() => handleSelectLibrarySyncMode(option.value as 'off' | 'manual' | 'automatic')}
|
||||||
|
>
|
||||||
|
<View style={styles.sourceItemContent}>
|
||||||
|
<Text style={[
|
||||||
|
styles.sourceLabel,
|
||||||
|
{ color: isSelected ? currentTheme.colors.primary : currentTheme.colors.highEmphasis }
|
||||||
|
]}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
<Text style={[
|
||||||
|
styles.sourceDescription,
|
||||||
|
{ color: currentTheme.colors.mediumEmphasis }
|
||||||
|
]}>
|
||||||
|
{option.description}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{isSelected && (
|
||||||
|
<MaterialIcons
|
||||||
|
name="check"
|
||||||
|
size={20}
|
||||||
|
color={currentTheme.colors.primary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</BottomSheetScrollView>
|
||||||
|
</BottomSheetModal>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -857,6 +1015,45 @@ const styles = StyleSheet.create({
|
||||||
marginVertical: 20,
|
marginVertical: 20,
|
||||||
paddingHorizontal: 20,
|
paddingHorizontal: 20,
|
||||||
},
|
},
|
||||||
|
// Bottom Sheet styles
|
||||||
|
sheetHeader: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 12,
|
||||||
|
paddingBottom: 16,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||||
|
},
|
||||||
|
sheetTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
sheetContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 12,
|
||||||
|
},
|
||||||
|
sourceItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
sourceItemContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
sourceLabel: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
sourceDescription: {
|
||||||
|
fontSize: 13,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default TraktSettingsScreen;
|
export default TraktSettingsScreen;
|
||||||
Loading…
Reference in a new issue