mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
upnext button UI changes
This commit is contained in:
parent
33d13c74d3
commit
84b8cb7817
3 changed files with 292 additions and 242 deletions
|
|
@ -46,6 +46,7 @@ import PlayerControls from './controls/PlayerControls';
|
|||
import CustomSubtitles from './subtitles/CustomSubtitles';
|
||||
import { SourcesModal } from './modals/SourcesModal';
|
||||
import { EpisodesModal } from './modals/EpisodesModal';
|
||||
import UpNextButton from './common/UpNextButton';
|
||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||
import VlcVideoPlayer, { VlcPlayerRef } from './VlcVideoPlayer';
|
||||
import { stremioService } from '../../services/stremioService';
|
||||
|
|
@ -611,16 +612,12 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const metadataOpacity = useRef(new Animated.Value(1)).current;
|
||||
const metadataScale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Next episode button state
|
||||
const [showNextEpisodeButton, setShowNextEpisodeButton] = useState(false);
|
||||
// Next episode loading state
|
||||
const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false);
|
||||
const [nextLoadingProvider, setNextLoadingProvider] = useState<string | null>(null);
|
||||
const [nextLoadingQuality, setNextLoadingQuality] = useState<string | null>(null);
|
||||
const [nextLoadingTitle, setNextLoadingTitle] = useState<string | null>(null);
|
||||
|
||||
const nextEpisodeButtonOpacity = useRef(new Animated.Value(0)).current;
|
||||
const nextEpisodeButtonScale = useRef(new Animated.Value(0.8)).current;
|
||||
|
||||
// Cast display state
|
||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
||||
const [showCastDetails, setShowCastDetails] = useState(false);
|
||||
|
|
@ -701,30 +698,43 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
}, [type, groupedEpisodes, episodeId, season, episode]);
|
||||
|
||||
// Find next episode for series
|
||||
// Find next episode for series (use groupedEpisodes or fallback to metadataGroupedEpisodes)
|
||||
const nextEpisode = useMemo(() => {
|
||||
try {
|
||||
if ((type as any) !== 'series' || !season || !episode) return null;
|
||||
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
|
||||
// Prefer groupedEpisodes from route, else metadataGroupedEpisodes
|
||||
const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0
|
||||
? groupedEpisodes
|
||||
: (metadataGroupedEpisodes || {});
|
||||
const allEpisodes = Object.values(sourceGroups || {}).flat() as any[];
|
||||
if (!allEpisodes || allEpisodes.length === 0) return null;
|
||||
|
||||
// First try next episode in same season
|
||||
let nextEp = allEpisodes.find((ep: any) =>
|
||||
ep.season_number === season && ep.episode_number === episode + 1
|
||||
);
|
||||
|
||||
// If not found, try first episode of next season
|
||||
if (!nextEp) {
|
||||
nextEp = allEpisodes.find((ep: any) =>
|
||||
ep.season_number === season + 1 && ep.episode_number === 1
|
||||
);
|
||||
}
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
logger.log('[AndroidVideoPlayer] nextEpisode computation', {
|
||||
fromRouteGroups: !!(groupedEpisodes && Object.keys(groupedEpisodes || {}).length),
|
||||
fromMetadataGroups: !!(metadataGroupedEpisodes && Object.keys(metadataGroupedEpisodes || {}).length),
|
||||
allEpisodesCount: allEpisodes?.length || 0,
|
||||
currentSeason: season,
|
||||
currentEpisode: episode,
|
||||
found: !!nextEp,
|
||||
foundId: nextEp?.stremioId || nextEp?.id,
|
||||
foundName: nextEp?.name,
|
||||
});
|
||||
}
|
||||
return nextEp;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [type, season, episode, groupedEpisodes]);
|
||||
}, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]);
|
||||
|
||||
// Small offset (in seconds) used to avoid seeking to the *exact* end of the
|
||||
// file which triggers the `onEnd` callback and causes playback to restart.
|
||||
|
|
@ -2744,65 +2754,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
};
|
||||
}, [paused]);
|
||||
|
||||
// Handle next episode button visibility based on current time and next episode availability
|
||||
useEffect(() => {
|
||||
if ((type as any) !== 'series' || !nextEpisode || duration <= 0) {
|
||||
if (showNextEpisodeButton) {
|
||||
// Hide button with animation
|
||||
Animated.parallel([
|
||||
Animated.timing(nextEpisodeButtonOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(nextEpisodeButtonScale, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start(() => {
|
||||
setShowNextEpisodeButton(false);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show button when 1 minute (60 seconds) remains
|
||||
const timeRemaining = duration - currentTime;
|
||||
const shouldShowButton = timeRemaining <= 60 && timeRemaining > 10; // Hide in last 10 seconds
|
||||
|
||||
if (shouldShowButton && !showNextEpisodeButton) {
|
||||
setShowNextEpisodeButton(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(nextEpisodeButtonOpacity, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(nextEpisodeButtonScale, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
} else if (!shouldShowButton && showNextEpisodeButton) {
|
||||
Animated.parallel([
|
||||
Animated.timing(nextEpisodeButtonOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(nextEpisodeButtonScale, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start(() => {
|
||||
setShowNextEpisodeButton(false);
|
||||
});
|
||||
}
|
||||
}, [type, nextEpisode, duration, currentTime, showNextEpisodeButton]);
|
||||
// Up Next visibility handled inside reusable component
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
|
@ -3752,59 +3704,22 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Next Episode Button */}
|
||||
{showNextEpisodeButton && nextEpisode && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 80 + insets.bottom,
|
||||
right: 8 + insets.right,
|
||||
opacity: nextEpisodeButtonOpacity,
|
||||
transform: [{ scale: nextEpisodeButtonScale }],
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
}}
|
||||
onPress={handlePlayNextEpisode}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLoadingNextEpisode ? (
|
||||
<ActivityIndicator size="small" color="#000000" style={{ marginRight: 8 }} />
|
||||
) : (
|
||||
<MaterialIcons name="skip-next" size={18} color="#000000" style={{ marginRight: 8 }} />
|
||||
)}
|
||||
<View>
|
||||
<Text style={{ color: '#000000', fontSize: 11, fontWeight: '700', opacity: 0.8 }}>
|
||||
{isLoadingNextEpisode ? 'Loading next episode…' : 'Up next'}
|
||||
</Text>
|
||||
<Text style={{ color: '#000000', fontSize: 13, fontWeight: '700' }} numberOfLines={1}>
|
||||
S{nextEpisode.season_number}E{nextEpisode.episode_number}
|
||||
{nextEpisode.name ? `: ${nextEpisode.name}` : ''}
|
||||
</Text>
|
||||
{isLoadingNextEpisode && (
|
||||
<Text style={{ color: '#333333', fontSize: 11, marginTop: 2 }} numberOfLines={1}>
|
||||
{nextLoadingProvider ? `${nextLoadingProvider}` : 'Finding source…'}
|
||||
{nextLoadingQuality ? ` • ${nextLoadingQuality}p` : ''}
|
||||
{nextLoadingTitle ? ` • ${nextLoadingTitle}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
{/* Next Episode Button (reusable) */}
|
||||
<UpNextButton
|
||||
type={type as any}
|
||||
nextEpisode={nextEpisode as any}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
insets={{ top: insets.top, right: insets.right, bottom: insets.bottom, left: insets.left }}
|
||||
isLoading={isLoadingNextEpisode}
|
||||
nextLoadingProvider={nextLoadingProvider}
|
||||
nextLoadingQuality={nextLoadingQuality}
|
||||
nextLoadingTitle={nextLoadingTitle}
|
||||
onPress={handlePlayNextEpisode}
|
||||
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
||||
controlsVisible={showControls}
|
||||
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
|
||||
/>
|
||||
|
||||
<CustomSubtitles
|
||||
key={customSubtitleVersion}
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ import { SpeedModal } from './modals/SpeedModal';
|
|||
import PlayerControls from './controls/PlayerControls';
|
||||
import CustomSubtitles from './subtitles/CustomSubtitles';
|
||||
import { SourcesModal } from './modals/SourcesModal';
|
||||
import UpNextButton from './common/UpNextButton';
|
||||
import { EpisodesModal } from './modals/EpisodesModal';
|
||||
import LoadingOverlay from './modals/LoadingOverlay';
|
||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||
|
|
@ -251,14 +252,11 @@ const KSPlayerCore: React.FC = () => {
|
|||
const metadataOpacity = useRef(new Animated.Value(1)).current;
|
||||
const metadataScale = useRef(new Animated.Value(1)).current;
|
||||
|
||||
// Next episode button state
|
||||
const [showNextEpisodeButton, setShowNextEpisodeButton] = useState(false);
|
||||
// Next episode loading state
|
||||
const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false);
|
||||
const [nextLoadingProvider, setNextLoadingProvider] = useState<string | null>(null);
|
||||
const [nextLoadingQuality, setNextLoadingQuality] = useState<string | null>(null);
|
||||
const [nextLoadingTitle, setNextLoadingTitle] = useState<string | null>(null);
|
||||
const nextEpisodeButtonOpacity = useRef(new Animated.Value(0)).current;
|
||||
const nextEpisodeButtonScale = useRef(new Animated.Value(0.8)).current;
|
||||
|
||||
// Cast display state
|
||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
||||
|
|
@ -410,11 +408,14 @@ const KSPlayerCore: React.FC = () => {
|
|||
}
|
||||
})();
|
||||
|
||||
// Find next episode for series
|
||||
// Find next episode for series (fallback to metadataGroupedEpisodes when needed)
|
||||
const nextEpisode = useMemo(() => {
|
||||
try {
|
||||
if (type !== 'series' || !season || !episode) return null;
|
||||
const allEpisodes = Object.values(groupedEpisodes || {}).flat() as any[];
|
||||
const sourceGroups = groupedEpisodes && Object.keys(groupedEpisodes || {}).length > 0
|
||||
? groupedEpisodes
|
||||
: (metadataGroupedEpisodes || {});
|
||||
const allEpisodes = Object.values(sourceGroups || {}).flat() as any[];
|
||||
if (!allEpisodes || allEpisodes.length === 0) return null;
|
||||
|
||||
// First try next episode in same season
|
||||
|
|
@ -429,11 +430,23 @@ const KSPlayerCore: React.FC = () => {
|
|||
);
|
||||
}
|
||||
|
||||
if (DEBUG_MODE) {
|
||||
logger.log('[KSPlayerCore] nextEpisode computation', {
|
||||
fromRouteGroups: !!(groupedEpisodes && Object.keys(groupedEpisodes || {}).length),
|
||||
fromMetadataGroups: !!(metadataGroupedEpisodes && Object.keys(metadataGroupedEpisodes || {}).length),
|
||||
allEpisodesCount: allEpisodes?.length || 0,
|
||||
currentSeason: season,
|
||||
currentEpisode: episode,
|
||||
found: !!nextEp,
|
||||
foundId: nextEp?.stremioId || nextEp?.id,
|
||||
foundName: nextEp?.name,
|
||||
});
|
||||
}
|
||||
return nextEp;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [type, season, episode, groupedEpisodes]);
|
||||
}, [type, season, episode, groupedEpisodes, metadataGroupedEpisodes]);
|
||||
|
||||
// Small offset (in seconds) used to avoid seeking to the *exact* end of the
|
||||
// file which triggers the `onEnd` callback and causes playback to restart.
|
||||
|
|
@ -2135,65 +2148,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
};
|
||||
}, [paused]);
|
||||
|
||||
// Handle next episode button visibility based on current time and next episode availability
|
||||
useEffect(() => {
|
||||
if (type !== 'series' || !nextEpisode || duration <= 0) {
|
||||
if (showNextEpisodeButton) {
|
||||
// Hide button with animation
|
||||
Animated.parallel([
|
||||
Animated.timing(nextEpisodeButtonOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(nextEpisodeButtonScale, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start(() => {
|
||||
setShowNextEpisodeButton(false);
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show button when 1 minute (60 seconds) remains
|
||||
const timeRemaining = duration - currentTime;
|
||||
const shouldShowButton = timeRemaining <= 60 && timeRemaining > 10; // Hide in last 10 seconds
|
||||
|
||||
if (shouldShowButton && !showNextEpisodeButton) {
|
||||
setShowNextEpisodeButton(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(nextEpisodeButtonOpacity, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(nextEpisodeButtonScale, {
|
||||
toValue: 1,
|
||||
tension: 100,
|
||||
friction: 8,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start();
|
||||
} else if (!shouldShowButton && showNextEpisodeButton) {
|
||||
Animated.parallel([
|
||||
Animated.timing(nextEpisodeButtonOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(nextEpisodeButtonScale, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
})
|
||||
]).start(() => {
|
||||
setShowNextEpisodeButton(false);
|
||||
});
|
||||
}
|
||||
}, [type, nextEpisode, duration, currentTime, showNextEpisodeButton]);
|
||||
// Up Next visibility handled inside reusable component
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
|
|
@ -3049,59 +3004,22 @@ const KSPlayerCore: React.FC = () => {
|
|||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Next Episode Button */}
|
||||
{showNextEpisodeButton && nextEpisode && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 80 + insets.bottom,
|
||||
right: 8 + insets.right,
|
||||
opacity: nextEpisodeButtonOpacity,
|
||||
transform: [{ scale: nextEpisodeButtonScale }],
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={{
|
||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: 18,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
}}
|
||||
onPress={handlePlayNextEpisode}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{isLoadingNextEpisode ? (
|
||||
<ActivityIndicator size="small" color="#000000" style={{ marginRight: 8 }} />
|
||||
) : (
|
||||
<MaterialIcons name="skip-next" size={18} color="#000000" style={{ marginRight: 8 }} />
|
||||
)}
|
||||
<View>
|
||||
<Text style={{ color: '#000000', fontSize: 11, fontWeight: '700', opacity: 0.8 }}>
|
||||
{isLoadingNextEpisode ? 'Loading next episode…' : 'Up next'}
|
||||
</Text>
|
||||
<Text style={{ color: '#000000', fontSize: 13, fontWeight: '700' }} numberOfLines={1}>
|
||||
S{nextEpisode.season_number}E{nextEpisode.episode_number}
|
||||
{nextEpisode.name ? `: ${nextEpisode.name}` : ''}
|
||||
</Text>
|
||||
{isLoadingNextEpisode && (
|
||||
<Text style={{ color: '#333333', fontSize: 11, marginTop: 2 }} numberOfLines={1}>
|
||||
{nextLoadingProvider ? `${nextLoadingProvider}` : 'Finding source…'}
|
||||
{nextLoadingQuality ? ` • ${nextLoadingQuality}p` : ''}
|
||||
{nextLoadingTitle ? ` • ${nextLoadingTitle}` : ''}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
)}
|
||||
{/* Next Episode Button (reusable) */}
|
||||
<UpNextButton
|
||||
type={type as any}
|
||||
nextEpisode={nextEpisode as any}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
insets={{ top: insets.top, right: insets.right, bottom: insets.bottom, left: insets.left }}
|
||||
isLoading={isLoadingNextEpisode}
|
||||
nextLoadingProvider={nextLoadingProvider}
|
||||
nextLoadingQuality={nextLoadingQuality}
|
||||
nextLoadingTitle={nextLoadingTitle}
|
||||
onPress={handlePlayNextEpisode}
|
||||
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
||||
controlsVisible={showControls}
|
||||
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 126 : 106}
|
||||
/>
|
||||
|
||||
<CustomSubtitles
|
||||
useCustomSubtitles={useCustomSubtitles}
|
||||
|
|
|
|||
217
src/components/player/common/UpNextButton.tsx
Normal file
217
src/components/player/common/UpNextButton.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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;
|
||||
|
||||
|
||||
Loading…
Reference in a new issue