mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-16 07:46:29 +00:00
feat: implement robust MyAnimeList integration with offline mapping and library UI
This commit is contained in:
parent
50c1f36413
commit
7a09c46ccb
13 changed files with 763 additions and 499 deletions
261
src/components/mal/MalEditModal.tsx
Normal file
261
src/components/mal/MalEditModal.tsx
Normal file
|
|
@ -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<MalEditModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
anime,
|
||||
onUpdateSuccess,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { showSuccess, showError } = useToast();
|
||||
|
||||
const [status, setStatus] = useState<MalListStatus>(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 (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={[styles.modalContent, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1}>
|
||||
Edit {anime.node.title}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={onClose}>
|
||||
<MaterialIcons name="close" size={24} color={currentTheme.colors.mediumEmphasis} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>Status</Text>
|
||||
<View style={styles.statusGrid}>
|
||||
{statusOptions.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.statusChip,
|
||||
{ borderColor: currentTheme.colors.border },
|
||||
status === option.value && {
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
borderColor: currentTheme.colors.primary
|
||||
}
|
||||
]}
|
||||
onPress={() => setStatus(option.value)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.statusText,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
status === option.value && { color: 'white' }
|
||||
]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View style={styles.inputRow}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Episodes ({anime.node.num_episodes || '?'})
|
||||
</Text>
|
||||
<TextInput
|
||||
style={[styles.input, {
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
borderColor: currentTheme.colors.border,
|
||||
backgroundColor: currentTheme.colors.elevation1
|
||||
}]}
|
||||
value={episodes}
|
||||
onChangeText={setEpisodes}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.mediumEmphasis }]}>Score (0-10)</Text>
|
||||
<TextInput
|
||||
style={[styles.input, {
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
borderColor: currentTheme.colors.border,
|
||||
backgroundColor: currentTheme.colors.elevation1
|
||||
}]}
|
||||
value={score}
|
||||
onChangeText={setScore}
|
||||
keyboardType="numeric"
|
||||
placeholder="0"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleUpdate}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{isUpdating ? (
|
||||
<ActivityIndicator color="white" />
|
||||
) : (
|
||||
<Text style={styles.updateButtonText}>Update MAL</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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 (
|
||||
<Animated.View style={[isTablet ? styles.tabletActionButtons : styles.actionButtons, animatedStyle]}>
|
||||
|
|
@ -460,33 +453,6 @@ const ActionButtons = memo(({
|
|||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* MAL Button */}
|
||||
{hasMal && (
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, isTablet && styles.tabletIconButton, styles.singleRowIconButton]}
|
||||
onPress={onMalPress}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{Platform.OS === 'ios' ? (
|
||||
GlassViewComp && liquidGlassAvailable ? (
|
||||
<GlassViewComp
|
||||
style={styles.blurBackgroundRound}
|
||||
glassEffectStyle="regular"
|
||||
/>
|
||||
) : (
|
||||
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
|
||||
)
|
||||
) : (
|
||||
<View style={styles.androidFallbackBlurRound} />
|
||||
)}
|
||||
<Image
|
||||
source={require('../../../assets/rating-icons/mal-icon.png')}
|
||||
style={{ width: isTablet ? 28 : 24, height: isTablet ? 28 : 24, borderRadius: isTablet ? 14 : 12 }}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
@ -891,8 +857,6 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
dynamicBackgroundColor,
|
||||
handleBack,
|
||||
tmdbId,
|
||||
malId,
|
||||
onMalPress,
|
||||
// Trakt integration props
|
||||
isAuthenticated,
|
||||
isInWatchlist,
|
||||
|
|
@ -1916,27 +1880,25 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
{/* Optimized Action Buttons */}
|
||||
<ActionButtons
|
||||
handleShowStreams={handleShowStreams}
|
||||
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}
|
||||
malId={malId}
|
||||
onMalPress={onMalPress}
|
||||
/>
|
||||
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}
|
||||
/>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -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<MalScoreModalProps> = ({
|
||||
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<MalListStatus>(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 (
|
||||
<Modal visible={visible} transparent animationType="fade">
|
||||
<View style={styles.overlay}>
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.elevation2 || '#1E1E1E' }]}>
|
||||
<View style={styles.header}>
|
||||
<Image
|
||||
source={require('../../../assets/rating-icons/mal-icon.png')}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>{animeTitle}</Text>
|
||||
</View>
|
||||
|
||||
{/* Season Selector */}
|
||||
{type === 'series' && seasons.length > 1 && (
|
||||
<View style={styles.seasonContainer}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis, marginTop: 0 }]}>Season</Text>
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.seasonScroll}>
|
||||
{seasons.sort((a, b) => a - b).map((s) => (
|
||||
<TouchableOpacity
|
||||
key={s}
|
||||
style={[
|
||||
styles.seasonChip,
|
||||
selectedSeason === s && { backgroundColor: currentTheme.colors.primary },
|
||||
{ borderColor: currentTheme.colors.border }
|
||||
]}
|
||||
onPress={() => setSelectedSeason(s)}
|
||||
>
|
||||
<Text style={[styles.chipText, selectedSeason === s && { color: '#fff', fontWeight: 'bold' }]}>
|
||||
Season {s}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{fetchingData ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Status</Text>
|
||||
<View style={styles.optionsRow}>
|
||||
{STATUS_OPTIONS.map((opt) => (
|
||||
<TouchableOpacity
|
||||
key={opt.value}
|
||||
style={[
|
||||
styles.chip,
|
||||
status === opt.value && { backgroundColor: currentTheme.colors.primary },
|
||||
{ borderColor: currentTheme.colors.border }
|
||||
]}
|
||||
onPress={() => setStatus(opt.value)}
|
||||
>
|
||||
<Text style={[styles.chipText, status === opt.value && { color: '#fff' }]}>{opt.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Episodes Watched</Text>
|
||||
<View style={styles.episodeRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.roundButton, { borderColor: currentTheme.colors.border }]}
|
||||
onPress={() => setEpisodes(Math.max(0, episodes - 1))}
|
||||
>
|
||||
<MaterialIcons name="remove" size={20} color={currentTheme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.episodeDisplay}>
|
||||
<Text style={[styles.episodeCount, { color: currentTheme.colors.highEmphasis }]}>{episodes}</Text>
|
||||
{totalEpisodes > 0 && (
|
||||
<Text style={[styles.totalEpisodes, { color: currentTheme.colors.mediumEmphasis }]}> / {totalEpisodes}</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.roundButton, { borderColor: currentTheme.colors.border }]}
|
||||
onPress={() => setEpisodes(totalEpisodes > 0 ? Math.min(totalEpisodes, episodes + 1) : episodes + 1)}
|
||||
>
|
||||
<MaterialIcons name="add" size={20} color={currentTheme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.mediumEmphasis }]}>Score</Text>
|
||||
<View style={styles.optionsRow}>
|
||||
{[...Array(11).keys()].map((s) => (
|
||||
<TouchableOpacity
|
||||
key={s}
|
||||
style={[
|
||||
styles.scoreChip,
|
||||
score === s && { backgroundColor: '#F5C518', borderColor: '#F5C518' },
|
||||
{ borderColor: currentTheme.colors.border }
|
||||
]}
|
||||
onPress={() => setScore(s)}
|
||||
>
|
||||
<Text style={[styles.chipText, score === s && { color: '#000', fontWeight: 'bold' }]}>{s === 0 ? '-' : s}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</ScrollView>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<TouchableOpacity style={styles.cancelButton} onPress={onClose}>
|
||||
<Text style={{ color: currentTheme.colors.mediumEmphasis }}>Cancel</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { backgroundColor: currentTheme.colors.primary, opacity: (loading || fetchingData || !activeMalId) ? 0.6 : 1 }]}
|
||||
onPress={handleSave}
|
||||
disabled={loading || fetchingData || !activeMalId}
|
||||
>
|
||||
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.saveButtonText}>Save</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
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 },
|
||||
});
|
||||
|
|
@ -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
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="MalLibrary"
|
||||
component={MalLibraryScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'default' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PlayerSettings"
|
||||
component={PlayerSettingsScreen}
|
||||
|
|
|
|||
|
|
@ -965,9 +965,7 @@ const LibraryScreen = () => {
|
|||
return;
|
||||
}
|
||||
if (filterType === 'mal') {
|
||||
setShowTraktContent(false);
|
||||
setFilter('mal');
|
||||
loadMalList();
|
||||
navigation.navigate('MalLibrary');
|
||||
return;
|
||||
}
|
||||
setShowTraktContent(false);
|
||||
|
|
|
|||
312
src/screens/MalLibraryScreen.tsx
Normal file
312
src/screens/MalLibraryScreen.tsx
Normal file
|
|
@ -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<any>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [groupedList, setGroupedList] = useState<Record<MalListStatus, MalAnimeNode[]>>({
|
||||
watching: [],
|
||||
completed: [],
|
||||
on_hold: [],
|
||||
dropped: [],
|
||||
plan_to_watch: [],
|
||||
});
|
||||
|
||||
const [selectedAnime, setSelectedAnime] = useState<MalAnimeNode | null>(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<MalListStatus, MalAnimeNode[]> = {
|
||||
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 }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.animeItem}
|
||||
onPress={() => handleItemPress(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FastImage
|
||||
source={{ uri: item.node.main_picture?.medium }}
|
||||
style={styles.poster}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<View style={styles.badgeContainer}>
|
||||
<View style={[styles.episodeBadge, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<Text style={styles.episodeText}>
|
||||
{item.list_status.num_episodes_watched} / {item.node.num_episodes || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[styles.animeTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={2}>
|
||||
{item.node.title}
|
||||
</Text>
|
||||
{item.list_status.score > 0 && (
|
||||
<View style={styles.scoreRow}>
|
||||
<MaterialIcons name="star" size={12} color="#FFD700" />
|
||||
<Text style={[styles.scoreText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{item.list_status.score}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Requirement 5: Manual update button */}
|
||||
<TouchableOpacity
|
||||
style={styles.editButton}
|
||||
onPress={() => {
|
||||
setSelectedAnime(item);
|
||||
setIsEditModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<MaterialIcons name="edit" size={16} color={currentTheme.colors.white} />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const renderSection = (status: MalListStatus, title: string, icon: string) => {
|
||||
const data = groupedList[status];
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.sectionContainer}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialIcons name={icon as any} size={20} color={currentTheme.colors.primary} style={{ marginRight: 8 }} />
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
{title} ({data.length})
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={data}
|
||||
renderItem={renderAnimeItem}
|
||||
keyExtractor={item => item.node.id.toString()}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.carouselContent}
|
||||
snapToInterval={ITEM_WIDTH + 12}
|
||||
decelerationRate="fast"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={() => navigation.goBack()} style={styles.backButton}>
|
||||
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.highEmphasis} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
MyAnimeList
|
||||
</Text>
|
||||
{/* Requirement 6: Manual Sync Button */}
|
||||
<TouchableOpacity onPress={handleRefresh} style={styles.syncButton} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
) : (
|
||||
<MaterialIcons name="sync" size={24} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{!isLoading || isRefreshing ? (
|
||||
<ScrollView
|
||||
refreshControl={
|
||||
<RefreshControl refreshing={isRefreshing} onRefresh={handleRefresh} tintColor={currentTheme.colors.primary} />
|
||||
}
|
||||
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')}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{selectedAnime && (
|
||||
<MalEditModal
|
||||
visible={isEditModalVisible}
|
||||
anime={selectedAnime}
|
||||
onClose={() => {
|
||||
setIsEditModalVisible(false);
|
||||
setSelectedAnime(null);
|
||||
}}
|
||||
onUpdateSuccess={fetchMalList}
|
||||
/>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
|
|
@ -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 (
|
||||
<SafeAreaView style={[
|
||||
styles.container,
|
||||
|
|
@ -193,6 +200,22 @@ const MalSettingsScreen: React.FC = () => {
|
|||
>
|
||||
<Text style={styles.buttonText}>Sign Out</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary, marginTop: 12 }]}
|
||||
onPress={() => {
|
||||
setIsLoading(true);
|
||||
MalSync.syncMalToLibrary().then(() => {
|
||||
setIsLoading(false);
|
||||
openAlert('Sync Complete', 'MAL data has been refreshed.');
|
||||
});
|
||||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="sync" size={20} color="white" style={{ marginRight: 8 }} />
|
||||
<Text style={styles.buttonText}>Sync Now</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.signInContainer}>
|
||||
|
|
@ -207,12 +230,6 @@ const MalSettingsScreen: React.FC = () => {
|
|||
<Text style={[styles.signInDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Sync your watch history and manage your anime list.
|
||||
</Text>
|
||||
<View style={[styles.noteContainer, { backgroundColor: currentTheme.colors.primary + '15', borderColor: currentTheme.colors.primary + '30' }]}>
|
||||
<MaterialIcons name="info-outline" size={18} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.noteText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
MAL sync only works with the <Text style={{ fontWeight: 'bold' }}>AnimeKitsu</Text> catalog items.
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleSignIn}
|
||||
|
|
@ -234,10 +251,10 @@ const MalSettingsScreen: React.FC = () => {
|
|||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Enable Sync
|
||||
Enable MAL Sync
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Sync watch status to MyAnimeList
|
||||
Global switch to enable or disable all MyAnimeList features.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
|
|
@ -256,7 +273,7 @@ const MalSettingsScreen: React.FC = () => {
|
|||
Auto Episode Update
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Automatically update episode progress when watching
|
||||
Automatically update your progress on MAL when you finish watching an episode (>=90% completion).
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
|
|
@ -267,6 +284,25 @@ const MalSettingsScreen: React.FC = () => {
|
|||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={styles.settingTextContainer}>
|
||||
<Text style={[styles.settingLabel, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Auto Add Anime
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
If an anime is not in your MAL list, it will be added automatically when you start watching.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={autoAddEnabled}
|
||||
onValueChange={toggleAutoAdd}
|
||||
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
|
||||
thumbColor={autoAddEnabled ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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<number | null>(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<any>(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 && (
|
||||
<MalScoreModal
|
||||
visible={malModalVisible}
|
||||
onClose={() => 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'}
|
||||
/>
|
||||
)}
|
||||
</AnimatedSafeAreaView>
|
||||
</Animated.View>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<NavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<any>();
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<number | null> => {
|
||||
// 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);
|
||||
|
|
|
|||
|
|
@ -1186,64 +1186,9 @@ class StremioService {
|
|||
async getStreams(type: string, id: string, callback?: StreamCallback): Promise<void> {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue