mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
Merge branch 'main' into patch-8
This commit is contained in:
commit
99db47d503
21 changed files with 1225 additions and 1269 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -101,6 +101,7 @@ const AnnouncementOverlay: React.FC<AnnouncementOverlayProps> = ({
|
|||
transparent
|
||||
animationType="none"
|
||||
statusBarTranslucent
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
onRequestClose={handleClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1660,6 +1660,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
animationType="fade"
|
||||
onRequestClose={closeEpisodeActionMenu}
|
||||
statusBarTranslucent
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
>
|
||||
<Pressable
|
||||
style={{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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' : 'Built‑in 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' : 'Built‑in 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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1413,6 +1413,7 @@ const AddonsScreen = () => {
|
|||
visible={showConfirmModal}
|
||||
transparent
|
||||
animationType="fade"
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
onRequestClose={() => {
|
||||
setShowConfirmModal(false);
|
||||
setAddonDetails(null);
|
||||
|
|
|
|||
|
|
@ -685,6 +685,7 @@ const CatalogSettingsScreen = () => {
|
|||
animationType="fade"
|
||||
transparent={true}
|
||||
visible={isRenameModalVisible}
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
onRequestClose={() => {
|
||||
setIsRenameModalVisible(false);
|
||||
setCatalogToRename(null);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue