mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
217 lines
7.2 KiB
TypeScript
217 lines
7.2 KiB
TypeScript
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
import { View, Text, TouchableOpacity, ActivityIndicator, Image, Platform } from 'react-native';
|
|
import { Animated } from 'react-native';
|
|
import { MaterialIcons } from '@expo/vector-icons';
|
|
import { logger } from '../../../utils/logger';
|
|
import { LinearGradient } from 'expo-linear-gradient';
|
|
|
|
export interface Insets {
|
|
top: number;
|
|
right: number;
|
|
bottom: number;
|
|
left: number;
|
|
}
|
|
|
|
export interface NextEpisodeLike {
|
|
season_number: number;
|
|
episode_number: number;
|
|
name?: string;
|
|
thumbnailUrl?: string; // Added thumbnailUrl to NextEpisodeLike
|
|
}
|
|
|
|
interface UpNextButtonProps {
|
|
type: string | undefined;
|
|
nextEpisode: NextEpisodeLike | null | undefined;
|
|
currentTime: number;
|
|
duration: number;
|
|
insets: Insets;
|
|
isLoading: boolean;
|
|
nextLoadingProvider?: string | null;
|
|
nextLoadingQuality?: string | null;
|
|
nextLoadingTitle?: string | null;
|
|
onPress: () => void;
|
|
metadata?: { poster?: string; id?: string }; // Added metadata prop
|
|
controlsVisible?: boolean;
|
|
controlsFixedOffset?: number;
|
|
}
|
|
|
|
const UpNextButton: React.FC<UpNextButtonProps> = ({
|
|
type,
|
|
nextEpisode,
|
|
currentTime,
|
|
duration,
|
|
insets,
|
|
isLoading,
|
|
nextLoadingProvider,
|
|
nextLoadingQuality,
|
|
nextLoadingTitle,
|
|
onPress,
|
|
metadata,
|
|
controlsVisible = false,
|
|
controlsFixedOffset = 100,
|
|
}) => {
|
|
const [visible, setVisible] = useState(false);
|
|
const opacity = useRef(new Animated.Value(0)).current;
|
|
const scale = useRef(new Animated.Value(0.8)).current;
|
|
const translateY = useRef(new Animated.Value(0)).current;
|
|
|
|
// Derive thumbnail similar to EpisodeCard
|
|
let imageUri: string | null = null;
|
|
const anyEpisode: any = nextEpisode as any;
|
|
if (anyEpisode?.still_path) {
|
|
if (typeof anyEpisode.still_path === 'string') {
|
|
if (anyEpisode.still_path.startsWith('http')) {
|
|
imageUri = anyEpisode.still_path;
|
|
} else {
|
|
try {
|
|
const { tmdbService } = require('../../../services/tmdbService');
|
|
const url = tmdbService.getImageUrl(anyEpisode.still_path, 'w500');
|
|
if (url) imageUri = url;
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
if (!imageUri && nextEpisode?.thumbnailUrl) imageUri = nextEpisode.thumbnailUrl;
|
|
if (!imageUri && metadata?.poster) imageUri = metadata.poster || null;
|
|
|
|
const shouldShow = useMemo(() => {
|
|
if (!nextEpisode || duration <= 0) return false;
|
|
const timeRemaining = duration - currentTime;
|
|
// Be tolerant to timer jitter: show when under ~1 minute and above 10s
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (shouldShow && !visible) {
|
|
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 {}
|
|
Animated.parallel([
|
|
Animated.timing(opacity, { toValue: 0, duration: 200, useNativeDriver: true }),
|
|
Animated.timing(scale, { toValue: 0.8, duration: 200, useNativeDriver: true }),
|
|
]).start(() => {
|
|
setVisible(false);
|
|
});
|
|
}
|
|
}, [shouldShow, visible, opacity, scale]);
|
|
|
|
// Animate vertical offset based on controls visibility
|
|
useEffect(() => {
|
|
const target = controlsVisible ? -Math.max(0, controlsFixedOffset - 8) : 0;
|
|
Animated.timing(translateY, {
|
|
toValue: target,
|
|
duration: 220,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
}, [controlsVisible, controlsFixedOffset, translateY]);
|
|
|
|
if (!visible || !nextEpisode) return null;
|
|
|
|
return (
|
|
<Animated.View
|
|
style={{
|
|
position: 'absolute',
|
|
bottom: 24 + insets.bottom,
|
|
right: (Platform.OS === 'android' ? 12 : 4) + insets.right,
|
|
opacity,
|
|
transform: [{ scale }, { translateY }],
|
|
zIndex: 50,
|
|
// Square compact card (bigger)
|
|
width: 200,
|
|
height: 140,
|
|
borderRadius: 18,
|
|
overflow: 'hidden',
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 4 },
|
|
shadowOpacity: 0.25,
|
|
shadowRadius: 10,
|
|
elevation: 10,
|
|
backgroundColor: '#1b1b1b',
|
|
}}
|
|
>
|
|
<TouchableOpacity
|
|
style={{ flex: 1 }}
|
|
onPress={onPress}
|
|
activeOpacity={0.85}
|
|
>
|
|
{/* Thumbnail fills card */}
|
|
{imageUri ? (
|
|
<Image
|
|
source={{ uri: imageUri }}
|
|
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, width: '100%', height: '100%' }}
|
|
resizeMode="cover"
|
|
/>
|
|
) : (
|
|
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#2a2a2a' }}>
|
|
<MaterialIcons name="movie" size={44} color="#9aa0a6" />
|
|
</View>
|
|
)}
|
|
|
|
{/* Bottom overlay text */}
|
|
<LinearGradient
|
|
colors={["rgba(0,0,0,0)", "rgba(0,0,0,0.5)", "rgba(0,0,0,0.9)"]}
|
|
locations={[0, 0.4, 1]}
|
|
start={{ x: 0.5, y: 0 }}
|
|
end={{ x: 0.5, y: 1 }}
|
|
style={{
|
|
position: 'absolute',
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
paddingHorizontal: 10,
|
|
paddingVertical: 8,
|
|
height: '60%',
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 3 }}>
|
|
{isLoading ? (
|
|
<ActivityIndicator size="small" color="#ffffff" style={{ marginRight: 6, transform: [{ scale: 0.85 }] }} />
|
|
) : (
|
|
<MaterialIcons name="skip-next" size={18} color="#ffffff" style={{ marginRight: 6 }} />
|
|
)}
|
|
<Text style={{ color: '#ffffff', fontSize: 11, fontWeight: '700', opacity: 0.9 }} numberOfLines={1}>
|
|
{isLoading ? 'Loading next…' : 'Up next'}
|
|
</Text>
|
|
</View>
|
|
<Text style={{ color: '#ffffff', fontSize: 12, fontWeight: '700' }} numberOfLines={1}>
|
|
S{nextEpisode.season_number}E{nextEpisode.episode_number}
|
|
{nextEpisode.name ? `: ${nextEpisode.name}` : ''}
|
|
</Text>
|
|
{isLoading && (
|
|
<Text style={{ color: 'rgba(255,255,255,0.9)', fontSize: 11 }} numberOfLines={1}>
|
|
{nextLoadingProvider ? `${nextLoadingProvider}` : 'Finding source…'}
|
|
{nextLoadingQuality ? ` • ${nextLoadingQuality}p` : ''}
|
|
{nextLoadingTitle ? ` • ${nextLoadingTitle}` : ''}
|
|
</Text>
|
|
)}
|
|
</LinearGradient>
|
|
</TouchableOpacity>
|
|
</Animated.View>
|
|
);
|
|
}
|
|
|
|
export default UpNextButton;
|
|
|
|
|