Merge branch 'main' into patch-8

This commit is contained in:
AdityasahuX07 2025-12-20 22:31:28 +05:30 committed by GitHub
commit 99db47d503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1225 additions and 1269 deletions

6
.gitignore vendored
View file

@ -31,9 +31,9 @@ yarn-error.*
*.pem
# local env files
.env
.env*.local
.env*.local
.env
# Sentry
ios/sentry.properties
android/sentry.properties
@ -70,7 +70,7 @@ sliderreadme.md
bottomsheet.md
fastimage.md
# Backup directories
## Backup directories
backup_sdk54_upgrade/
SDK54_UPGRADE_SUMMARY.md
SDK54_UPGRADE_SUMMARY.md

View file

@ -101,6 +101,7 @@ const AnnouncementOverlay: React.FC<AnnouncementOverlayProps> = ({
transparent
animationType="none"
statusBarTranslucent
supportedOrientations={['portrait', 'landscape']}
onRequestClose={handleClose}
>
<View style={styles.overlay}>

View file

@ -98,7 +98,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
const isWatched = !!isWatchedProp;
const inTraktWatchlist = isAuthenticated && isInWatchlist(item.id, item.type as 'movie' | 'show');
const inTraktCollection = isAuthenticated && isInCollection(item.id, item.type as 'movie' | 'show');
let menuOptions = [
{
icon: 'bookmark',
@ -152,6 +152,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
visible={visible}
transparent
animationType="none"
supportedOrientations={['portrait', 'landscape']}
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
@ -162,7 +163,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
<View style={styles.dragHandle} />
<View style={styles.menuHeader}>
<FastImage
source={{
source={{
uri: item.poster,
priority: FastImage.priority.high,
cache: FastImage.cacheControl.immutable

View file

@ -1660,6 +1660,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
animationType="fade"
onRequestClose={closeEpisodeActionMenu}
statusBarTranslucent
supportedOrientations={['portrait', 'landscape']}
>
<Pressable
style={{

View file

@ -74,7 +74,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
// 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';
@ -82,13 +82,13 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
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
const horizontalPadding = useMemo(() => {
switch (deviceType) {
@ -102,7 +102,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
return 16; // phone
}
}, [deviceType]);
// Enhanced trailer card sizing
const trailerCardWidth = useMemo(() => {
switch (deviceType) {
@ -116,7 +116,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
return 200; // phone
}
}, [deviceType]);
const trailerCardSpacing = useMemo(() => {
switch (deviceType) {
case 'tv':
@ -293,7 +293,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
// Auto-select the first available category, preferring "Trailer"
const availableCategories = Object.keys(categorized);
const preferredCategory = availableCategories.includes('Trailer') ? 'Trailer' :
availableCategories.includes('Teaser') ? 'Teaser' : availableCategories[0];
availableCategories.includes('Teaser') ? 'Teaser' : availableCategories[0];
setSelectedCategory(preferredCategory);
}
} catch (err) {
@ -379,7 +379,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
} catch (error) {
logger.warn('TrailersSection', 'Error pausing hero trailer:', error);
}
setSelectedTrailer(trailer);
setModalVisible(true);
};
@ -499,15 +499,15 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
return (
<Animated.View style={[
styles.container,
styles.container,
sectionAnimatedStyle,
{ paddingHorizontal: horizontalPadding }
]}>
{/* Enhanced Header with Category Selector */}
<View style={styles.header}>
<Text style={[
styles.headerTitle,
{
styles.headerTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
}
@ -519,8 +519,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
{trailerCategories.length > 0 && selectedCategory && (
<TouchableOpacity
style={[
styles.categorySelector,
{
styles.categorySelector,
{
borderColor: 'rgba(255,255,255,0.6)',
paddingHorizontal: isTV ? 14 : isLargeTablet ? 12 : isTablet ? 10 : 10,
paddingVertical: isTV ? 8 : isLargeTablet ? 6 : isTablet ? 5 : 5,
@ -533,8 +533,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
>
<Text
style={[
styles.categorySelectorText,
{
styles.categorySelectorText,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
maxWidth: isTV ? 150 : isLargeTablet ? 130 : isTablet ? 120 : 120
@ -559,6 +559,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
visible={dropdownVisible}
transparent={true}
animationType="fade"
supportedOrientations={['portrait', 'landscape']}
onRequestClose={() => setDropdownVisible(false)}
>
<TouchableOpacity
@ -587,7 +588,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
>
<View style={styles.dropdownItemContent}>
<View style={[
styles.categoryIconContainer,
styles.categoryIconContainer,
{
backgroundColor: currentTheme.colors.primary + '15',
width: isTV ? 36 : isLargeTablet ? 32 : isTablet ? 28 : 28,
@ -601,18 +602,18 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
color={currentTheme.colors.primary}
/>
</View>
<Text style={[
styles.dropdownItemText,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
}
]}>
<Text style={[
styles.dropdownItemText,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 16
}
]}>
{formatTrailerType(category)}
</Text>
<Text style={[
styles.dropdownItemCount,
{
styles.dropdownItemCount,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12,
paddingHorizontal: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 8 : 8,
@ -690,8 +691,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
<View style={styles.trailerInfoBelow}>
<Text
style={[
styles.trailerTitle,
{
styles.trailerTitle,
{
color: currentTheme.colors.highEmphasis,
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
lineHeight: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 16,
@ -704,8 +705,8 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
{trailer.displayName || trailer.name}
</Text>
<Text style={[
styles.trailerMeta,
{
styles.trailerMeta,
{
color: currentTheme.colors.textMuted,
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10
}

View file

@ -135,10 +135,10 @@ const AndroidVideoPlayer: React.FC = () => {
// Helper to get dynamic volume icon
const getVolumeIcon = (value: number) => {
if (value === 0) return 'volume-off';
if (value < 0.3) return 'volume-mute';
if (value < 0.6) return 'volume-down';
return 'volume-up';
if (value === 0) return 'volume-off';
if (value < 0.3) return 'volume-mute';
if (value < 0.6) return 'volume-down';
return 'volume-up';
};
// Helper to get dynamic brightness icon
@ -1160,15 +1160,29 @@ const AndroidVideoPlayer: React.FC = () => {
}
}, [id, type, paused, currentTime, duration]);
// Use refs to track latest values for unmount cleanup without causing effect re-runs
const currentTimeRef = useRef(currentTime);
const durationRef = useRef(duration);
// Keep refs updated with latest values
useEffect(() => {
currentTimeRef.current = currentTime;
}, [currentTime]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
// Cleanup effect - only runs on actual component unmount
useEffect(() => {
return () => {
if (id && type && duration > 0) {
if (id && type && durationRef.current > 0) {
saveWatchProgress();
// Final Trakt sync on component unmount
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
}
};
}, [id, type, currentTime, duration]);
}, [id, type]); // Only id and type - NOT currentTime or duration
const seekToTime = (rawSeconds: number) => {
// Clamp to just before the end of the media.
@ -3432,8 +3446,6 @@ const AndroidVideoPlayer: React.FC = () => {
buffered={buffered}
formatTime={formatTime}
playerBackend={useVLC ? 'VLC' : 'ExoPlayer'}
nextLoadingTitle={nextLoadingTitle}
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
/>
{/* Combined Volume & Brightness Gesture Indicator - NEW PILL STYLE (No Bar) */}
@ -3441,45 +3453,45 @@ const AndroidVideoPlayer: React.FC = () => {
<View style={localStyles.gestureIndicatorContainer}>
{/* Dynamic Icon */}
<View
style={[
localStyles.iconWrapper,
{
// Conditional Background Color Logic
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
? 'rgba(242, 184, 181)'
: 'rgba(59, 59, 59)'
}
]}
>
<MaterialIcons
name={
gestureControls.showVolumeOverlay
? getVolumeIcon(volume)
: getBrightnessIcon(brightness)
}
size={24} // Reduced size to fit inside a 32-40px circle better
color={
gestureControls.showVolumeOverlay && volume === 0
? 'rgba(96, 20, 16)' // Bright RED for MUTE icon itself
: 'rgba(255, 255, 255)' // White for all other states
}
/>
style={[
localStyles.iconWrapper,
{
// Conditional Background Color Logic
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
? 'rgba(242, 184, 181)'
: 'rgba(59, 59, 59)'
}
]}
>
<MaterialIcons
name={
gestureControls.showVolumeOverlay
? getVolumeIcon(volume)
: getBrightnessIcon(brightness)
}
size={24} // Reduced size to fit inside a 32-40px circle better
color={
gestureControls.showVolumeOverlay && volume === 0
? 'rgba(96, 20, 16)' // Bright RED for MUTE icon itself
: 'rgba(255, 255, 255)' // White for all other states
}
/>
</View>
{/* Text Label: Shows "Muted" or percentage */}
<Text
style={[
localStyles.gestureText,
// Conditional Text Color Logic
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' } // Light RED for "Muted"
]}
>
{/* Conditional Text Content Logic */}
{gestureControls.showVolumeOverlay && volume === 0
? "Muted" // Display "Muted" when volume is 0
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%` // Display percentage otherwise
}
</Text>
<Text
style={[
localStyles.gestureText,
// Conditional Text Color Logic
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' } // Light RED for "Muted"
]}
>
{/* Conditional Text Content Logic */}
{gestureControls.showVolumeOverlay && volume === 0
? "Muted" // Display "Muted" when volume is 0
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%` // Display percentage otherwise
}
</Text>
</View>
)}
@ -4067,32 +4079,32 @@ const AndroidVideoPlayer: React.FC = () => {
// New styles for the gesture indicator
const localStyles = StyleSheet.create({
gestureIndicatorContainer: {
position: 'absolute',
top: '4%', // Adjust this for vertical position
alignSelf: 'center', // Adjust this for horizontal position
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(25, 25, 25)', // Dark pill background
borderRadius: 70,
paddingHorizontal: 15,
paddingVertical: 15,
zIndex: 2000, // Very high z-index to ensure visibility
minWidth: 120, // Adjusted min width since bar is removed
position: 'absolute',
top: '4%', // Adjust this for vertical position
alignSelf: 'center', // Adjust this for horizontal position
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(25, 25, 25)', // Dark pill background
borderRadius: 70,
paddingHorizontal: 15,
paddingVertical: 15,
zIndex: 2000, // Very high z-index to ensure visibility
minWidth: 120, // Adjusted min width since bar is removed
},
iconWrapper: {
borderRadius: 50, // Makes it a perfect circle (set to a high number)
width: 40, // Define the diameter of the circle
height: 40, // Define the diameter of the circle
justifyContent: 'center',
alignItems: 'center',
marginRight: 12, // Margin to separate icon circle from percentage text
borderRadius: 50, // Makes it a perfect circle (set to a high number)
width: 40, // Define the diameter of the circle
height: 40, // Define the diameter of the circle
justifyContent: 'center',
alignItems: 'center',
marginRight: 12, // Margin to separate icon circle from percentage text
},
gestureText: {
color: '#FFFFFF',
fontSize: 18,
fontWeight: 'normal',
minWidth: 35,
textAlign: 'right',
color: '#FFFFFF',
fontSize: 18,
fontWeight: 'normal',
minWidth: 35,
textAlign: 'right',
},
});

View file

@ -838,15 +838,29 @@ const KSPlayerCore: React.FC = () => {
}
}, [id, type, paused, duration]);
// Use refs to track latest values for unmount cleanup without causing effect re-runs
const currentTimeRef = useRef(currentTime);
const durationRef = useRef(duration);
// Keep refs updated with latest values
useEffect(() => {
currentTimeRef.current = currentTime;
}, [currentTime]);
useEffect(() => {
durationRef.current = duration;
}, [duration]);
// Cleanup effect - only runs on actual component unmount
useEffect(() => {
return () => {
if (id && type && duration > 0) {
if (id && type && durationRef.current > 0) {
saveWatchProgress();
// Final Trakt sync on component unmount
traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
traktAutosync.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'unmount');
}
};
}, [id, type, currentTime, duration]);
}, [id, type]); // Only id and type - NOT currentTime or duration
const onPlaying = () => {
if (isMounted.current && !isSeeking.current) {

View file

@ -67,7 +67,7 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
const { tmdbService } = require('../../../services/tmdbService');
const url = tmdbService.getImageUrl(anyEpisode.still_path, 'w500');
if (url) imageUri = url;
} catch {}
} catch { }
}
}
}
@ -81,33 +81,19 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
return timeRemaining < 61 && timeRemaining > 10;
}, [nextEpisode, duration, currentTime]);
// Debug log inputs and computed state on changes
useEffect(() => {
try {
const timeRemaining = duration - currentTime;
logger.log('[UpNextButton] state', {
hasNextEpisode: !!nextEpisode,
currentTime,
duration,
timeRemaining,
isLoading,
shouldShow,
controlsVisible,
controlsFixedOffset,
});
} catch {}
}, [nextEpisode, currentTime, duration, isLoading, shouldShow, controlsVisible, controlsFixedOffset]);
// Debug logging removed to reduce console noise
// The state is computed in shouldShow useMemo above
useEffect(() => {
if (shouldShow && !visible) {
try { logger.log('[UpNextButton] showing with animation'); } catch {}
try { logger.log('[UpNextButton] showing with animation'); } catch { }
setVisible(true);
Animated.parallel([
Animated.timing(opacity, { toValue: 1, duration: 400, useNativeDriver: true }),
Animated.spring(scale, { toValue: 1, tension: 100, friction: 8, useNativeDriver: true }),
]).start();
} else if (!shouldShow && visible) {
try { logger.log('[UpNextButton] hiding with animation'); } catch {}
try { logger.log('[UpNextButton] hiding with animation'); } catch { }
Animated.parallel([
Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
Animated.timing(scale, { toValue: 0.8, duration: 200, useNativeDriver: true }),

View file

@ -1,26 +1,22 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, Platform, useWindowDimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
FadeIn,
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
import { getTrackDisplayName, DEBUG_MODE } from '../utils/playerUtils';
import { logger } from '../../../utils/logger';
import { getTrackDisplayName } from '../utils/playerUtils';
interface AudioTrackModalProps {
showAudioModal: boolean;
setShowAudioModal: (show: boolean) => void;
ksAudioTracks: Array<{id: number, name: string, language?: string}>;
ksAudioTracks: Array<{ id: number, name: string, language?: string }>;
selectedAudioTrack: number | null;
selectAudioTrack: (trackId: number) => void;
}
const { width } = Dimensions.get('window');
const MENU_WIDTH = Math.min(width * 0.85, 400);
export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
showAudioModal,
setShowAudioModal,
@ -28,52 +24,19 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
selectedAudioTrack,
selectAudioTrack,
}) => {
const handleClose = () => {
setShowAudioModal(false);
};
const { width } = useWindowDimensions();
const MENU_WIDTH = Math.min(width * 0.85, 400);
// Debug logging when modal opens
React.useEffect(() => {
if (showAudioModal && DEBUG_MODE) {
logger.log(`[AudioTrackModal] Modal opened with selectedAudioTrack:`, selectedAudioTrack);
logger.log(`[AudioTrackModal] Available tracks:`, ksAudioTracks);
if (typeof selectedAudioTrack === 'number') {
const selectedTrack = ksAudioTracks.find(track => track.id === selectedAudioTrack);
if (selectedTrack) {
logger.log(`[AudioTrackModal] Selected track found: ${selectedTrack.name} (${selectedTrack.language})`);
} else {
logger.warn(`[AudioTrackModal] Selected track ${selectedAudioTrack} not found in available tracks`);
}
}
}
}, [showAudioModal, selectedAudioTrack, ksAudioTracks]);
const handleClose = () => setShowAudioModal(false);
if (!showAudioModal) return null;
return (
<>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 9998,
}}
>
<TouchableOpacity
style={{ flex: 1 }}
onPress={handleClose}
activeOpacity={1}
/>
</Animated.View>
{/* Side Menu */}
return (
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
</TouchableOpacity>
<Animated.View
entering={SlideInRight.duration(300)}
exiting={SlideOutRight.duration(250)}
@ -83,147 +46,67 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
right: 0,
bottom: 0,
width: MENU_WIDTH,
backgroundColor: '#1A1A1A',
zIndex: 9999,
elevation: 20,
shadowColor: '#000',
shadowOffset: { width: -5, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 10,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
backgroundColor: '#0f0f0f',
borderLeftWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
}}
>
{/* Header */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 60,
paddingBottom: 20,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
}}>
<Text style={{
color: '#FFFFFF',
fontSize: 22,
fontWeight: '700',
}}>
Audio Tracks
</Text>
<TouchableOpacity
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#FFFFFF" />
</TouchableOpacity>
<View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 15, paddingHorizontal: 20 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>Audio Tracks</Text>
</View>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 15, paddingBottom: 40 }}
>
{/* Audio Tracks */}
<View>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
fontWeight: '600',
marginBottom: 15,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}>
Available Tracks ({ksAudioTracks.length})
</Text>
<View style={{ gap: 8 }}>
{ksAudioTracks.map((track) => {
// Determine if track is selected
const isSelected = selectedAudioTrack === track.id;
<View style={{ gap: 8 }}>
{ksAudioTracks.map((track) => {
const isSelected = selectedAudioTrack === track.id;
return (
<TouchableOpacity
key={track.id}
style={{
backgroundColor: isSelected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)',
}}
onPress={() => {
if (DEBUG_MODE) {
logger.log(`[AudioTrackModal] Selecting track: ${track.id} (${track.name})`);
}
selectAudioTrack(track.id);
// Close modal after selection
setTimeout(() => {
setShowAudioModal(false);
}, 200);
}}
activeOpacity={0.7}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
<Text style={{
color: '#FFFFFF',
fontSize: 15,
fontWeight: '500',
flex: 1,
}}>
{getTrackDisplayName(track)}
</Text>
</View>
{track.language && (
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 13,
}}>
{track.language.toUpperCase()}
</Text>
)}
</View>
{isSelected && (
<MaterialIcons name="check" size={20} color="#22C55E" />
)}
</View>
</TouchableOpacity>
);
})}
</View>
return (
<TouchableOpacity
key={track.id}
onPress={() => {
selectAudioTrack(track.id);
setTimeout(handleClose, 200);
}}
style={{
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 12,
backgroundColor: isSelected ? 'white' : 'rgba(255,255,255,0.06)',
borderWidth: 1,
borderColor: isSelected ? 'white' : 'rgba(255,255,255,0.1)',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<View style={{ flex: 1 }}>
<Text style={{
color: isSelected ? 'black' : 'white',
fontWeight: isSelected ? '700' : '500',
fontSize: 15
}}>
{getTrackDisplayName(track)}
</Text>
</View>
{isSelected && <MaterialIcons name="check" size={18} color="black" />}
</TouchableOpacity>
);
})}
{ksAudioTracks.length === 0 && (
<View style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 20,
alignItems: 'center',
}}>
<MaterialIcons name="volume-off" size={48} color="rgba(255,255,255,0.3)" />
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 16,
marginTop: 16,
textAlign: 'center',
}}>
No audio tracks available
</Text>
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
<MaterialIcons name="volume-off" size={32} color="white" />
<Text style={{ color: 'white', marginTop: 10 }}>No audio tracks available</Text>
</View>
)}
</View>
</ScrollView>
</Animated.View>
</>
</View>
);
};
};

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, StyleSheet, Platform, useWindowDimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
FadeIn,
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutRight,
@ -20,16 +20,13 @@ interface EpisodeStreamsModalProps {
metadata?: { id?: string; name?: string };
}
const { width } = Dimensions.get('window');
const MENU_WIDTH = Math.min(width * 0.85, 400);
const QualityBadge = ({ quality }: { quality: string | null }) => {
if (!quality) return null;
const qualityNum = parseInt(quality);
let color = '#8B5CF6';
let label = `${quality}p`;
if (qualityNum >= 2160) {
color = '#F59E0B';
label = '4K';
@ -40,9 +37,9 @@ const QualityBadge = ({ quality }: { quality: string | null }) => {
color = '#10B981';
label = 'HD';
}
return (
<View
<View
style={{
backgroundColor: `${color}20`,
borderColor: `${color}60`,
@ -73,6 +70,9 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
onSelectStream,
metadata,
}) => {
const { width } = useWindowDimensions();
const MENU_WIDTH = Math.min(width * 0.85, 400);
const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: Stream[]; addonName: string } }>({});
const [isLoading, setIsLoading] = useState(false);
const [hasErrors, setHasErrors] = useState<string[]>([]);
@ -89,35 +89,34 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
const fetchStreams = async () => {
if (!episode || !metadata?.id) return;
setIsLoading(true);
setHasErrors([]);
setAvailableStreams({});
try {
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
let completedProviders = 0;
const expectedProviders = new Set<string>();
const respondedProviders = new Set<string>();
const installedAddons = stremioService.getInstalledAddons();
const streamAddons = installedAddons.filter((addon: any) =>
const streamAddons = installedAddons.filter((addon: any) =>
addon.resources && addon.resources.includes('stream')
);
streamAddons.forEach((addon: any) => expectedProviders.add(addon.id));
logger.log(`[EpisodeStreamsModal] Fetching streams for ${episodeId}, expecting ${expectedProviders.size} providers`);
await stremioService.getStreams('series', episodeId, (streams: any, addonId: any, addonName: any, error: any) => {
completedProviders++;
respondedProviders.add(addonId);
if (error) {
logger.warn(`[EpisodeStreamsModal] Error from ${addonName || addonId}:`, error);
setHasErrors(prev => [...prev, `${addonName || addonId}: ${error.message || 'Unknown error'}`]);
} else if (streams && streams.length > 0) {
// Update state incrementally for each provider
setAvailableStreams(prev => ({
...prev,
[addonId]: {
@ -129,13 +128,13 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
} else {
logger.log(`[EpisodeStreamsModal] No streams from ${addonName || addonId}`);
}
if (completedProviders >= expectedProviders.size) {
logger.log(`[EpisodeStreamsModal] All providers completed. Total providers responded: ${respondedProviders.size}`);
setIsLoading(false);
}
});
// Fallback timeout
setTimeout(() => {
if (respondedProviders.size === 0) {
@ -144,7 +143,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
setIsLoading(false);
}
}, 8000);
} catch (error) {
logger.error('[EpisodeStreamsModal] Error fetching streams:', error);
setHasErrors(prev => [...prev, `Failed to fetch streams: ${error}`]);
@ -158,38 +157,16 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
return match ? match[1] : null;
};
const handleClose = () => {
onClose();
};
if (!visible) return null;
const sortedProviders = Object.entries(availableStreams);
return (
<>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 9998,
}}
>
<TouchableOpacity
style={{ flex: 1 }}
onPress={handleClose}
activeOpacity={1}
/>
</Animated.View>
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={onClose}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
</TouchableOpacity>
{/* Side Menu */}
<Animated.View
entering={SlideInRight.duration(300)}
exiting={SlideOutRight.duration(250)}
@ -199,66 +176,29 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
right: 0,
bottom: 0,
width: MENU_WIDTH,
backgroundColor: '#1A1A1A',
zIndex: 9999,
elevation: 20,
shadowColor: '#000',
shadowOffset: { width: -5, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 10,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
backgroundColor: '#0f0f0f',
borderLeftWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
}}
>
{/* Header */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 60,
paddingBottom: 20,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
}}>
<View style={{ flex: 1 }}>
<Text style={{
color: '#FFFFFF',
fontSize: 18,
fontWeight: '700',
}}>
{episode?.name || 'Select Stream'}
</Text>
{episode && (
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 12,
marginTop: 4,
}}>
S{episode.season_number}E{episode.episode_number}
<View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 15, paddingHorizontal: 20 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }} numberOfLines={1}>
{episode?.name || 'Select Stream'}
</Text>
)}
{episode && (
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, marginTop: 4 }}>
S{episode.season_number}E{episode.episode_number}
</Text>
)}
</View>
</View>
<TouchableOpacity
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#FFFFFF" />
</TouchableOpacity>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 15, paddingBottom: 40 }}
>
{isLoading && (
<View style={{
@ -292,20 +232,20 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
}}>
{providerData.addonName} ({providerData.streams.length})
</Text>
<View style={{ gap: 8 }}>
{providerData.streams.map((stream, index) => {
const quality = getQualityFromTitle(stream.title) || stream.quality;
return (
<TouchableOpacity
key={`${providerId}-${index}`}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
backgroundColor: 'rgba(255,255,255,0.06)',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255,255,255,0.1)',
}}
onPress={() => onSelectStream(stream)}
activeOpacity={0.7}
@ -319,7 +259,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
gap: 8,
}}>
<Text style={{
color: '#FFFFFF',
color: 'white',
fontSize: 15,
fontWeight: '500',
flex: 1,
@ -328,14 +268,14 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
</Text>
{quality && <QualityBadge quality={quality} />}
</View>
{(stream.size || stream.lang) && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
{stream.size && (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="storage" size={14} color="rgba(107, 114, 128, 0.8)" />
<MaterialIcons name="storage" size={14} color="rgba(255,255,255,0.5)" />
<Text style={{
color: 'rgba(107, 114, 128, 0.8)',
color: 'rgba(255,255,255,0.5)',
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
@ -346,9 +286,9 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
)}
{stream.lang && (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="language" size={14} color="rgba(59, 130, 246, 0.8)" />
<MaterialIcons name="language" size={14} color="rgba(59,130,246,0.8)" />
<Text style={{
color: 'rgba(59, 130, 246, 0.8)',
color: 'rgba(59,130,246,0.8)',
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
@ -360,11 +300,8 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
</View>
)}
</View>
<View style={{
marginLeft: 12,
alignItems: 'center',
}}>
<View style={{ marginLeft: 12, alignItems: 'center' }}>
<MaterialIcons name="play-arrow" size={20} color="rgba(255,255,255,0.4)" />
</View>
</View>
@ -434,7 +371,6 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
)}
</ScrollView>
</Animated.View>
</>
</View>
);
};

View file

@ -1,8 +1,8 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native';
import { View, Text, TouchableOpacity, ScrollView, useWindowDimensions, StyleSheet, Platform, ActivityIndicator } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
FadeIn,
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutRight,
@ -18,13 +18,11 @@ interface EpisodesModalProps {
setShowEpisodesModal: (show: boolean) => void;
groupedEpisodes: { [seasonNumber: number]: Episode[] };
currentEpisode?: { season: number; episode: number };
metadata?: { poster?: string; id?: string };
metadata?: { poster?: string; id?: string; tmdbId?: string; type?: string };
onSelectEpisode: (episode: Episode) => void;
tmdbEpisodeOverrides?: any;
}
const { width } = Dimensions.get('window');
const MENU_WIDTH = Math.min(width * 0.85, 400);
export const EpisodesModal: React.FC<EpisodesModalProps> = ({
showEpisodesModal,
setShowEpisodesModal,
@ -32,131 +30,75 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
currentEpisode,
metadata,
onSelectEpisode,
tmdbEpisodeOverrides
}) => {
const { width } = useWindowDimensions();
const [selectedSeason, setSelectedSeason] = useState<number>(currentEpisode?.season || 1);
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({});
const [tmdbEpisodeOverrides, setTmdbEpisodeOverrides] = useState<{ [epKey: string]: { vote_average?: number; runtime?: number; still_path?: string } }>({});
const [currentTheme, setCurrentTheme] = useState({
colors: {
text: '#FFFFFF',
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: any }>({});
const [isLoadingProgress, setIsLoadingProgress] = useState(false);
const MENU_WIDTH = Math.min(width * 0.85, 400);
const currentTheme = {
colors: {
text: '#FFFFFF',
textMuted: 'rgba(255,255,255,0.6)',
mediumEmphasis: 'rgba(255,255,255,0.7)',
primary: '#3B82F6',
white: '#FFFFFF',
elevation2: 'rgba(255,255,255,0.05)'
}
});
};
// Logic Preserved: Fetch progress from storage/Trakt
useEffect(() => {
const fetchProgress = async () => {
if (showEpisodesModal && metadata?.id) {
setIsLoadingProgress(true);
try {
const allProgress = await storageService.getAllWatchProgress();
const progress: { [key: string]: any } = {};
// Filter progress for current show's episodes
Object.entries(allProgress).forEach(([key, value]) => {
if (key.includes(metadata.id!)) {
progress[key] = value;
}
});
setEpisodeProgress(progress);
// Trakt sync logic preserved
const traktService = TraktService.getInstance();
if (await traktService.isAuthenticated()) {
// Optional: background sync logic
}
} catch (err) {
logger.error('Failed to fetch episode progress', err);
} finally {
setIsLoadingProgress(false);
}
}
};
fetchProgress();
}, [showEpisodesModal, metadata?.id]);
// Initialize season only when modal opens
useEffect(() => {
if (showEpisodesModal && currentEpisode?.season) {
setSelectedSeason(currentEpisode.season);
}
}, [showEpisodesModal, currentEpisode?.season]);
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
const allProgress = await storageService.getAllWatchProgress();
const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {};
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
currentSeasonEpisodes.forEach(episode => {
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
const key = `series:${metadata.id}:${episodeId}`;
if (allProgress[key]) {
progress[episodeId] = {
currentTime: allProgress[key].currentTime,
duration: allProgress[key].duration,
lastUpdated: allProgress[key].lastUpdated
};
}
});
// Trakt watched-history integration
try {
const traktService = TraktService.getInstance();
const isAuthed = await traktService.isAuthenticated();
if (isAuthed && metadata?.id) {
const historyItems = await traktService.getWatchedEpisodesHistory(1, 400);
historyItems.forEach(item => {
if (item.type !== 'episode') return;
const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null;
if (!showImdb || showImdb !== metadata.id) return;
const season = item.episode?.season;
const epNum = item.episode?.number;
if (season === undefined || epNum === undefined) return;
const episodeId = `${metadata.id}:${season}:${epNum}`;
const watchedAt = new Date(item.watched_at).getTime();
const traktProgressEntry = {
currentTime: 1,
duration: 1,
lastUpdated: watchedAt,
};
const existing = progress[episodeId];
const existingPercent = existing ? (existing.currentTime / existing.duration) * 100 : 0;
if (!existing || existingPercent < 85) {
progress[episodeId] = traktProgressEntry;
}
});
}
} catch (err) {
logger.error('[EpisodesModal] Failed to merge Trakt history:', err);
}
setEpisodeProgress(progress);
};
useEffect(() => {
loadEpisodesProgress();
}, [selectedSeason, metadata?.id]);
const handleClose = () => {
setShowEpisodesModal(false);
};
}, [showEpisodesModal]);
if (!showEpisodesModal) return null;
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
const isEpisodeCurrent = (episode: Episode) => {
return currentEpisode &&
episode.season_number === currentEpisode.season &&
episode.episode_number === currentEpisode.episode;
};
return (
<>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 9998,
}}
>
<TouchableOpacity
style={{ flex: 1 }}
onPress={handleClose}
activeOpacity={1}
/>
</Animated.View>
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={() => setShowEpisodesModal(false)}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
</TouchableOpacity>
{/* Side Menu */}
<Animated.View
entering={SlideInRight.duration(300)}
exiting={SlideOutRight.duration(250)}
@ -166,85 +108,33 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
right: 0,
bottom: 0,
width: MENU_WIDTH,
backgroundColor: '#1A1A1A',
zIndex: 9999,
elevation: 20,
shadowColor: '#000',
shadowOffset: { width: -5, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 10,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
backgroundColor: '#0f0f0f',
borderLeftWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
}}
>
{/* Header */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 60,
paddingBottom: 20,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
}}>
<Text style={{
color: '#FFFFFF',
fontSize: 22,
fontWeight: '700',
}}>
Episodes
</Text>
<TouchableOpacity
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#FFFFFF" />
</TouchableOpacity>
</View>
<View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 15, paddingHorizontal: 20 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>Episodes</Text>
</View>
{/* Season Selector */}
<View
style={{
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
paddingVertical: 6,
}}
>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{
paddingHorizontal: 20,
}}
>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 15, gap: 8 }}>
{seasons.map((season) => (
<TouchableOpacity
key={season}
onPress={() => setSelectedSeason(season)}
style={{
paddingHorizontal: 16,
paddingVertical: 6,
borderRadius: 6,
marginRight: 8,
backgroundColor: selectedSeason === season ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)',
paddingVertical: 8,
borderRadius: 20,
backgroundColor: selectedSeason === season ? 'white' : 'rgba(255,255,255,0.06)',
borderWidth: 1,
borderColor: selectedSeason === season ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255, 255, 255, 0.1)',
borderColor: selectedSeason === season ? 'white' : 'rgba(255,255,255,0.1)',
}}
onPress={() => setSelectedSeason(season)}
activeOpacity={0.7}
>
<Text style={{
color: selectedSeason === season ? '#3B82F6' : '#FFFFFF',
fontSize: 13,
fontWeight: selectedSeason === season ? '700' : '500',
color: selectedSeason === season ? 'black' : 'white',
fontWeight: selectedSeason === season ? '700' : '500'
}}>
Season {season}
</Text>
@ -253,57 +143,30 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
</ScrollView>
</View>
{/* Episodes List */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
showsVerticalScrollIndicator={false}
>
{currentSeasonEpisodes.length > 0 ? (
currentSeasonEpisodes.map((episode, index) => {
const isCurrent = isEpisodeCurrent(episode);
return (
<View
key={episode.id}
style={{
opacity: isCurrent ? 1 : 1,
marginBottom: index < currentSeasonEpisodes.length - 1 ? 16 : 0,
}}
>
<EpisodeCard
episode={episode}
metadata={metadata}
tmdbEpisodeOverrides={tmdbEpisodeOverrides}
episodeProgress={episodeProgress}
onPress={() => onSelectEpisode(episode)}
currentTheme={currentTheme}
isCurrent={isCurrent}
/>
</View>
);
})
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ padding: 15, paddingBottom: 40 }}>
{isLoadingProgress ? (
<ActivityIndicator color="white" style={{ marginTop: 20 }} />
) : (
<View style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 20,
alignItems: 'center',
}}>
<MaterialIcons name="error-outline" size={48} color="rgba(255,255,255,0.3)" />
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 16,
marginTop: 16,
textAlign: 'center',
}}>
No episodes available for Season {selectedSeason}
</Text>
<View style={{ gap: 2 }}>
{currentSeasonEpisodes.map((episode) => (
<EpisodeCard
key={episode.id}
episode={episode}
metadata={metadata}
episodeProgress={episodeProgress}
tmdbEpisodeOverrides={tmdbEpisodeOverrides}
onPress={() => {
onSelectEpisode(episode);
setShowEpisodesModal(false);
}}
currentTheme={currentTheme}
isCurrent={currentEpisode?.season === episode.season_number && currentEpisode?.episode === episode.episode_number}
/>
))}
</View>
)}
</ScrollView>
</Animated.View>
</>
</View>
);
};

View file

@ -1,8 +1,8 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, StyleSheet, Platform, useWindowDimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
FadeIn,
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutRight,
@ -18,16 +18,13 @@ interface SourcesModalProps {
isChangingSource?: boolean;
}
const { width } = Dimensions.get('window');
const MENU_WIDTH = Math.min(width * 0.85, 400);
const QualityBadge = ({ quality }: { quality: string | null }) => {
if (!quality) return null;
const qualityNum = parseInt(quality);
let color = '#8B5CF6'; // Default purple
let label = `${quality}p`;
if (qualityNum >= 2160) {
color = '#F59E0B'; // Gold for 4K
label = '4K';
@ -38,9 +35,9 @@ const QualityBadge = ({ quality }: { quality: string | null }) => {
color = '#10B981'; // Green for 720p
label = 'HD';
}
return (
<View
<View
style={{
backgroundColor: `${color}20`,
borderColor: `${color}60`,
@ -72,6 +69,9 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
onSelectStream,
isChangingSource = false,
}) => {
const { width } = useWindowDimensions();
const MENU_WIDTH = Math.min(width * 0.85, 400);
const handleClose = () => {
setShowSourcesModal(false);
};
@ -97,29 +97,11 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
};
return (
<>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 9998,
}}
>
<TouchableOpacity
style={{ flex: 1 }}
onPress={handleClose}
activeOpacity={1}
/>
</Animated.View>
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
</TouchableOpacity>
{/* Side Menu */}
<Animated.View
entering={SlideInRight.duration(300)}
exiting={SlideOutRight.duration(250)}
@ -129,55 +111,20 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
right: 0,
bottom: 0,
width: MENU_WIDTH,
backgroundColor: '#1A1A1A',
zIndex: 9999,
elevation: 20,
shadowColor: '#000',
shadowOffset: { width: -5, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 10,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
backgroundColor: '#0f0f0f',
borderLeftWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
}}
>
{/* Header */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 60,
paddingBottom: 20,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
}}>
<Text style={{
color: '#FFFFFF',
fontSize: 22,
fontWeight: '700',
}}>
Change Source
</Text>
<TouchableOpacity
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#FFFFFF" />
</TouchableOpacity>
<View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 15, paddingHorizontal: 20 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>Change Source</Text>
</View>
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ padding: 15, paddingBottom: 40 }}
>
{isChangingSource && (
<View style={{
@ -213,21 +160,21 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
}}>
{providerData.addonName} ({providerData.streams.length})
</Text>
<View style={{ gap: 8 }}>
{providerData.streams.map((stream, index) => {
const isSelected = isStreamSelected(stream);
const quality = getQualityFromTitle(stream.title) || stream.quality;
return (
<TouchableOpacity
key={`${providerId}-${index}`}
style={{
backgroundColor: isSelected ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
backgroundColor: isSelected ? 'white' : 'rgba(255,255,255,0.06)',
borderRadius: 12,
padding: 12,
borderWidth: 1,
borderColor: isSelected ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255, 255, 255, 0.1)',
borderColor: isSelected ? 'white' : 'rgba(255,255,255,0.1)',
opacity: (isChangingSource && !isSelected) ? 0.6 : 1,
}}
onPress={() => handleStreamSelect(stream)}
@ -243,23 +190,23 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
gap: 8,
}}>
<Text style={{
color: '#FFFFFF',
color: isSelected ? 'black' : 'white',
fontSize: 15,
fontWeight: '500',
fontWeight: isSelected ? '700' : '500',
flex: 1,
}}>
{stream.title || stream.name || `Stream ${index + 1}`}
</Text>
{quality && <QualityBadge quality={quality} />}
</View>
{(stream.size || stream.lang) && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}>
{stream.size && (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="storage" size={14} color="rgba(107, 114, 128, 0.8)" />
<MaterialIcons name="storage" size={14} color={isSelected ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.5)'} />
<Text style={{
color: 'rgba(107, 114, 128, 0.8)',
color: isSelected ? 'rgba(0,0,0,0.6)' : 'rgba(255,255,255,0.5)',
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
@ -270,9 +217,9 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
)}
{stream.lang && (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="language" size={14} color="rgba(59, 130, 246, 0.8)" />
<MaterialIcons name="language" size={14} color={isSelected ? 'rgba(0,0,0,0.6)' : 'rgba(59,130,246,0.8)'} />
<Text style={{
color: 'rgba(59, 130, 246, 0.8)',
color: isSelected ? 'rgba(0,0,0,0.6)' : 'rgba(59,130,246,0.8)',
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
@ -284,13 +231,10 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
</View>
)}
</View>
<View style={{
marginLeft: 12,
alignItems: 'center',
}}>
<View style={{ marginLeft: 12, alignItems: 'center' }}>
{isSelected ? (
<MaterialIcons name="check" size={20} color="#3B82F6" />
<MaterialIcons name="check" size={18} color="black" />
) : (
<MaterialIcons name="play-arrow" size={20} color="rgba(255,255,255,0.4)" />
)}
@ -330,6 +274,6 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
)}
</ScrollView>
</Animated.View>
</>
</View>
);
};

View file

@ -1,13 +1,13 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Platform, useWindowDimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
FadeIn,
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
import { styles } from '../utils/playerStyles';
import { StyleSheet } from 'react-native';
import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes';
import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
@ -21,7 +21,7 @@ interface SubtitleModalsProps {
isLoadingSubtitles: boolean;
customSubtitles: SubtitleCue[];
availableSubtitles: WyzieSubtitle[];
ksTextTracks: Array<{id: number, name: string, language?: string}>;
ksTextTracks: Array<{ id: number, name: string, language?: string }>;
selectedTextTrack: number;
useCustomSubtitles: boolean;
// When true, KSPlayer is active (iOS MKV path). Use to gate iOS-only limitations.
@ -128,7 +128,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
width * (isIos ? (isLandscape ? 0.6 : 0.8) : 0.85),
isIos ? 420 : 400
);
React.useEffect(() => {
if (showSubtitleModal && !isLoadingSubtitleList && availableSubtitles.length === 0) {
fetchAvailableSubtitles();
@ -183,31 +183,13 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
// Main subtitle menu
const renderSubtitleMenu = () => {
if (!showSubtitleModal) return null;
return (
<>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
zIndex: 9998,
}}
>
<TouchableOpacity
style={{ flex: 1 }}
onPress={handleClose}
activeOpacity={1}
/>
</Animated.View>
{/* Side Menu */}
return (
<View style={[StyleSheet.absoluteFill, { zIndex: 9999 }]}>
<TouchableOpacity style={StyleSheet.absoluteFill} activeOpacity={1} onPress={handleClose}>
<Animated.View entering={FadeIn.duration(200)} exiting={FadeOut.duration(150)} style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }} />
</TouchableOpacity>
<Animated.View
entering={SlideInRight.duration(300)}
exiting={SlideOutRight.duration(250)}
@ -217,51 +199,22 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
right: 0,
bottom: 0,
width: menuWidth,
backgroundColor: '#1A1A1A',
zIndex: 9999,
elevation: 20,
shadowColor: '#000',
shadowOffset: { width: -5, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 10,
borderTopLeftRadius: 20,
borderBottomLeftRadius: 20,
paddingRight: 0,
backgroundColor: '#0f0f0f',
borderLeftWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
}}
>
{/* Header */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: insets.top + (isCompact ? 8 : 12),
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
}}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Text style={{ color: '#FFFFFF', fontSize: 22, fontWeight: '700' }}>Subtitles</Text>
<View style={{ paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, backgroundColor: useCustomSubtitles ? 'rgba(34,197,94,0.2)' : 'rgba(59,130,246,0.2)' }}>
<Text style={{ color: useCustomSubtitles ? '#22C55E' : '#3B82F6', fontSize: 11, fontWeight: '700' }}>
{useCustomSubtitles ? 'Addon in use' : 'Builtin in use'}
</Text>
<View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 15, paddingHorizontal: 20 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 15 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>Subtitles</Text>
<View style={{ paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, backgroundColor: useCustomSubtitles ? 'rgba(34,197,94,0.2)' : 'rgba(59,130,246,0.2)' }}>
<Text style={{ color: useCustomSubtitles ? '#22C55E' : '#3B82F6', fontSize: 11, fontWeight: '700' }}>
{useCustomSubtitles ? 'Addon in use' : 'Builtin in use'}
</Text>
</View>
</View>
</View>
<TouchableOpacity
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#FFFFFF" />
</TouchableOpacity>
</View>
{/* Segmented Tabs */}
@ -288,7 +241,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
))}
</View>
<ScrollView
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, paddingBottom: (isCompact ? 24 : 40) + (isIos ? insets.bottom : 0) }}
showsVerticalScrollIndicator={false}
@ -413,171 +366,171 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
)}
{activeTab === 'addon' && (
<View style={{ marginBottom: 30 }}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 15,
}}>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: isCompact ? 13 : 14,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
<View style={{ marginBottom: 30 }}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 15,
}}>
Addon Subtitles
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{useCustomSubtitles && (
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: isCompact ? 13 : 14,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
}}>
Addon Subtitles
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{useCustomSubtitles && (
<TouchableOpacity
style={{
backgroundColor: 'rgba(239, 68, 68, 0.15)',
borderRadius: 12,
paddingHorizontal: chipPadH,
paddingVertical: chipPadV - 2,
flexDirection: 'row',
alignItems: 'center',
}}
onPress={() => {
disableCustomSubtitles();
setSelectedOnlineSubtitleId(null);
}}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={16} color="#EF4444" />
<Text style={{
color: '#EF4444',
fontSize: isCompact ? 11 : 12,
fontWeight: '600',
marginLeft: 6,
}}>
Disable
</Text>
</TouchableOpacity>
)}
<TouchableOpacity
style={{
backgroundColor: 'rgba(239, 68, 68, 0.15)',
backgroundColor: 'rgba(34, 197, 94, 0.15)',
borderRadius: 12,
paddingHorizontal: chipPadH,
paddingVertical: chipPadV-2,
paddingVertical: chipPadV - 2,
flexDirection: 'row',
alignItems: 'center',
}}
onPress={() => {
disableCustomSubtitles();
setSelectedOnlineSubtitleId(null);
}}
activeOpacity={0.7}
onPress={() => fetchAvailableSubtitles()}
disabled={isLoadingSubtitleList}
>
<MaterialIcons name="close" size={16} color="#EF4444" />
{isLoadingSubtitleList ? (
<ActivityIndicator size="small" color="#22C55E" />
) : (
<MaterialIcons name="refresh" size={16} color="#22C55E" />
)}
<Text style={{
color: '#EF4444',
color: '#22C55E',
fontSize: isCompact ? 11 : 12,
fontWeight: '600',
marginLeft: 6,
}}>
Disable
{isLoadingSubtitleList ? 'Searching' : 'Refresh'}
</Text>
</TouchableOpacity>
)}
</View>
</View>
{(availableSubtitles.length === 0) && !isLoadingSubtitleList ? (
<TouchableOpacity
style={{
backgroundColor: 'rgba(34, 197, 94, 0.15)',
borderRadius: 12,
paddingHorizontal: chipPadH,
paddingVertical: chipPadV-2,
flexDirection: 'row',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: isCompact ? 14 : 20,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderStyle: 'dashed',
}}
onPress={() => fetchAvailableSubtitles()}
disabled={isLoadingSubtitleList}
activeOpacity={0.7}
>
{isLoadingSubtitleList ? (
<ActivityIndicator size="small" color="#22C55E" />
) : (
<MaterialIcons name="refresh" size={16} color="#22C55E" />
)}
<MaterialIcons name="cloud-download" size={24} color="rgba(255,255,255,0.4)" />
<Text style={{
color: '#22C55E',
fontSize: isCompact ? 11 : 12,
fontWeight: '600',
marginLeft: 6,
color: 'rgba(255, 255, 255, 0.6)',
fontSize: isCompact ? 13 : 14,
marginTop: 8,
textAlign: 'center',
}}>
{isLoadingSubtitleList ? 'Searching' : 'Refresh'}
Tap to fetch from addons
</Text>
</TouchableOpacity>
</View>
</View>
{(availableSubtitles.length === 0) && !isLoadingSubtitleList ? (
<TouchableOpacity
style={{
) : isLoadingSubtitleList ? (
<View style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: isCompact ? 14 : 20,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderStyle: 'dashed',
}}
onPress={() => fetchAvailableSubtitles()}
activeOpacity={0.7}
>
<MaterialIcons name="cloud-download" size={24} color="rgba(255,255,255,0.4)" />
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: isCompact ? 13 : 14,
marginTop: 8,
textAlign: 'center',
}}>
Tap to fetch from addons
</Text>
</TouchableOpacity>
) : isLoadingSubtitleList ? (
<View style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: isCompact ? 14 : 20,
alignItems: 'center',
}}>
<ActivityIndicator size="large" color="#22C55E" />
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: isCompact ? 13 : 14,
marginTop: 12,
}}>
Searching...
</Text>
</View>
) : (
<View style={{ gap: 8 }}>
{availableSubtitles.map((sub) => {
const isSelected = useCustomSubtitles && selectedOnlineSubtitleId === sub.id;
return (
<TouchableOpacity
key={sub.id}
style={{
backgroundColor: isSelected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: sectionPad,
borderWidth: 1,
borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)',
}}
onPress={() => {
handleLoadWyzieSubtitle(sub);
}}
activeOpacity={0.7}
disabled={isLoadingSubtitles}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text style={{ color: '#FFFFFF', fontSize: 15, fontWeight: '500' }}>
{sub.display}
</Text>
{(() => {
const filename = getFileNameFromUrl(sub.url);
if (!filename) return null;
return (
<Text style={{ color: 'rgba(255,255,255,0.75)', fontSize: 12, marginTop: 2 }} numberOfLines={1}>
{filename}
</Text>
);
})()}
<Text style={{ color: 'rgba(255, 255, 255, 0.6)', fontSize: 13, marginTop: 2 }}>
{formatLanguage(sub.language)}{sub.source ? ` · ${sub.source}` : ''}
</Text>
<ActivityIndicator size="large" color="#22C55E" />
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: isCompact ? 13 : 14,
marginTop: 12,
}}>
Searching...
</Text>
</View>
) : (
<View style={{ gap: 8 }}>
{availableSubtitles.map((sub) => {
const isSelected = useCustomSubtitles && selectedOnlineSubtitleId === sub.id;
return (
<TouchableOpacity
key={sub.id}
style={{
backgroundColor: isSelected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: sectionPad,
borderWidth: 1,
borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)',
}}
onPress={() => {
handleLoadWyzieSubtitle(sub);
}}
activeOpacity={0.7}
disabled={isLoadingSubtitles}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flex: 1 }}>
<Text style={{ color: '#FFFFFF', fontSize: 15, fontWeight: '500' }}>
{sub.display}
</Text>
{(() => {
const filename = getFileNameFromUrl(sub.url);
if (!filename) return null;
return (
<Text style={{ color: 'rgba(255,255,255,0.75)', fontSize: 12, marginTop: 2 }} numberOfLines={1}>
{filename}
</Text>
);
})()}
<Text style={{ color: 'rgba(255, 255, 255, 0.6)', fontSize: 13, marginTop: 2 }}>
{formatLanguage(sub.language)}{sub.source ? ` · ${sub.source}` : ''}
</Text>
</View>
{(isLoadingSubtitles && loadingSubtitleId === sub.id) ? (
<ActivityIndicator size="small" color="#22C55E" />
) : isSelected ? (
<MaterialIcons name="check" size={20} color="#22C55E" />
) : (
<MaterialIcons name="download" size={20} color="rgba(255,255,255,0.4)" />
)}
</View>
{(isLoadingSubtitles && loadingSubtitleId === sub.id) ? (
<ActivityIndicator size="small" color="#22C55E" />
) : isSelected ? (
<MaterialIcons name="check" size={20} color="#22C55E" />
) : (
<MaterialIcons name="download" size={20} color="rgba(255,255,255,0.4)" />
)}
</View>
</TouchableOpacity>
);
})}
</View>
)}
</View>
</TouchableOpacity>
);
})}
</View>
)}
</View>
)}
{activeTab === 'appearance' && (
@ -902,7 +855,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</ScrollView>
</Animated.View>
</>
</View>
);
};

View file

@ -1413,6 +1413,7 @@ const AddonsScreen = () => {
visible={showConfirmModal}
transparent
animationType="fade"
supportedOrientations={['portrait', 'landscape']}
onRequestClose={() => {
setShowConfirmModal(false);
setAddonDetails(null);

View file

@ -685,6 +685,7 @@ const CatalogSettingsScreen = () => {
animationType="fade"
transparent={true}
visible={isRenameModalVisible}
supportedOrientations={['portrait', 'landscape']}
onRequestClose={() => {
setIsRenameModalVisible(false);
setCatalogToRename(null);

View file

@ -12,13 +12,14 @@ import {
Linking,
RefreshControl,
FlatList,
ActivityIndicator
ActivityIndicator,
Alert
} from 'react-native';
import { mmkvStorage } from '../services/mmkvStorage';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import FastImage from '@d11/react-native-fast-image';
import { Feather } from '@expo/vector-icons';
import { Feather, FontAwesome5 } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { fetchContributors, GitHubContributor } from '../services/githubReleaseService';
@ -30,6 +31,48 @@ const isLargeTablet = width >= 1024;
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Discord API URL from environment
const DISCORD_USER_API = process.env.EXPO_PUBLIC_DISCORD_USER_API || 'https://pfpfinder.com/api/discord/user';
// Discord brand color
const DISCORD_BRAND_COLOR = '#5865F2';
// Special mentions - Discord community members (only store IDs and roles)
interface SpecialMentionConfig {
discordId: string;
role: string;
description: string;
}
interface DiscordUserData {
id: string;
global_name: string | null;
username: string;
avatar: string | null;
}
interface SpecialMention extends SpecialMentionConfig {
name: string;
username: string;
avatarUrl: string;
isLoading: boolean;
}
const SPECIAL_MENTIONS_CONFIG: SpecialMentionConfig[] = [
{
discordId: '709281623866081300',
role: 'Community Manager',
description: 'Manages the Discord & Reddit communities for Nuvio',
},
{
discordId: '777773947071758336',
role: 'Server Sponsor',
description: 'Sponsored the server infrastructure for Nuvio',
},
];
type TabType = 'contributors' | 'special';
interface ContributorCardProps {
contributor: GitHubContributor;
currentTheme: any;
@ -86,15 +129,174 @@ const ContributorCard: React.FC<ContributorCardProps> = ({ contributor, currentT
);
};
// Special Mention Card Component - Same layout as ContributorCard
interface SpecialMentionCardProps {
mention: SpecialMention;
currentTheme: any;
isTablet: boolean;
isLargeTablet: boolean;
}
const SpecialMentionCard: React.FC<SpecialMentionCardProps> = ({ mention, currentTheme, isTablet, isLargeTablet }) => {
const handlePress = useCallback(() => {
// Try to open Discord profile
const discordUrl = `discord://-/users/${mention.discordId}`;
Linking.canOpenURL(discordUrl).then((supported) => {
if (supported) {
Linking.openURL(discordUrl);
} else {
// Fallback: show alert with Discord info
Alert.alert(
mention.name,
`Discord: @${mention.username}\n\nOpen Discord and search for this user to connect with them.`,
[{ text: 'OK' }]
);
}
});
}, [mention.discordId, mention.name, mention.username]);
// Default avatar fallback
const defaultAvatar = `https://cdn.discordapp.com/embed/avatars/0.png`;
return (
<TouchableOpacity
style={[
styles.contributorCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletContributorCard
]}
onPress={handlePress}
activeOpacity={0.7}
>
{/* Avatar with Discord badge */}
<View style={styles.specialAvatarContainer}>
{mention.isLoading ? (
<View style={[
styles.avatar,
isTablet && styles.tabletAvatar,
{ backgroundColor: currentTheme.colors.elevation2, justifyContent: 'center', alignItems: 'center' }
]}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
) : (
<FastImage
source={{ uri: mention.avatarUrl || defaultAvatar }}
style={[
styles.avatar,
isTablet && styles.tabletAvatar
]}
resizeMode={FastImage.resizeMode.cover}
/>
)}
<View style={[styles.discordBadgeSmall, { backgroundColor: DISCORD_BRAND_COLOR }]}>
<FontAwesome5 name="discord" size={10} color="#FFFFFF" />
</View>
</View>
{/* User info */}
<View style={styles.contributorInfo}>
<Text style={[
styles.username,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletUsername
]}>
{mention.isLoading ? 'Loading...' : mention.name}
</Text>
{!mention.isLoading && mention.username && (
<Text style={[
styles.contributions,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletContributions
]}>
@{mention.username}
</Text>
)}
<View style={[styles.roleBadgeSmall, { backgroundColor: currentTheme.colors.primary + '20' }]}>
<Text style={[styles.roleBadgeText, { color: currentTheme.colors.primary }]}>
{mention.role}
</Text>
</View>
</View>
{/* Discord icon on right */}
<FontAwesome5
name="discord"
size={isTablet ? 20 : 16}
color={currentTheme.colors.mediumEmphasis}
style={styles.externalIcon}
/>
</TouchableOpacity>
);
};
const ContributorsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<TabType>('contributors');
const [contributors, setContributors] = useState<GitHubContributor[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [specialMentions, setSpecialMentions] = useState<SpecialMention[]>([]);
const [specialMentionsLoading, setSpecialMentionsLoading] = useState(true);
// Fetch Discord user data for special mentions
const loadSpecialMentions = useCallback(async () => {
setSpecialMentionsLoading(true);
// Initialize with loading state
const initialMentions: SpecialMention[] = SPECIAL_MENTIONS_CONFIG.map(config => ({
...config,
name: 'Loading...',
username: '',
avatarUrl: '',
isLoading: true,
}));
setSpecialMentions(initialMentions);
// Fetch each user's data from Discord API
const fetchedMentions = await Promise.all(
SPECIAL_MENTIONS_CONFIG.map(async (config): Promise<SpecialMention> => {
try {
const response = await fetch(`${DISCORD_USER_API}/${config.discordId}`);
if (!response.ok) {
throw new Error('Failed to fetch Discord user');
}
const userData: DiscordUserData = await response.json();
return {
...config,
name: userData.global_name || userData.username,
username: userData.username,
avatarUrl: userData.avatar || '',
isLoading: false,
};
} catch (error) {
if (__DEV__) console.error(`Error fetching Discord user ${config.discordId}:`, error);
// Return fallback data
return {
...config,
name: 'Discord User',
username: config.discordId,
avatarUrl: '',
isLoading: false,
};
}
})
);
setSpecialMentions(fetchedMentions);
setSpecialMentionsLoading(false);
}, []);
// Load special mentions when switching to that tab
useEffect(() => {
if (activeTab === 'special' && specialMentions.length === 0) {
loadSpecialMentions();
}
}, [activeTab, specialMentions.length, loadSpecialMentions]);
const loadContributors = useCallback(async (isRefresh = false) => {
try {
@ -104,7 +306,7 @@ const ContributorsScreen: React.FC = () => {
setLoading(true);
}
setError(null);
// Check cache first (unless refreshing)
if (!isRefresh) {
try {
@ -112,7 +314,7 @@ const ContributorsScreen: React.FC = () => {
const cacheTimestamp = await mmkvStorage.getItem('github_contributors_timestamp');
const now = Date.now();
const ONE_HOUR = 60 * 60 * 1000; // 1 hour cache
if (cachedData && cacheTimestamp) {
const timestamp = parseInt(cacheTimestamp, 10);
if (now - timestamp < ONE_HOUR) {
@ -136,10 +338,10 @@ const ContributorsScreen: React.FC = () => {
try {
await mmkvStorage.removeItem('github_contributors');
await mmkvStorage.removeItem('github_contributors_timestamp');
} catch {}
} catch { }
}
}
const data = await fetchContributors();
if (data && Array.isArray(data) && data.length > 0) {
setContributors(data);
@ -155,7 +357,7 @@ const ContributorsScreen: React.FC = () => {
try {
await mmkvStorage.removeItem('github_contributors');
await mmkvStorage.removeItem('github_contributors_timestamp');
} catch {}
} catch { }
setError('Unable to load contributors. This might be due to GitHub API rate limits.');
}
} catch (err) {
@ -184,7 +386,7 @@ const ContributorsScreen: React.FC = () => {
if (__DEV__) console.error('Error checking cache on mount:', error);
}
};
clearInvalidCache();
loadContributors();
}, [loadContributors]);
@ -247,7 +449,7 @@ const ContributorsScreen: React.FC = () => {
{ backgroundColor: currentTheme.colors.darkBackground }
]}>
<StatusBar barStyle={'light-content'} />
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
@ -267,85 +469,176 @@ const ContributorsScreen: React.FC = () => {
</Text>
</View>
{/* Tab Switcher */}
<View style={[
styles.tabSwitcher,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletTabSwitcher
]}>
<TouchableOpacity
style={[
styles.tab,
activeTab === 'contributors' && { backgroundColor: currentTheme.colors.primary },
isTablet && styles.tabletTab
]}
onPress={() => setActiveTab('contributors')}
activeOpacity={0.7}
>
<Text style={[
styles.tabText,
{ color: activeTab === 'contributors' ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletTabText
]}>
Contributors
</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.tab,
activeTab === 'special' && { backgroundColor: currentTheme.colors.primary },
isTablet && styles.tabletTab
]}
onPress={() => setActiveTab('special')}
activeOpacity={0.7}
>
<Text style={[
styles.tabText,
{ color: activeTab === 'special' ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletTabText
]}>
Special Mentions
</Text>
</TouchableOpacity>
</View>
<View style={styles.content}>
<View style={[styles.contentContainer, isTablet && styles.tabletContentContainer]}>
{error ? (
<View style={styles.errorContainer}>
<Feather name="alert-circle" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.errorText, { color: currentTheme.colors.mediumEmphasis }]}>
{error}
</Text>
<Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}>
GitHub API rate limit exceeded. Please try again later or pull to refresh.
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => loadContributors()}
>
<Text style={[styles.retryText, { color: currentTheme.colors.white }]}>
Try Again
</Text>
</TouchableOpacity>
</View>
) : contributors.length === 0 ? (
<View style={styles.emptyContainer}>
<Feather name="users" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
No contributors found
</Text>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.listContent,
isTablet && styles.tabletListContent
]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary]}
/>
}
showsVerticalScrollIndicator={false}
>
<View style={[
styles.gratitudeCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletGratitudeCard
]}>
<View style={styles.gratitudeContent}>
<Feather name="heart" size={isTablet ? 32 : 24} color={currentTheme.colors.primary} />
<Text style={[
styles.gratitudeText,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText
]}>
We're grateful for every contribution
</Text>
<Text style={[
styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext
]}>
Each line of code, bug report, and suggestion helps make Nuvio better for everyone
</Text>
</View>
</View>
<FlatList
data={contributors}
renderItem={renderContributor}
keyExtractor={keyExtractor}
numColumns={isTablet ? 2 : 1}
key={isTablet ? 'tablet' : 'mobile'}
scrollEnabled={false}
{activeTab === 'contributors' ? (
// Contributors Tab
<>
{error ? (
<View style={styles.errorContainer}>
<Feather name="alert-circle" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.errorText, { color: currentTheme.colors.mediumEmphasis }]}>
{error}
</Text>
<Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}>
GitHub API rate limit exceeded. Please try again later or pull to refresh.
</Text>
<TouchableOpacity
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => loadContributors()}
>
<Text style={[styles.retryText, { color: currentTheme.colors.white }]}>
Try Again
</Text>
</TouchableOpacity>
</View>
) : contributors.length === 0 ? (
<View style={styles.emptyContainer}>
<Feather name="users" size={48} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
No contributors found
</Text>
</View>
) : (
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.listContent,
isTablet && styles.tabletListContent
]}
refreshControl={
<RefreshControl
refreshing={refreshing}
onRefresh={handleRefresh}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary]}
/>
}
showsVerticalScrollIndicator={false}
>
<View style={[
styles.gratitudeCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletGratitudeCard
]}>
<View style={styles.gratitudeContent}>
<Feather name="heart" size={isTablet ? 32 : 24} color={currentTheme.colors.primary} />
<Text style={[
styles.gratitudeText,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText
]}>
We're grateful for every contribution
</Text>
<Text style={[
styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext
]}>
Each line of code, bug report, and suggestion helps make Nuvio better for everyone
</Text>
</View>
</View>
<FlatList
data={contributors}
renderItem={renderContributor}
keyExtractor={keyExtractor}
numColumns={isTablet ? 2 : 1}
key={isTablet ? 'tablet' : 'mobile'}
scrollEnabled={false}
showsVerticalScrollIndicator={false}
columnWrapperStyle={isTablet ? styles.tabletRow : undefined}
/>
</ScrollView>
)}
</>
) : (
// Special Mentions Tab
<ScrollView
style={styles.scrollView}
contentContainerStyle={[
styles.listContent,
isTablet && styles.tabletListContent
]}
showsVerticalScrollIndicator={false}
columnWrapperStyle={isTablet ? styles.tabletRow : undefined}
/>
</ScrollView>
>
<View style={[
styles.gratitudeCard,
{ backgroundColor: currentTheme.colors.elevation1 },
isTablet && styles.tabletGratitudeCard
]}>
<View style={styles.gratitudeContent}>
<FontAwesome5 name="star" size={isTablet ? 32 : 24} color={currentTheme.colors.primary} solid />
<Text style={[
styles.gratitudeText,
{ color: currentTheme.colors.highEmphasis },
isTablet && styles.tabletGratitudeText
]}>
Special Thanks
</Text>
<Text style={[
styles.gratitudeSubtext,
{ color: currentTheme.colors.mediumEmphasis },
isTablet && styles.tabletGratitudeSubtext
]}>
These amazing people help keep the Nuvio community running and the servers online
</Text>
</View>
</View>
{specialMentions.map((mention: SpecialMention) => (
<SpecialMentionCard
key={mention.discordId}
mention={mention}
currentTheme={currentTheme}
isTablet={isTablet}
isLargeTablet={isLargeTablet}
/>
))}
</ScrollView>
)}
</View>
</View>
@ -563,6 +856,70 @@ const styles = StyleSheet.create({
externalIcon: {
marginLeft: 8,
},
// Special Mentions - Compact styles for horizontal layout
specialAvatarContainer: {
position: 'relative',
marginRight: 16,
},
discordBadgeSmall: {
position: 'absolute',
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#1a1a1a',
},
roleBadgeSmall: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 10,
marginTop: 4,
alignSelf: 'flex-start',
},
roleBadgeText: {
fontSize: 10,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.3,
},
// Tab Switcher Styles
tabSwitcher: {
flexDirection: 'row',
marginHorizontal: 16,
marginBottom: 16,
padding: 4,
borderRadius: 12,
},
tabletTabSwitcher: {
marginHorizontal: 32,
marginBottom: 24,
padding: 6,
borderRadius: 16,
},
tab: {
flex: 1,
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
},
tabletTab: {
paddingVertical: 14,
paddingHorizontal: 24,
borderRadius: 12,
},
tabText: {
fontSize: 14,
fontWeight: '600',
},
tabletTabText: {
fontSize: 16,
},
});
export default ContributorsScreen;

View file

@ -37,8 +37,8 @@ import { useTraktContext } from '../contexts/TraktContext';
import TraktIcon from '../../assets/rating-icons/trakt.svg';
import { traktService, TraktService, TraktImages } from '../services/traktService';
import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner';
import { useSettings } from '../hooks/useSettings';
// Define interfaces for proper typing
interface LibraryItem extends StreamingContent {
progress?: number;
lastWatched?: string;
@ -72,10 +72,9 @@ interface TraktFolder {
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Compute responsive grid layout (more columns on tablets)
function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: number } {
const horizontalPadding = 16; // matches listContainer padding (approx)
const gutter = 12; // space between items (via space-between + marginBottom)
const horizontalPadding = 16;
const gutter = 12;
let numColumns = 3;
if (screenWidth >= 1200) numColumns = 5;
else if (screenWidth >= 1000) numColumns = 4;
@ -86,7 +85,19 @@ function getGridLayout(screenWidth: number): { numColumns: number; itemWidth: nu
return { numColumns, itemWidth };
}
const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: TraktDisplayItem; width: number; navigation: any; currentTheme: any }) => {
const TraktItem = React.memo(({
item,
width,
navigation,
currentTheme,
showTitles
}: {
item: TraktDisplayItem;
width: number;
navigation: any;
currentTheme: any;
showTitles: boolean;
}) => {
const [posterUrl, setPosterUrl] = useState<string | null>(null);
useEffect(() => {
@ -129,9 +140,11 @@ const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item:
</View>
)}
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
{item.name}
</Text>
{showTitles && (
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
{item.name}
</Text>
)}
</View>
</TouchableOpacity>
);
@ -184,7 +197,6 @@ const SkeletonLoader = () => {
</View>
);
// Render enough skeletons for at least two rows
const skeletonCount = numColumns * 2;
return (
<View style={styles.skeletonContainer}>
@ -208,13 +220,12 @@ const LibraryScreen = () => {
const [showTraktContent, setShowTraktContent] = useState(false);
const [selectedTraktFolder, setSelectedTraktFolder] = useState<string | null>(null);
const { showInfo, showError } = useToast();
// DropUpMenu state
const [menuVisible, setMenuVisible] = useState(false);
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
const { settings } = useSettings();
// Trakt integration
const {
isAuthenticated: traktAuthenticated,
isLoading: traktLoading,
@ -230,7 +241,6 @@ const LibraryScreen = () => {
loadAllCollections
} = useTraktContext();
// Force consistent status bar settings
useEffect(() => {
const applyStatusBarConfig = () => {
StatusBar.setBarStyle('light-content');
@ -241,30 +251,24 @@ const LibraryScreen = () => {
};
applyStatusBarConfig();
// Re-apply on focus
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
return unsubscribe;
}, [navigation]);
// Handle hardware back button and gesture navigation
useEffect(() => {
const backAction = () => {
if (showTraktContent) {
if (selectedTraktFolder) {
// If in a specific folder, go back to folder list
setSelectedTraktFolder(null);
} else {
// If in Trakt collections view, go back to main library
setShowTraktContent(false);
}
return true; // Prevent default back behavior
return true;
}
return false; // Allow default back behavior (navigate back)
return false;
};
const backHandler = BackHandler.addEventListener('hardwareBackPress', backAction);
return () => backHandler.remove();
}, [showTraktContent, selectedTraktFolder]);
@ -274,16 +278,13 @@ const LibraryScreen = () => {
try {
const items = await catalogService.getLibraryItems();
// Sort by date added (most recent first)
const sortedItems = items.sort((a, b) => {
const timeA = (a as any).addedToLibraryAt || 0;
const timeB = (b as any).addedToLibraryAt || 0;
return timeB - timeA; // Descending order (newest first)
return timeB - timeA;
});
// Load watched status for each item from AsyncStorage
const updatedItems = await Promise.all(sortedItems.map(async (item) => {
// Map StreamingContent to LibraryItem shape
const libraryItem: LibraryItem = {
...item,
gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'],
@ -306,18 +307,14 @@ const LibraryScreen = () => {
loadLibrary();
// Subscribe to library updates
const unsubscribe = catalogService.subscribeToLibraryUpdates(async (items) => {
// Sort by date added (most recent first)
const sortedItems = items.sort((a, b) => {
const timeA = (a as any).addedToLibraryAt || 0;
const timeB = (b as any).addedToLibraryAt || 0;
return timeB - timeA; // Descending order (newest first)
return timeB - timeA;
});
// Sync watched status on update
const updatedItems = await Promise.all(sortedItems.map(async (item) => {
// Map StreamingContent to LibraryItem shape
const libraryItem: LibraryItem = {
...item,
gradient: Array.isArray((item as any).gradient) ? (item as any).gradient : ['#222', '#444'],
@ -333,10 +330,7 @@ const LibraryScreen = () => {
setLibraryItems(updatedItems);
});
// Listen for watched status changes
const watchedSub = DeviceEventEmitter.addListener('watchedStatusChanged', loadLibrary);
// Refresh when screen regains focus
const focusSub = navigation.addListener('focus', loadLibrary);
return () => {
@ -352,7 +346,6 @@ const LibraryScreen = () => {
return true;
});
// Generate Trakt collection folders
const traktFolders = useMemo((): TraktFolder[] => {
if (!traktAuthenticated) return [];
@ -389,61 +382,57 @@ const LibraryScreen = () => {
}
];
// Only return folders that have content
return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const renderItem = ({ item }: { item: LibraryItem }) => {
const aspectRatio = item.posterShape === 'landscape' ? 16 / 9 : (item.posterShape === 'square' ? 1 : 2 / 3);
return (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
}}
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black, aspectRatio }]}>
<FastImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
/>
{item.watched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
</View>
const renderItem = ({ item }: { item: LibraryItem }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
onLongPress={() => {
setSelectedItem(item);
setMenuVisible(true);
}}
activeOpacity={0.7}
>
<View>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<FastImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
resizeMode={FastImage.resizeMode.cover}
/>
{item.watched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success || '#4CAF50'} />
</View>
)}
{item.progress !== undefined && item.progress < 1 && (
<View style={styles.progressBarContainer}>
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
]}
/>
</View>
)}
</View>
{settings.showPosterTitles && (
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
{item.name}
</Text>
</View>
</TouchableOpacity>
);
};
)}
</View>
</TouchableOpacity>
);
// Render individual Trakt collection folder
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
onPress={() => {
setSelectedTraktFolder(folder.id);
loadAllCollections(); // Load all collections when entering a specific folder
loadAllCollections();
}}
activeOpacity={0.7}
>
@ -474,8 +463,8 @@ const LibraryScreen = () => {
navigation.navigate('TraktSettings');
} else {
setShowTraktContent(true);
setSelectedTraktFolder(null); // Reset to folder view
loadAllCollections(); // Load all collections when opening
setSelectedTraktFolder(null);
loadAllCollections();
}
}}
activeOpacity={0.7}
@ -494,24 +483,30 @@ const LibraryScreen = () => {
)}
</View>
</View>
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
Trakt collections
</Text>
{settings.showPosterTitles && (
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
Trakt collections
</Text>
)}
</View>
</TouchableOpacity>
);
const renderTraktItem = useCallback(({ item }: { item: TraktDisplayItem }) => {
return <TraktItem item={item} width={itemWidth} navigation={navigation} currentTheme={currentTheme} />;
}, [itemWidth, navigation, currentTheme]);
return <TraktItem
item={item}
width={itemWidth}
navigation={navigation}
currentTheme={currentTheme}
showTitles={settings.showPosterTitles}
/>;
}, [itemWidth, navigation, currentTheme, settings.showPosterTitles]);
// Get items for a specific Trakt folder
const getTraktFolderItems = useCallback((folderId: string): TraktDisplayItem[] => {
const items: TraktDisplayItem[] = [];
switch (folderId) {
case 'watched':
// Add watched movies
if (watchedMovies) {
for (const watchedMovie of watchedMovies) {
const movie = watchedMovie.movie;
@ -522,7 +517,7 @@ const LibraryScreen = () => {
type: 'movie',
poster: 'placeholder',
year: movie.year,
lastWatched: watchedMovie.last_watched_at, // Store raw timestamp for sorting
lastWatched: watchedMovie.last_watched_at,
plays: watchedMovie.plays,
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
@ -531,7 +526,6 @@ const LibraryScreen = () => {
}
}
}
// Add watched shows
if (watchedShows) {
for (const watchedShow of watchedShows) {
const show = watchedShow.show;
@ -542,7 +536,7 @@ const LibraryScreen = () => {
type: 'series',
poster: 'placeholder',
year: show.year,
lastWatched: watchedShow.last_watched_at, // Store raw timestamp for sorting
lastWatched: watchedShow.last_watched_at,
plays: watchedShow.plays,
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
@ -554,7 +548,6 @@ const LibraryScreen = () => {
break;
case 'continue-watching':
// Add continue watching items
if (continueWatching) {
for (const item of continueWatching) {
if (item.type === 'movie' && item.movie) {
@ -564,7 +557,7 @@ const LibraryScreen = () => {
type: 'movie',
poster: 'placeholder',
year: item.movie.year,
lastWatched: item.paused_at, // Store raw timestamp for sorting
lastWatched: item.paused_at,
imdbId: item.movie.ids.imdb,
traktId: item.movie.ids.trakt,
images: item.movie.images,
@ -576,7 +569,7 @@ const LibraryScreen = () => {
type: 'series',
poster: 'placeholder',
year: item.show.year,
lastWatched: item.paused_at, // Store raw timestamp for sorting
lastWatched: item.paused_at,
imdbId: item.show.ids.imdb,
traktId: item.show.ids.trakt,
images: item.show.images,
@ -587,7 +580,6 @@ const LibraryScreen = () => {
break;
case 'watchlist':
// Add watchlist movies
if (watchlistMovies) {
for (const watchlistMovie of watchlistMovies) {
const movie = watchlistMovie.movie;
@ -598,7 +590,7 @@ const LibraryScreen = () => {
type: 'movie',
poster: 'placeholder',
year: movie.year,
lastWatched: watchlistMovie.listed_at, // Store raw timestamp for sorting
lastWatched: watchlistMovie.listed_at,
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
images: movie.images,
@ -606,7 +598,6 @@ const LibraryScreen = () => {
}
}
}
// Add watchlist shows
if (watchlistShows) {
for (const watchlistShow of watchlistShows) {
const show = watchlistShow.show;
@ -617,7 +608,7 @@ const LibraryScreen = () => {
type: 'series',
poster: 'placeholder',
year: show.year,
lastWatched: watchlistShow.listed_at, // Store raw timestamp for sorting
lastWatched: watchlistShow.listed_at,
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
images: show.images,
@ -628,7 +619,6 @@ const LibraryScreen = () => {
break;
case 'collection':
// Add collection movies
if (collectionMovies) {
for (const collectionMovie of collectionMovies) {
const movie = collectionMovie.movie;
@ -639,7 +629,7 @@ const LibraryScreen = () => {
type: 'movie',
poster: 'placeholder',
year: movie.year,
lastWatched: collectionMovie.collected_at, // Store raw timestamp for sorting
lastWatched: collectionMovie.collected_at,
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
images: movie.images,
@ -647,7 +637,6 @@ const LibraryScreen = () => {
}
}
}
// Add collection shows
if (collectionShows) {
for (const collectionShow of collectionShows) {
const show = collectionShow.show;
@ -658,7 +647,7 @@ const LibraryScreen = () => {
type: 'series',
poster: 'placeholder',
year: show.year,
lastWatched: collectionShow.collected_at, // Store raw timestamp for sorting
lastWatched: collectionShow.collected_at,
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
images: show.images,
@ -669,7 +658,6 @@ const LibraryScreen = () => {
break;
case 'ratings':
// Add rated content
if (ratedContent) {
for (const ratedItem of ratedContent) {
if (ratedItem.movie) {
@ -680,7 +668,7 @@ const LibraryScreen = () => {
type: 'movie',
poster: 'placeholder',
year: movie.year,
lastWatched: ratedItem.rated_at, // Store raw timestamp for sorting
lastWatched: ratedItem.rated_at,
rating: ratedItem.rating,
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
@ -694,7 +682,7 @@ const LibraryScreen = () => {
type: 'series',
poster: 'placeholder',
year: show.year,
lastWatched: ratedItem.rated_at, // Store raw timestamp for sorting
lastWatched: ratedItem.rated_at,
rating: ratedItem.rating,
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
@ -706,7 +694,6 @@ const LibraryScreen = () => {
break;
}
// Sort by last watched/added date (most recent first) using raw timestamps
return items.sort((a, b) => {
const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0;
const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0;
@ -719,7 +706,6 @@ const LibraryScreen = () => {
return <TraktLoadingSpinner />;
}
// If no specific folder is selected, show the folder structure
if (!selectedTraktFolder) {
if (traktFolders.length === 0) {
return (
@ -745,7 +731,6 @@ const LibraryScreen = () => {
);
}
// Show collection folders
return (
<FlashList
data={traktFolders}
@ -760,7 +745,6 @@ const LibraryScreen = () => {
);
}
// Show content for specific folder
const folderItems = getTraktFolderItems(selectedTraktFolder);
if (folderItems.length === 0) {
@ -902,7 +886,6 @@ const LibraryScreen = () => {
);
};
// Tablet detection aligned with navigation tablet logic
const isTablet = useMemo(() => {
const smallestDimension = Math.min(width, height);
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
@ -910,7 +893,6 @@ const LibraryScreen = () => {
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
{/* ScreenHeader Component */}
<ScreenHeader
title={showTraktContent
? (selectedTraktFolder
@ -932,7 +914,6 @@ const LibraryScreen = () => {
isTablet={isTablet}
/>
{/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{!showTraktContent && (
<View style={styles.filtersContainer}>
@ -945,14 +926,13 @@ const LibraryScreen = () => {
{showTraktContent ? renderTraktContent() : renderContent()}
</View>
{/* DropUpMenu integration */}
{selectedItem && (
<DropUpMenu
visible={menuVisible}
onClose={() => setMenuVisible(false)}
item={selectedItem}
isWatched={!!selectedItem.watched}
isSaved={true} // Since this is from library, it's always saved
isSaved={true}
onOptionSelect={async (option) => {
if (!selectedItem) return;
switch (option) {
@ -969,12 +949,10 @@ const LibraryScreen = () => {
}
case 'watched': {
try {
// Use AsyncStorage to store watched status by key
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
const newWatched = !selectedItem.watched;
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched');
// Instantly update local state
setLibraryItems(prev => prev.map(item =>
item.id === selectedItem.id && item.type === selectedItem.type
? { ...item, watched: newWatched }
@ -1273,7 +1251,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
},
headerSpacer: {
width: 44, // Match the back button width
width: 44,
},
traktContainer: {
flex: 1,
@ -1294,4 +1272,4 @@ const styles = StyleSheet.create({
},
});
export default LibraryScreen;
export default LibraryScreen;

View file

@ -1946,6 +1946,7 @@ const PluginsScreen: React.FC = () => {
visible={showHelpModal}
transparent={true}
animationType="fade"
supportedOrientations={['portrait', 'landscape']}
onRequestClose={() => setShowHelpModal(false)}
>
<View style={styles.modalOverlay}>
@ -1978,6 +1979,7 @@ const PluginsScreen: React.FC = () => {
visible={showAddRepositoryModal}
transparent={true}
animationType="fade"
supportedOrientations={['portrait', 'landscape']}
onRequestClose={() => setShowAddRepositoryModal(false)}
>
<View style={styles.modalOverlay}>

View file

@ -33,7 +33,7 @@ const ProfilesScreen: React.FC = () => {
const navigation = useNavigation();
const { currentTheme } = useTheme();
const { isAuthenticated, userProfile, refreshAuthStatus } = useTraktContext();
const [profiles, setProfiles] = useState<Profile[]>([]);
const [showAddModal, setShowAddModal] = useState(false);
const [newProfileName, setNewProfileName] = useState('');
@ -52,7 +52,7 @@ const ProfilesScreen: React.FC = () => {
) => {
setAlertTitle(title);
setAlertMessage(message);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]);
setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => { } }]);
setAlertVisible(true);
};
@ -92,7 +92,7 @@ const ProfilesScreen: React.FC = () => {
}
});
});
return unsubscribe;
}, [navigation, refreshAuthStatus, isAuthenticated, loadProfiles]);
@ -112,7 +112,7 @@ const ProfilesScreen: React.FC = () => {
navigation.goBack();
return;
}
loadProfiles();
}, [isAuthenticated, loadProfiles, navigation]);
@ -141,7 +141,7 @@ const ProfilesScreen: React.FC = () => {
...profile,
isActive: profile.id === id
}));
setProfiles(updatedProfiles);
saveProfiles(updatedProfiles);
}, [profiles, saveProfiles]);
@ -164,14 +164,14 @@ const ProfilesScreen: React.FC = () => {
'Delete Profile',
'Are you sure you want to delete this profile? This action cannot be undone.',
[
{ label: 'Cancel', onPress: () => {} },
{
label: 'Delete',
{ label: 'Cancel', onPress: () => { } },
{
label: 'Delete',
onPress: () => {
const updatedProfiles = profiles.filter(profile => profile.id !== id);
setProfiles(updatedProfiles);
saveProfiles(updatedProfiles);
}
}
}
]
);
@ -183,10 +183,10 @@ const ProfilesScreen: React.FC = () => {
const renderItem = ({ item }: { item: Profile }) => (
<View style={styles.profileItem}>
<TouchableOpacity
<TouchableOpacity
style={[
styles.profileContent,
item.isActive && {
item.isActive && {
backgroundColor: `${currentTheme.colors.primary}30`,
borderColor: currentTheme.colors.primary
}
@ -194,10 +194,10 @@ const ProfilesScreen: React.FC = () => {
onPress={() => handleSelectProfile(item.id)}
>
<View style={styles.avatarContainer}>
<MaterialIcons
name="account-circle"
size={40}
color={item.isActive ? currentTheme.colors.primary : currentTheme.colors.text}
<MaterialIcons
name="account-circle"
size={40}
color={item.isActive ? currentTheme.colors.primary : currentTheme.colors.text}
/>
</View>
<View style={styles.profileInfo}>
@ -211,7 +211,7 @@ const ProfilesScreen: React.FC = () => {
)}
</View>
{!item.isActive && (
<TouchableOpacity
<TouchableOpacity
style={styles.deleteButton}
onPress={() => handleDeleteProfile(item.id)}
>
@ -225,7 +225,7 @@ const ProfilesScreen: React.FC = () => {
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
<View style={styles.header}>
<TouchableOpacity
onPress={handleBack}
@ -281,6 +281,7 @@ const ProfilesScreen: React.FC = () => {
visible={showAddModal}
transparent
animationType="fade"
supportedOrientations={['portrait', 'landscape']}
onRequestClose={() => setShowAddModal(false)}
>
<View style={styles.modalOverlay}>
@ -288,11 +289,11 @@ const ProfilesScreen: React.FC = () => {
<Text style={[styles.modalTitle, { color: currentTheme.colors.text }]}>
Create New Profile
</Text>
<TextInput
style={[
styles.input,
{
{
backgroundColor: `${currentTheme.colors.textMuted}20`,
color: currentTheme.colors.text,
borderColor: currentTheme.colors.border
@ -304,9 +305,9 @@ const ProfilesScreen: React.FC = () => {
onChangeText={setNewProfileName}
autoFocus
/>
<View style={styles.modalButtons}>
<TouchableOpacity
<TouchableOpacity
style={[styles.modalButton, styles.cancelButton]}
onPress={() => {
setNewProfileName('');
@ -315,9 +316,9 @@ const ProfilesScreen: React.FC = () => {
>
<Text style={{ color: currentTheme.colors.textMuted }}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
<TouchableOpacity
style={[
styles.modalButton,
styles.modalButton,
styles.createButton,
{ backgroundColor: currentTheme.colors.primary }
]}

View file

@ -132,17 +132,27 @@ export const StreamsScreen = () => {
const { showSuccess, showInfo } = useToast();
// Add dimension listener and tablet detection
// Use a ref to track previous dimensions to avoid unnecessary re-renders
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
const prevDimensionsRef = useRef({ width: dimensions.width, height: dimensions.height });
useEffect(() => {
const subscription = Dimensions.addEventListener('change', ({ window }) => {
setDimensions(window);
// Only update state if dimensions actually changed (with 1px tolerance)
const widthChanged = Math.abs(window.width - prevDimensionsRef.current.width) > 1;
const heightChanged = Math.abs(window.height - prevDimensionsRef.current.height) > 1;
if (widthChanged || heightChanged) {
prevDimensionsRef.current = { width: window.width, height: window.height };
setDimensions(window);
}
});
return () => subscription?.remove();
}, []);
// Memoize tablet detection to prevent recalculation on every render
const deviceWidth = dimensions.width;
const isTablet = deviceWidth >= 768;
const isTablet = useMemo(() => deviceWidth >= 768, [deviceWidth]);
// Add refs to prevent excessive updates and duplicate loads
const isMounted = useRef(true);
@ -303,6 +313,9 @@ export const StreamsScreen = () => {
}, []);
// Monitor streams loading and update available providers immediately
// Use a ref to track the previous providers to avoid unnecessary state updates
const prevProvidersRef = useRef<Set<string>>(new Set());
useEffect(() => {
// Skip processing if component is unmounting
if (!isMounted.current) return;
@ -317,14 +330,21 @@ export const StreamsScreen = () => {
if (providersWithStreams.length > 0) {
logger.log(`📊 Providers with streams: ${providersWithStreams.join(', ')}`);
const providersWithStreamsSet = new Set(providersWithStreams);
// Only update if we have new providers, don't remove existing ones during loading
setAvailableProviders(prevProviders => {
const newProviders = new Set([...prevProviders, ...providersWithStreamsSet]);
if (__DEV__) console.log('[StreamsScreen] availableProviders ->', Array.from(newProviders));
return newProviders;
});
// Check if we actually have new providers before triggering state update
const hasNewProviders = providersWithStreams.some(
provider => !prevProvidersRef.current.has(provider)
);
if (hasNewProviders) {
setAvailableProviders(prevProviders => {
const newProviders = new Set([...prevProviders, ...providersWithStreams]);
// Update ref to track current providers
prevProvidersRef.current = newProviders;
if (__DEV__) console.log('[StreamsScreen] availableProviders ->', Array.from(newProviders));
return newProviders;
});
}
}
// Update loading states for individual providers

View file

@ -36,27 +36,27 @@ const TMDB_API_KEY = '439c478a771f35c05022f9feabcca01c';
// Define example shows with their IMDB IDs and TMDB IDs
const EXAMPLE_SHOWS = [
{
name: 'Breaking Bad',
imdbId: 'tt0903747',
{
name: 'Breaking Bad',
imdbId: 'tt0903747',
tmdbId: '1396',
type: 'tv' as const
},
{
name: 'Friends',
imdbId: 'tt0108778',
{
name: 'Friends',
imdbId: 'tt0108778',
tmdbId: '1668',
type: 'tv' as const
},
{
name: 'Stranger Things',
imdbId: 'tt4574334',
{
name: 'Stranger Things',
imdbId: 'tt4574334',
tmdbId: '66732',
type: 'tv' as const
},
{
name: 'Avatar',
imdbId: 'tt0499549',
{
name: 'Avatar',
imdbId: 'tt0499549',
tmdbId: '19995',
type: 'movie' as const
},
@ -82,7 +82,7 @@ const TMDBSettingsScreen = () => {
const { settings, updateSetting } = useSettings();
const [languagePickerVisible, setLanguagePickerVisible] = useState(false);
const [languageSearch, setLanguageSearch] = useState('');
// Logo preview state
const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]);
const [tmdbLogo, setTmdbLogo] = useState<string | null>(null);
@ -126,7 +126,7 @@ const TMDBSettingsScreen = () => {
try {
const keys = await mmkvStorage.getAllKeys();
const tmdbKeys = keys.filter(key => key.startsWith('tmdb_cache_'));
let totalSize = 0;
for (const key of tmdbKeys) {
const value = mmkvStorage.getString(key);
@ -134,7 +134,7 @@ const TMDBSettingsScreen = () => {
totalSize += value.length;
}
}
// Convert to KB/MB
let sizeStr = '';
if (totalSize < 1024) {
@ -144,7 +144,7 @@ const TMDBSettingsScreen = () => {
} else {
sizeStr = `${(totalSize / (1024 * 1024)).toFixed(2)} MB`;
}
setCacheSize(sizeStr);
} catch (error) {
logger.error('[TMDBSettingsScreen] Error calculating cache size:', error);
@ -187,17 +187,17 @@ const TMDBSettingsScreen = () => {
mmkvStorage.getItem(TMDB_API_KEY_STORAGE_KEY),
mmkvStorage.getItem(USE_CUSTOM_TMDB_API_KEY)
]);
logger.log('[TMDBSettingsScreen] API key status:', savedKey ? 'Found' : 'Not found');
logger.log('[TMDBSettingsScreen] Use custom API setting:', savedUseCustomKey);
if (savedKey) {
setApiKey(savedKey);
setIsKeySet(true);
} else {
setIsKeySet(false);
}
setUseCustomKey(savedUseCustomKey === 'true');
} catch (error) {
logger.error('[TMDBSettingsScreen] Failed to load settings:', error);
@ -212,7 +212,7 @@ const TMDBSettingsScreen = () => {
const saveApiKey = async () => {
logger.log('[TMDBSettingsScreen] Starting API key save');
Keyboard.dismiss();
try {
const trimmedKey = apiKey.trim();
if (!trimmedKey) {
@ -299,27 +299,27 @@ const TMDBSettingsScreen = () => {
try {
await mmkvStorage.setItem(USE_CUSTOM_TMDB_API_KEY, value ? 'true' : 'false');
setUseCustomKey(value);
if (!value) {
// If switching to built-in key, show confirmation
logger.log('[TMDBSettingsScreen] Switching to built-in API key');
setTestResult({
success: true,
message: 'Now using the built-in TMDb API key.'
setTestResult({
success: true,
message: 'Now using the built-in TMDb API key.'
});
} else if (apiKey && isKeySet) {
// If switching to custom key and we have a key
logger.log('[TMDBSettingsScreen] Switching to custom API key');
setTestResult({
success: true,
message: 'Now using your custom TMDb API key.'
setTestResult({
success: true,
message: 'Now using your custom TMDb API key.'
});
} else {
// If switching to custom key but don't have a key yet
logger.log('[TMDBSettingsScreen] No custom key available yet');
setTestResult({
success: false,
message: 'Please enter and save your custom TMDb API key.'
setTestResult({
success: false,
message: 'Please enter and save your custom TMDb API key.'
});
}
} catch (error) {
@ -355,27 +355,27 @@ const TMDBSettingsScreen = () => {
setLoadingLogos(true);
setTmdbLogo(null);
setTmdbBanner(null);
try {
const tmdbId = show.tmdbId;
const contentType = show.type;
logger.log(`[TMDBSettingsScreen] Fetching ${show.name} with TMDB ID: ${tmdbId}`);
const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en';
const apiKey = TMDB_API_KEY;
const endpoint = contentType === 'tv' ? 'tv' : 'movie';
const response = await fetch(`https://api.themoviedb.org/3/${endpoint}/${tmdbId}/images?api_key=${apiKey}`);
const imagesData = await response.json();
if (imagesData.logos && imagesData.logos.length > 0) {
let logoPath: string | null = null;
let logoLanguage = preferredTmdbLanguage;
// Try to find logo in preferred language
const preferredLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === preferredTmdbLanguage);
if (preferredLogo) {
logoPath = preferredLogo.file_path;
logoLanguage = preferredTmdbLanguage;
@ -383,7 +383,7 @@ const TMDBSettingsScreen = () => {
} else {
// Fallback to English
const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en');
if (englishLogo) {
logoPath = englishLogo.file_path;
logoLanguage = 'en';
@ -395,7 +395,7 @@ const TMDBSettingsScreen = () => {
setIsPreviewFallback(true);
}
}
if (logoPath) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${logoPath}`);
setPreviewLanguage(logoLanguage);
@ -407,7 +407,7 @@ const TMDBSettingsScreen = () => {
setPreviewLanguage('');
setIsPreviewFallback(false);
}
// Get TMDB banner (backdrop)
if (imagesData.backdrops && imagesData.backdrops.length > 0) {
const backdropPath = imagesData.backdrops[0].file_path;
@ -415,7 +415,7 @@ const TMDBSettingsScreen = () => {
} else {
const detailsResponse = await fetch(`https://api.themoviedb.org/3/${endpoint}/${tmdbId}?api_key=${apiKey}`);
const details = await detailsResponse.json();
if (details.backdrop_path) {
setTmdbBanner(`https://image.tmdb.org/t/p/original${details.backdrop_path}`);
}
@ -444,17 +444,17 @@ const TMDBSettingsScreen = () => {
</View>
);
}
return (
<View style={styles.bannerContainer}>
<FastImage
<FastImage
source={{ uri: banner || undefined }}
style={styles.bannerImage}
resizeMode={FastImage.resizeMode.cover}
/>
<View style={styles.bannerOverlay} />
{logo && (
<FastImage
<FastImage
source={{ uri: logo }}
style={styles.logoOverBanner}
resizeMode={FastImage.resizeMode.contain}
@ -491,7 +491,7 @@ const TMDBSettingsScreen = () => {
if (__DEV__) console.error('Error loading selected show:', e);
}
};
loadSelectedShow();
}, []);
@ -512,7 +512,7 @@ const TMDBSettingsScreen = () => {
}
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
@ -520,7 +520,7 @@ const TMDBSettingsScreen = () => {
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</View>
@ -602,7 +602,7 @@ const TMDBSettingsScreen = () => {
{/* Logo Preview */}
<View style={styles.divider} />
<Text style={[styles.settingTitle, { color: currentTheme.colors.text, marginBottom: 8 }]}>Logo Preview</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis, marginBottom: 12 }]}>
Preview shows how localized logos will appear in the selected language.
@ -610,8 +610,8 @@ const TMDBSettingsScreen = () => {
{/* Show selector */}
<Text style={[styles.selectorLabel, { color: currentTheme.colors.mediumEmphasis }]}>Example:</Text>
<ScrollView
horizontal
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.showsScrollContent}
style={styles.showsScrollView}
@ -627,7 +627,7 @@ const TMDBSettingsScreen = () => {
onPress={() => handleShowSelect(show)}
activeOpacity={0.7}
>
<Text
<Text
style={[
styles.showItemText,
{ color: currentTheme.colors.mediumEmphasis },
@ -795,7 +795,7 @@ const TMDBSettingsScreen = () => {
{/* Cache Management Section */}
<View style={styles.divider} />
<View style={styles.settingRow}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.text }]}>Cache Size</Text>
@ -828,6 +828,7 @@ const TMDBSettingsScreen = () => {
visible={languagePickerVisible}
transparent
animationType="slide"
supportedOrientations={['portrait', 'landscape']}
onRequestClose={() => setLanguagePickerVisible(false)}
>
<TouchableWithoutFeedback onPress={() => setLanguagePickerVisible(false)}>
@ -955,42 +956,42 @@ const TMDBSettingsScreen = () => {
return (
<>
{filteredLanguages.map(({ code, label, native }) => (
<TouchableOpacity
key={code}
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
style={[
styles.languageItem,
settings.tmdbLanguagePreference === code && styles.selectedLanguageItem
]}
activeOpacity={0.7}
>
<View style={styles.languageContent}>
<View style={styles.languageInfo}>
<Text style={[
styles.languageName,
settings.tmdbLanguagePreference === code && styles.selectedLanguageName,
{
color: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.text,
}
]}>
{native}
</Text>
<Text style={[
styles.languageCode,
settings.tmdbLanguagePreference === code && styles.selectedLanguageCode,
{
color: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis,
}
]}>
{label} {code.toUpperCase()}
</Text>
</View>
{settings.tmdbLanguagePreference === code && (
<View style={styles.checkmarkContainer}>
<MaterialIcons name="check-circle" size={24} color={currentTheme.colors.primary} />
</View>
)}
</View>
<TouchableOpacity
key={code}
onPress={() => { updateSetting('tmdbLanguagePreference', code); setLanguagePickerVisible(false); }}
style={[
styles.languageItem,
settings.tmdbLanguagePreference === code && styles.selectedLanguageItem
]}
activeOpacity={0.7}
>
<View style={styles.languageContent}>
<View style={styles.languageInfo}>
<Text style={[
styles.languageName,
settings.tmdbLanguagePreference === code && styles.selectedLanguageName,
{
color: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.text,
}
]}>
{native}
</Text>
<Text style={[
styles.languageCode,
settings.tmdbLanguagePreference === code && styles.selectedLanguageCode,
{
color: settings.tmdbLanguagePreference === code ? currentTheme.colors.primary : currentTheme.colors.mediumEmphasis,
}
]}>
{label} {code.toUpperCase()}
</Text>
</View>
{settings.tmdbLanguagePreference === code && (
<View style={styles.checkmarkContainer}>
<MaterialIcons name="check-circle" size={24} color={currentTheme.colors.primary} />
</View>
)}
</View>
</TouchableOpacity>
))}
{languageSearch.length > 0 && filteredLanguages.length === 0 && (