diff --git a/src/components/mal/MalEditModal.tsx b/src/components/mal/MalEditModal.tsx new file mode 100644 index 00000000..0a2f3931 --- /dev/null +++ b/src/components/mal/MalEditModal.tsx @@ -0,0 +1,261 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + Modal, + TouchableOpacity, + TextInput, + ScrollView, + KeyboardAvoidingView, + Platform, + ActivityIndicator, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useTheme } from '../../contexts/ThemeContext'; +import { MalApiService } from '../../services/mal/MalApi'; +import { MalListStatus, MalAnimeNode } from '../../types/mal'; +import { useToast } from '../../contexts/ToastContext'; + +interface MalEditModalProps { + visible: boolean; + onClose: () => void; + anime: MalAnimeNode; + onUpdateSuccess: () => void; +} + +export const MalEditModal: React.FC = ({ + visible, + onClose, + anime, + onUpdateSuccess, +}) => { + const { currentTheme } = useTheme(); + const { showSuccess, showError } = useToast(); + + const [status, setStatus] = useState(anime.list_status.status); + const [episodes, setEpisodes] = useState(anime.list_status.num_episodes_watched.toString()); + const [score, setScore] = useState(anime.list_status.score.toString()); + const [isUpdating, setIsUpdating] = useState(false); + + useEffect(() => { + if (visible) { + setStatus(anime.list_status.status); + setEpisodes(anime.list_status.num_episodes_watched.toString()); + setScore(anime.list_status.score.toString()); + } + }, [visible, anime]); + + const handleUpdate = async () => { + setIsUpdating(true); + try { + const epNum = parseInt(episodes, 10) || 0; + let scoreNum = parseInt(score, 10) || 0; + + // Validation: MAL scores must be between 0 and 10 + scoreNum = Math.max(0, Math.min(10, scoreNum)); + + await MalApiService.updateStatus(anime.node.id, status, epNum, scoreNum); + + showSuccess('Updated', `${anime.node.title} status updated on MAL`); + onUpdateSuccess(); + onClose(); + } catch (error) { + showError('Update Failed', 'Could not update MAL status'); + } finally { + setIsUpdating(false); + } + }; + + const statusOptions: { label: string; value: MalListStatus }[] = [ + { label: 'Watching', value: 'watching' }, + { label: 'Completed', value: 'completed' }, + { label: 'On Hold', value: 'on_hold' }, + { label: 'Dropped', value: 'dropped' }, + { label: 'Plan to Watch', value: 'plan_to_watch' }, + ]; + + return ( + + + + + + + Edit {anime.node.title} + + + + + + + + Status + + {statusOptions.map((option) => ( + setStatus(option.value)} + > + + {option.label} + + + ))} + + + + + + Episodes ({anime.node.num_episodes || '?'}) + + + + + + Score (0-10) + + + + + + {isUpdating ? ( + + ) : ( + Update MAL + )} + + + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.7)', + justifyContent: 'center', + alignItems: 'center', + padding: 20, + }, + container: { + width: '100%', + maxWidth: 400, + }, + modalContent: { + borderRadius: 16, + padding: 20, + maxHeight: '90%', + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 20, + }, + title: { + fontSize: 18, + fontWeight: '700', + flex: 1, + marginRight: 10, + }, + label: { + fontSize: 14, + fontWeight: '600', + marginBottom: 8, + marginTop: 12, + }, + statusGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + statusChip: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + borderWidth: 1, + marginBottom: 4, + }, + statusText: { + fontSize: 13, + fontWeight: '500', + }, + inputRow: { + flexDirection: 'row', + gap: 16, + marginTop: 8, + }, + inputGroup: { + flex: 1, + }, + input: { + height: 44, + borderRadius: 8, + borderWidth: 1, + paddingHorizontal: 12, + fontSize: 16, + }, + updateButton: { + height: 48, + borderRadius: 24, + justifyContent: 'center', + alignItems: 'center', + marginTop: 24, + marginBottom: 10, + }, + updateButtonText: { + color: 'white', + fontSize: 16, + fontWeight: '700', + }, +}); diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 3a4a6693..a690e949 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -105,8 +105,6 @@ interface HeroSectionProps { dynamicBackgroundColor?: string; handleBack: () => void; tmdbId?: number | null; - malId?: number | null; - onMalPress?: () => void; } // Ultra-optimized ActionButtons Component - minimal re-renders @@ -129,9 +127,7 @@ const ActionButtons = memo(({ isInWatchlist, isInCollection, onToggleWatchlist, - onToggleCollection, - malId, - onMalPress + onToggleCollection }: { handleShowStreams: () => void; toggleLibrary: () => void; @@ -152,8 +148,6 @@ const ActionButtons = memo(({ isInCollection?: boolean; onToggleWatchlist?: () => void; onToggleCollection?: () => void; - malId?: number | null; - onMalPress?: () => void; }) => { const { currentTheme } = useTheme(); const { t } = useTranslation(); @@ -344,10 +338,9 @@ const ActionButtons = memo(({ // Count additional buttons (AI Chat removed - now in top right corner) const hasTraktCollection = isAuthenticated; const hasRatings = type === 'series'; - const hasMal = !!malId; // Count additional buttons (AI Chat removed - now in top right corner) - const additionalButtonCount = (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0) + (hasMal ? 1 : 0); + const additionalButtonCount = (hasTraktCollection ? 1 : 0) + (hasRatings ? 1 : 0); return ( @@ -460,33 +453,6 @@ const ActionButtons = memo(({ /> )} - - {/* MAL Button */} - {hasMal && ( - - {Platform.OS === 'ios' ? ( - GlassViewComp && liquidGlassAvailable ? ( - - ) : ( - - ) - ) : ( - - )} - - - )} ); @@ -891,8 +857,6 @@ const HeroSection: React.FC = memo(({ dynamicBackgroundColor, handleBack, tmdbId, - malId, - onMalPress, // Trakt integration props isAuthenticated, isInWatchlist, @@ -1916,27 +1880,25 @@ const HeroSection: React.FC = memo(({ {/* Optimized Action Buttons */} + toggleLibrary={handleToggleLibrary} + inLibrary={inLibrary} + type={type} + id={id} + navigation={navigation} + playButtonText={playButtonText} + animatedStyle={buttonsAnimatedStyle} + isWatched={isWatched} + watchProgress={watchProgress} + groupedEpisodes={groupedEpisodes} + metadata={metadata} + settings={settings} + // Trakt integration props + isAuthenticated={isAuthenticated} + isInWatchlist={isInWatchlist} + isInCollection={isInCollection} + onToggleWatchlist={onToggleWatchlist} + onToggleCollection={onToggleCollection} + /> diff --git a/src/components/metadata/MalScoreModal.tsx b/src/components/metadata/MalScoreModal.tsx deleted file mode 100644 index 20c6acea..00000000 --- a/src/components/metadata/MalScoreModal.tsx +++ /dev/null @@ -1,260 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Modal, View, Text, StyleSheet, TouchableOpacity, ScrollView, ActivityIndicator, Image } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { MalApiService } from '../../services/mal/MalApi'; -import { MalSync } from '../../services/mal/MalSync'; -import { MalListStatus } from '../../types/mal'; -import { useTheme } from '../../contexts/ThemeContext'; - -interface MalScoreModalProps { - visible: boolean; - onClose: () => void; - malId: number; - animeTitle: string; - initialStatus?: MalListStatus; - initialScore?: number; - initialEpisodes?: number; - // Season support props - seasons?: number[]; - currentSeason?: number; - imdbId?: string; - type?: 'movie' | 'series'; -} - -const STATUS_OPTIONS: { label: string; value: MalListStatus }[] = [ - { label: 'Watching', value: 'watching' }, - { label: 'Completed', value: 'completed' }, - { label: 'On Hold', value: 'on_hold' }, - { label: 'Dropped', value: 'dropped' }, - { label: 'Plan to Watch', value: 'plan_to_watch' }, -]; - -export const MalScoreModal: React.FC = ({ - visible, - onClose, - malId, - animeTitle, - initialStatus = 'watching', - initialScore = 0, - initialEpisodes = 0, - seasons = [], - currentSeason = 1, - imdbId, - type = 'series' -}) => { - const { currentTheme } = useTheme(); - - // State for season management - const [selectedSeason, setSelectedSeason] = useState(currentSeason); - const [activeMalId, setActiveMalId] = useState(malId); - const [fetchingData, setFetchingData] = useState(false); - - // Form State - const [status, setStatus] = useState(initialStatus); - const [score, setScore] = useState(initialScore); - const [episodes, setEpisodes] = useState(initialEpisodes); - const [totalEpisodes, setTotalEpisodes] = useState(0); - const [loading, setLoading] = useState(false); - - // Fetch data when season changes (only for series with multiple seasons) - useEffect(() => { - const loadSeasonData = async () => { - setFetchingData(true); - // Reset active ID to prevent writing to the wrong season if fetch fails - setActiveMalId(0); - - try { - // 1. Resolve MAL ID for this season - let resolvedId = malId; - if (type === 'series' && (seasons.length > 1 || selectedSeason !== currentSeason)) { - resolvedId = await MalSync.getMalId(animeTitle, type, undefined, selectedSeason, imdbId) || 0; - } - - if (resolvedId) { - setActiveMalId(resolvedId); - - // 2. Fetch user status for this ID - const data = await MalApiService.getMyListStatus(resolvedId); - - if (data.list_status) { - setStatus(data.list_status.status); - setScore(data.list_status.score); - setEpisodes(data.list_status.num_episodes_watched); - } else { - // Default if not in list - setStatus('plan_to_watch'); - setScore(0); - setEpisodes(0); - } - setTotalEpisodes(data.num_episodes || 0); - } else { - console.warn('Could not resolve MAL ID for season', selectedSeason); - } - } catch (e) { - console.error('Failed to load season data', e); - } finally { - setFetchingData(false); - } - }; - - loadSeasonData(); - }, [selectedSeason, type, animeTitle, imdbId, malId]); - - const handleSave = async () => { - setLoading(true); - try { - await MalApiService.updateStatus(activeMalId, status, episodes, score); - onClose(); - } catch (e) { - console.error('Failed to update MAL status', e); - } finally { - setLoading(false); - } - }; - - return ( - - - - - - {animeTitle} - - - {/* Season Selector */} - {type === 'series' && seasons.length > 1 && ( - - Season - - {seasons.sort((a, b) => a - b).map((s) => ( - setSelectedSeason(s)} - > - - Season {s} - - - ))} - - - )} - - {fetchingData ? ( - - - - ) : ( - - Status - - {STATUS_OPTIONS.map((opt) => ( - setStatus(opt.value)} - > - {opt.label} - - ))} - - - Episodes Watched - - setEpisodes(Math.max(0, episodes - 1))} - > - - - - - {episodes} - {totalEpisodes > 0 && ( - / {totalEpisodes} - )} - - - setEpisodes(totalEpisodes > 0 ? Math.min(totalEpisodes, episodes + 1) : episodes + 1)} - > - - - - - Score - - {[...Array(11).keys()].map((s) => ( - setScore(s)} - > - {s === 0 ? '-' : s} - - ))} - - - )} - - - - Cancel - - - {loading ? : Save} - - - - - - ); -}; - -const styles = StyleSheet.create({ - overlay: { flex: 1, backgroundColor: 'rgba(0,0,0,0.8)', justifyContent: 'center', padding: 20 }, - container: { borderRadius: 16, padding: 20, maxHeight: '85%' }, - header: { flexDirection: 'row', alignItems: 'center', marginBottom: 20 }, - logo: { width: 32, height: 32, marginRight: 12, borderRadius: 8 }, - title: { fontSize: 18, fontWeight: 'bold', flex: 1 }, - sectionTitle: { fontSize: 14, fontWeight: '600', marginTop: 16, marginBottom: 8 }, - optionsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 }, - chip: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 20, borderWidth: 1, marginBottom: 4 }, - scoreChip: { width: 40, height: 40, borderRadius: 20, borderWidth: 1, justifyContent: 'center', alignItems: 'center', marginBottom: 4 }, - chipText: { fontSize: 12, fontWeight: '500' }, - footer: { flexDirection: 'row', justifyContent: 'flex-end', gap: 16, marginTop: 24 }, - cancelButton: { padding: 12 }, - saveButton: { paddingHorizontal: 24, paddingVertical: 12, borderRadius: 24, minWidth: 100, alignItems: 'center' }, - saveButtonText: { color: '#fff', fontWeight: 'bold' }, - seasonContainer: { marginBottom: 8 }, - seasonScroll: { paddingVertical: 4, gap: 8 }, - seasonChip: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 20, borderWidth: 1, marginRight: 8 }, - loadingContainer: { height: 200, justifyContent: 'center', alignItems: 'center' }, - episodeRow: { flexDirection: 'row', alignItems: 'center', gap: 16, marginBottom: 8 }, - roundButton: { width: 40, height: 40, borderRadius: 20, borderWidth: 1, justifyContent: 'center', alignItems: 'center' }, - episodeDisplay: { flexDirection: 'row', alignItems: 'baseline', minWidth: 80, justifyContent: 'center' }, - episodeCount: { fontSize: 20, fontWeight: 'bold' }, - totalEpisodes: { fontSize: 14, marginLeft: 2 }, -}); diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index b5302b5f..9553cff2 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -56,6 +56,7 @@ import HomeScreenSettings from '../screens/HomeScreenSettings'; import HeroCatalogsScreen from '../screens/HeroCatalogsScreen'; import TraktSettingsScreen from '../screens/TraktSettingsScreen'; import MalSettingsScreen from '../screens/MalSettingsScreen'; +import MalLibraryScreen from '../screens/MalLibraryScreen'; import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import ThemeScreen from '../screens/ThemeScreen'; import OnboardingScreen from '../screens/OnboardingScreen'; @@ -187,6 +188,7 @@ export type RootStackParamList = { HeroCatalogs: undefined; TraktSettings: undefined; MalSettings: undefined; + MalLibrary: undefined; PlayerSettings: undefined; ThemeSettings: undefined; ScraperSettings: undefined; @@ -1582,6 +1584,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> + { return; } if (filterType === 'mal') { - setShowTraktContent(false); - setFilter('mal'); - loadMalList(); + navigation.navigate('MalLibrary'); return; } setShowTraktContent(false); diff --git a/src/screens/MalLibraryScreen.tsx b/src/screens/MalLibraryScreen.tsx new file mode 100644 index 00000000..1aa5b893 --- /dev/null +++ b/src/screens/MalLibraryScreen.tsx @@ -0,0 +1,312 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + FlatList, + ActivityIndicator, + SafeAreaView, + StatusBar, + Platform, + Dimensions, + RefreshControl, + ScrollView, +} from 'react-native'; +import { useNavigation } from '@react-navigation/native'; +import { MaterialIcons } from '@expo/vector-icons'; +import FastImage from '@d11/react-native-fast-image'; +import { MalApiService } from '../services/mal/MalApi'; +import { MalAnimeNode, MalListStatus } from '../types/mal'; +import { useTheme } from '../contexts/ThemeContext'; +import { useTranslation } from 'react-i18next'; +import { mappingService } from '../services/MappingService'; +import { logger } from '../utils/logger'; +import { MalEditModal } from '../components/mal/MalEditModal'; + +const { width } = Dimensions.get('window'); +const ITEM_WIDTH = width * 0.35; +const ITEM_HEIGHT = ITEM_WIDTH * 1.5; + +const MalLibraryScreen: React.FC = () => { + const { t } = useTranslation(); + const navigation = useNavigation(); + const { currentTheme } = useTheme(); + + const [isLoading, setIsLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + const [groupedList, setGroupedList] = useState>({ + watching: [], + completed: [], + on_hold: [], + dropped: [], + plan_to_watch: [], + }); + + const [selectedAnime, setSelectedAnime] = useState(null); + const [isEditModalVisible, setIsEditModalVisible] = useState(false); + + const fetchMalList = useCallback(async () => { + try { + setIsLoading(true); + + let allItems: MalAnimeNode[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore && offset < 1000) { + const response = await MalApiService.getUserList(undefined, offset, 100); + if (response.data && response.data.length > 0) { + allItems = [...allItems, ...response.data]; + offset += response.data.length; + hasMore = !!response.paging.next; + } else { + hasMore = false; + } + } + + const grouped: Record = { + watching: [], + completed: [], + on_hold: [], + dropped: [], + plan_to_watch: [], + }; + + allItems.forEach(item => { + const status = item.list_status.status; + if (grouped[status]) { + grouped[status].push(item); + } + }); + + setGroupedList(grouped); + } catch (error) { + logger.error('[MalLibrary] Failed to fetch list', error); + } finally { + setIsLoading(false); + setIsRefreshing(false); + } + }, []); + + useEffect(() => { + fetchMalList(); + }, [fetchMalList]); + + const handleRefresh = () => { + setIsRefreshing(true); + fetchMalList(); + }; + + const handleItemPress = async (item: MalAnimeNode) => { + // Requirement 8: Resolve correct Cinemata / TMDB / IMDb ID + const malId = item.node.id; + + // Check offline mapping first (reverse lookup) + await mappingService.init(); + const imdbId = mappingService.getImdbIdFromMalId(malId); + + if (imdbId) { + navigation.navigate('Metadata', { + id: imdbId, + type: item.node.media_type === 'movie' ? 'movie' : 'series' + }); + } else { + // Fallback: Navigate to Search with the title if ID mapping is missing + logger.warn(`[MalLibrary] Could not resolve IMDb ID for MAL:${malId}. Falling back to Search.`); + navigation.navigate('Search', { query: item.node.title }); + } + }; + + const renderAnimeItem = ({ item }: { item: MalAnimeNode }) => ( + handleItemPress(item)} + activeOpacity={0.7} + > + + + + + {item.list_status.num_episodes_watched} / {item.node.num_episodes || '?'} + + + + + {item.node.title} + + {item.list_status.score > 0 && ( + + + + {item.list_status.score} + + + )} + + {/* Requirement 5: Manual update button */} + { + setSelectedAnime(item); + setIsEditModalVisible(true); + }} + > + + + + ); + + const renderSection = (status: MalListStatus, title: string, icon: string) => { + const data = groupedList[status]; + if (data.length === 0) return null; + + return ( + + + + + {title} ({data.length}) + + + item.node.id.toString()} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.carouselContent} + snapToInterval={ITEM_WIDTH + 12} + decelerationRate="fast" + /> + + ); + }; + + return ( + + + + navigation.goBack()} style={styles.backButton}> + + + + MyAnimeList + + {/* Requirement 6: Manual Sync Button */} + + {isLoading ? ( + + ) : ( + + )} + + + + {!isLoading || isRefreshing ? ( + + } + contentContainerStyle={{ paddingBottom: 40 }} + > + {renderSection('watching', 'Watching', 'play-circle-outline')} + {renderSection('plan_to_watch', 'Plan to Watch', 'bookmark-outline')} + {renderSection('completed', 'Completed', 'check-circle-outline')} + {renderSection('on_hold', 'On Hold', 'pause-circle-outline')} + {renderSection('dropped', 'Dropped', 'highlight-off')} + + ) : ( + + + + )} + + {selectedAnime && ( + { + setIsEditModalVisible(false); + setSelectedAnime(null); + }} + onUpdateSuccess={fetchMalList} + /> + )} + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1 }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + justifyContent: 'space-between' + }, + backButton: { padding: 4 }, + headerTitle: { fontSize: 20, fontWeight: '700', flex: 1, marginLeft: 16 }, + syncButton: { padding: 4 }, + loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + sectionContainer: { marginVertical: 12 }, + sectionHeader: { paddingHorizontal: 16, marginBottom: 8 }, + sectionTitle: { fontSize: 18, fontWeight: '700' }, + carouselContent: { paddingHorizontal: 10 }, + animeItem: { + width: ITEM_WIDTH, + marginHorizontal: 6, + marginBottom: 10, + }, + poster: { + width: ITEM_WIDTH, + height: ITEM_HEIGHT, + borderRadius: 8, + backgroundColor: '#333', + }, + badgeContainer: { + position: 'absolute', + top: 6, + left: 6, + }, + episodeBadge: { + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + }, + episodeText: { + color: 'white', + fontSize: 10, + fontWeight: '700', + }, + animeTitle: { + fontSize: 12, + fontWeight: '600', + marginTop: 6, + lineHeight: 16, + }, + scoreRow: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 2, + }, + scoreText: { + fontSize: 11, + marginLeft: 4, + }, + editButton: { + position: 'absolute', + top: 6, + right: 6, + backgroundColor: 'rgba(0,0,0,0.6)', + padding: 6, + borderRadius: 15, + } +}); + +export default MalLibraryScreen; diff --git a/src/screens/MalSettingsScreen.tsx b/src/screens/MalSettingsScreen.tsx index c1726b87..79e4729f 100644 --- a/src/screens/MalSettingsScreen.tsx +++ b/src/screens/MalSettingsScreen.tsx @@ -17,6 +17,7 @@ import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import FastImage from '@d11/react-native-fast-image'; import { MalAuth } from '../services/mal/MalAuth'; import { MalApiService } from '../services/mal/MalApi'; +import { MalSync } from '../services/mal/MalSync'; import { mmkvStorage } from '../services/mmkvStorage'; import { MalUser } from '../types/mal'; import { useTheme } from '../contexts/ThemeContext'; @@ -37,6 +38,7 @@ const MalSettingsScreen: React.FC = () => { const [syncEnabled, setSyncEnabled] = useState(mmkvStorage.getBoolean('mal_enabled') ?? true); const [autoUpdateEnabled, setAutoUpdateEnabled] = useState(mmkvStorage.getBoolean('mal_auto_update') ?? true); + const [autoAddEnabled, setAutoAddEnabled] = useState(mmkvStorage.getBoolean('mal_auto_add') ?? true); const [alertVisible, setAlertVisible] = useState(false); const [alertTitle, setAlertTitle] = useState(''); @@ -132,6 +134,11 @@ const MalSettingsScreen: React.FC = () => { mmkvStorage.setBoolean('mal_auto_update', val); }; + const toggleAutoAdd = (val: boolean) => { + setAutoAddEnabled(val); + mmkvStorage.setBoolean('mal_auto_add', val); + }; + return ( { > Sign Out + + { + setIsLoading(true); + MalSync.syncMalToLibrary().then(() => { + setIsLoading(false); + openAlert('Sync Complete', 'MAL data has been refreshed.'); + }); + }} + > + + + Sync Now + + ) : ( @@ -207,12 +230,6 @@ const MalSettingsScreen: React.FC = () => { Sync your watch history and manage your anime list. - - - - MAL sync only works with the AnimeKitsu catalog items. - - { - Enable Sync + Enable MAL Sync - Sync watch status to MyAnimeList + Global switch to enable or disable all MyAnimeList features. { Auto Episode Update - Automatically update episode progress when watching + Automatically update your progress on MAL when you finish watching an episode (>=90% completion). { /> + + + + + + Auto Add Anime + + + If an anime is not in your MAL list, it will be added automatically when you start watching. + + + + + )} diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index f85106cc..775dfab8 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -85,10 +85,6 @@ const MemoizedRatingsSection = memo(RatingsSection); const MemoizedCommentsSection = memo(CommentsSection); const MemoizedCastDetailsModal = memo(CastDetailsModal); -import { MalAuth } from '../services/mal/MalAuth'; -import { MalSync } from '../services/mal/MalSync'; -import { MalScoreModal } from '../components/metadata/MalScoreModal'; - // ... other imports const MetadataScreen: React.FC = () => { @@ -129,73 +125,6 @@ const MetadataScreen: React.FC = () => { loadingCollection, } = useMetadata({ id, type, addonId }); - const [malModalVisible, setMalModalVisible] = useState(false); - const [malId, setMalId] = useState(null); - const isMalAuthenticated = !!MalAuth.getToken(); - - useEffect(() => { - // STRICT MODE: Only enable MAL features if the content source is explicitly Anime (MAL/Kitsu) - // This prevents "fuzzy match" errors where Cinemeta shows get mapped to random anime or wrong seasons. - const isAnimeSource = id && (id.startsWith('mal:') || id.startsWith('kitsu:') || id.includes(':mal:') || id.includes(':kitsu:')); - - if (isMalAuthenticated && metadata?.name && isAnimeSource) { - // If it's a MAL source, extract ID directly - if (id.startsWith('mal:')) { - const directId = parseInt(id.split(':')[1], 10); - if (!isNaN(directId)) { - setMalId(directId); - return; - } - } - - // Otherwise resolve (e.g. Kitsu -> MAL) - MalSync.getMalId(metadata.name, Object.keys(groupedEpisodes).length > 0 ? 'series' : 'movie') - .then(id => setMalId(id)); - } else { - setMalId(null); - } - }, [isMalAuthenticated, metadata, groupedEpisodes, id]); - - // Log route parameters for debugging - React.useEffect(() => { - console.log('🔍 [MetadataScreen] Route params:', { id, type, episodeId, addonId }); - }, [id, type, episodeId, addonId]); - - // Enhanced responsive sizing for tablets and TV screens - const deviceWidth = Dimensions.get('window').width; - const deviceHeight = Dimensions.get('window').height; - - // Determine device type based on width - const getDeviceType = useCallback(() => { - if (deviceWidth >= BREAKPOINTS.tv) return 'tv'; - if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet'; - if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet'; - return 'phone'; - }, [deviceWidth]); - - const deviceType = getDeviceType(); - const isTablet = deviceType === 'tablet'; - const isLargeTablet = deviceType === 'largeTablet'; - const isTV = deviceType === 'tv'; - const isLargeScreen = isTablet || isLargeTablet || isTV; - - // Enhanced spacing and padding for production sections - const horizontalPadding = useMemo(() => { - switch (deviceType) { - case 'tv': - return 32; - case 'largeTablet': - return 28; - case 'tablet': - return 24; - default: - return 16; // phone - } - }, [deviceType]); - - // Optimized state management - reduced state variables - const [isContentReady, setIsContentReady] = useState(false); - const [showCastModal, setShowCastModal] = useState(false); const [selectedCastMember, setSelectedCastMember] = useState(null); const [shouldLoadSecondaryData, setShouldLoadSecondaryData] = useState(false); const [isScreenFocused, setIsScreenFocused] = useState(true); @@ -1029,8 +958,6 @@ const MetadataScreen: React.FC = () => { dynamicBackgroundColor={dynamicBackgroundColor} handleBack={handleBack} tmdbId={tmdbId} - malId={malId} - onMalPress={() => setMalModalVisible(true)} /> {/* Main Content - Optimized */} @@ -1458,19 +1385,6 @@ const MetadataScreen: React.FC = () => { isSpoilerRevealed={selectedComment ? revealedSpoilers.has(selectedComment.id.toString()) : false} onSpoilerPress={() => selectedComment && handleSpoilerPress(selectedComment)} /> - - {malId && ( - setMalModalVisible(false)} - malId={malId} - animeTitle={metadata?.name || ''} - seasons={Object.keys(groupedEpisodes).map(Number)} - currentSeason={selectedSeason} - imdbId={imdbId || undefined} - type={Object.keys(groupedEpisodes).length > 0 ? 'series' : type as 'movie' | 'series'} - /> - )} ); diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 8d5a6745..c9eb4c39 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -10,7 +10,7 @@ import { ScrollView, Platform, } from 'react-native'; -import { useNavigation, useFocusEffect } from '@react-navigation/native'; +import { useNavigation, useFocusEffect, useRoute } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { useTranslation } from 'react-i18next'; import { MaterialIcons } from '@expo/vector-icons'; @@ -63,6 +63,7 @@ const SearchScreen = () => { const { t } = useTranslation(); const { settings } = useSettings(); const navigation = useNavigation>(); + const route = useRoute(); const { addToWatchlist, removeFromWatchlist, addToCollection, removeFromCollection, isInWatchlist, isInCollection } = useTraktContext(); const { showSuccess, showInfo } = useToast(); const [query, setQuery] = useState(''); @@ -469,6 +470,13 @@ const SearchScreen = () => { useFocusEffect( useCallback(() => { isMounted.current = true; + + // Check for route query param + if (route.params?.query && route.params.query !== query) { + setQuery(route.params.query); + // The query effect will trigger debouncedSearch automatically + } + return () => { isMounted.current = false; if (liveSearchHandle.current) { @@ -477,7 +485,7 @@ const SearchScreen = () => { } debouncedSearch.cancel(); }; - }, [debouncedSearch]) + }, [debouncedSearch, route.params?.query]) ); const performLiveSearch = async (searchQuery: string) => { diff --git a/src/services/MappingService.ts b/src/services/MappingService.ts index 4bc69b46..d30bb2a6 100644 --- a/src/services/MappingService.ts +++ b/src/services/MappingService.ts @@ -25,6 +25,7 @@ interface Mappings { class MappingService { private mappings: Mappings = {}; private imdbIndex: { [imdbId: string]: string[] } = {}; // Maps IMDb ID to array of AniList IDs + private malIndex: { [malId: number]: string } = {}; // Maps MAL ID to AniList ID private isInitialized = false; /** @@ -63,7 +64,9 @@ class MappingService { */ private buildIndex() { this.imdbIndex = {}; + this.malIndex = {}; for (const [anilistId, entry] of Object.entries(this.mappings)) { + // IMDb Index if (entry.imdb_id) { const imdbIds = Array.isArray(entry.imdb_id) ? entry.imdb_id : [entry.imdb_id]; for (const id of imdbIds) { @@ -73,6 +76,14 @@ class MappingService { this.imdbIndex[id].push(anilistId); } } + + // MAL Index + if (entry.mal_id) { + const malIds = Array.isArray(entry.mal_id) ? entry.mal_id : [entry.mal_id]; + for (const id of malIds) { + this.malIndex[id] = anilistId; + } + } } } @@ -85,16 +96,11 @@ class MappingService { console.warn('MappingService not initialized. Call init() first.'); } - // Since we don't have a direct index for MAL IDs yet, we iterate (inefficient but works for now) - // Optimization: In a real app, we should build a malIndex similar to imdbIndex during init() - for (const entry of Object.values(this.mappings)) { - if (entry.mal_id) { - const malIds = Array.isArray(entry.mal_id) ? entry.mal_id : [entry.mal_id]; - if (malIds.includes(malId)) { - if (entry.imdb_id) { - return Array.isArray(entry.imdb_id) ? entry.imdb_id[0] : entry.imdb_id; - } - } + const anilistId = this.malIndex[malId]; + if (anilistId) { + const entry = this.mappings[anilistId]; + if (entry && entry.imdb_id) { + return Array.isArray(entry.imdb_id) ? entry.imdb_id[0] : entry.imdb_id; } } return null; diff --git a/src/services/mal/MalSync.ts b/src/services/mal/MalSync.ts index 7438f8b5..865f974f 100644 --- a/src/services/mal/MalSync.ts +++ b/src/services/mal/MalSync.ts @@ -42,6 +42,17 @@ export const MalSync = { * Caches the result to avoid repeated API calls. */ getMalId: async (title: string, type: 'movie' | 'series' = 'series', year?: number, season?: number, imdbId?: string, episode: number = 1): Promise => { + // Safety check: Never perform a MAL search for generic placeholders or empty strings. + // This prevents "cache poisoning" where a generic term matches a random anime. + const normalizedTitle = title.trim().toLowerCase(); + const isGenericTitle = !normalizedTitle || normalizedTitle === 'anime' || normalizedTitle === 'movie'; + + if (isGenericTitle) { + // If we have an offline mapping, we can still try it below, + // but we MUST skip the fuzzy search logic at the end. + if (!imdbId) return null; + } + // 1. Try Offline Mapping Service (Most accurate for perfect season/episode matching) if (imdbId && type === 'series' && season !== undefined) { const offlineMalId = mappingService.getMalId(imdbId, season, episode); @@ -59,7 +70,9 @@ export const MalSync = { const cachedId = mmkvStorage.getNumber(cacheKey); if (cachedId) return cachedId; - // 3. Search MAL + // 3. Search MAL (Skip if generic title) + if (isGenericTitle) return null; + try { let searchQuery = cleanTitle; // For Season 2+, explicitly search for that season @@ -117,9 +130,48 @@ export const MalSync = { imdbId?: string ) => { try { + // Requirement 9 & 10: Respect user settings and safety + const isEnabled = mmkvStorage.getBoolean('mal_enabled') ?? true; + const isAutoUpdate = mmkvStorage.getBoolean('mal_auto_update') ?? true; + + if (!isEnabled || !isAutoUpdate) { + return; + } + const malId = await MalSync.getMalId(animeTitle, type, undefined, season, imdbId, episodeNumber); if (!malId) return; + // Check current status on MAL to avoid overwriting completed/dropped shows + try { + const currentInfo = await MalApiService.getMyListStatus(malId); + const currentStatus = currentInfo.my_list_status?.status; + const currentEpisodesWatched = currentInfo.my_list_status?.num_episodes_watched || 0; + + // Requirement 4: Auto-Add Anime to MAL (Configurable) + if (!currentStatus) { + const autoAdd = mmkvStorage.getBoolean('mal_auto_add') ?? true; + if (!autoAdd) { + console.log(`[MalSync] Skipping scrobble for ${animeTitle}: Not in list and auto-add disabled`); + return; + } + } + + // If already completed or dropped, don't auto-update via scrobble + if (currentStatus === 'completed' || currentStatus === 'dropped') { + console.log(`[MalSync] Skipping update for ${animeTitle}: Status is ${currentStatus}`); + return; + } + + // If we are just starting (ep 1) or resuming (plan_to_watch/on_hold/null), set to watching + // Also ensure we don't downgrade episode count (though unlikely with scrobbling forward) + if (episodeNumber <= currentEpisodesWatched) { + console.log(`[MalSync] Skipping update for ${animeTitle}: Episode ${episodeNumber} <= Current ${currentEpisodesWatched}`); + return; + } + } catch (e) { + // If error (e.g. not found), proceed to add it + } + let finalTotalEpisodes = totalEpisodes; // If totalEpisodes not provided, try to fetch it from MAL details @@ -152,17 +204,27 @@ export const MalSync = { */ syncMalToLibrary: async () => { try { - const list = await MalApiService.getUserList(); - const watching = list.data.filter(item => item.list_status.status === 'watching' || item.list_status.status === 'plan_to_watch'); + let allItems: MalAnimeNode[] = []; + let offset = 0; + let hasMore = true; + + while (hasMore && offset < 1000) { // Limit to 1000 items for safety + const response = await MalApiService.getUserList(undefined, offset, 100); + if (response.data && response.data.length > 0) { + allItems = [...allItems, ...response.data]; + offset += response.data.length; + hasMore = !!response.paging.next; + } else { + hasMore = false; + } + } - for (const item of watching) { - // Try to find in local catalogs to get a proper StreamingContent object - // This is complex because we need to map MAL -> Stremio/TMDB. - // For now, we'll just cache the mapping for future use. + for (const item of allItems) { const type = item.node.media_type === 'movie' ? 'movie' : 'series'; const cacheKey = `${MAPPING_PREFIX}${item.node.title.trim()}_${type}`; mmkvStorage.setNumber(cacheKey, item.node.id); } + console.log(`[MalSync] Synced ${allItems.length} items to mapping cache.`); return true; } catch (e) { console.error('syncMalToLibrary failed', e); diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 84f5dab7..175de509 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -1186,64 +1186,9 @@ class StremioService { async getStreams(type: string, id: string, callback?: StreamCallback): Promise { await this.ensureInitialized(); - // Resolve MAL/Kitsu IDs to IMDb/TMDB for better stream compatibility let activeId = id; let resolvedTmdbId: string | null = null; - if (id.startsWith('mal:') || id.includes(':mal:')) { - try { - // Parse MAL ID and potential season/episode - let malId: number | null = null; - let s: number | undefined; - let e: number | undefined; - - const parts = id.split(':'); - // Handle mal:123 - if (id.startsWith('mal:')) { - malId = parseInt(parts[1], 10); - // simple mal:id usually implies movie or main series entry, assume s1e1 if not present? - // MetadataScreen typically passes raw id for movies, or constructs episode string for series - } - // Handle series:mal:123:1:1 - else if (id.includes(':mal:')) { - // series:mal:123:1:1 - if (parts[1] === 'mal') malId = parseInt(parts[2], 10); - if (parts.length >= 5) { - s = parseInt(parts[3], 10); - e = parseInt(parts[4], 10); - } - } - - if (malId) { - logger.log(`[getStreams] Resolving MAL ID ${malId} to IMDb/TMDB...`); - const { imdbId, season: malSeason } = await MalSync.getIdsFromMalId(malId); - - if (imdbId) { - const finalSeason = s || malSeason || 1; - const finalEpisode = e || 1; - - // 1. Set ID for Stremio Addons (Torrentio/Debrid searchers prefer IMDb) - if (type === 'series') { - // Ensure proper IMDb format: tt12345:1:1 - activeId = `${imdbId}:${finalSeason}:${finalEpisode}`; - } else { - activeId = imdbId; - } - logger.log(`[getStreams] Resolved -> Stremio ID: ${activeId}`); - - // 2. Set ID for Local Scrapers (They prefer TMDB) - const tmdbIdNum = await TMDBService.getInstance().findTMDBIdByIMDB(imdbId); - if (tmdbIdNum) { - resolvedTmdbId = tmdbIdNum.toString(); - logger.log(`[getStreams] Resolved -> TMDB ID: ${resolvedTmdbId}`); - } - } - } - } catch (err) { - logger.error('[getStreams] Failed to resolve MAL ID:', err); - } - } - const addons = this.getInstalledAddons(); // Check if local scrapers are enabled and execute them first diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 8a8095eb..8c029783 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -3,7 +3,7 @@ import { storageService } from './storageService'; import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; import { MalSync } from './mal/MalSync'; -import { MalAuthService } from './mal/MalAuth'; +import { MalAuth } from './mal/MalAuth'; import { mappingService } from './MappingService'; /** @@ -39,6 +39,9 @@ class WatchedService { try { logger.log(`[WatchedService] Marking movie as watched: ${imdbId}`); + const isTraktAuth = await this.traktService.isAuthenticated(); + let syncedToTrakt = false; + // Sync to Trakt if (isTraktAuth) { syncedToTrakt = await this.traktService.addToWatchedMovies(imdbId, watchedAt); @@ -46,8 +49,8 @@ class WatchedService { } // Sync to MAL - const isMalAuth = await MalAuthService.isAuthenticated(); - if (isMalAuth) { + const malToken = MalAuth.getToken(); + if (malToken) { MalSync.scrobbleEpisode( 'Movie', 1, @@ -86,6 +89,9 @@ class WatchedService { try { logger.log(`[WatchedService] Marking episode as watched: ${showImdbId} S${season}E${episode}`); + const isTraktAuth = await this.traktService.isAuthenticated(); + let syncedToTrakt = false; + // Sync to Trakt if (isTraktAuth) { syncedToTrakt = await this.traktService.addToWatchedEpisodes( @@ -98,11 +104,8 @@ class WatchedService { } // Sync to MAL - const isMalAuth = await MalAuthService.isAuthenticated(); - if (isMalAuth && showImdbId) { - // We need the title for scrobbleEpisode (as fallback), - // but getMalId will now prioritize the IMDb mapping. - // We'll use a placeholder title or try to find it if possible. + const malToken = MalAuth.getToken(); + if (malToken && showImdbId) { MalSync.scrobbleEpisode( 'Anime', // Title fallback episode,