From 84b8cb781768ddc96d89e763eaf80ee2590ac5fc Mon Sep 17 00:00:00 2001 From: tapframe Date: Thu, 30 Oct 2025 23:26:41 +0530 Subject: [PATCH] upnext button UI changes --- src/components/player/AndroidVideoPlayer.tsx | 161 +++---------- src/components/player/KSPlayerCore.tsx | 156 +++---------- src/components/player/common/UpNextButton.tsx | 217 ++++++++++++++++++ 3 files changed, 292 insertions(+), 242 deletions(-) create mode 100644 src/components/player/common/UpNextButton.tsx diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 7743651..e24d2d7 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -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(null); const [nextLoadingQuality, setNextLoadingQuality] = useState(null); const [nextLoadingTitle, setNextLoadingTitle] = useState(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(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 = () => { )} - {/* Next Episode Button */} - {showNextEpisodeButton && nextEpisode && ( - - - {isLoadingNextEpisode ? ( - - ) : ( - - )} - - - {isLoadingNextEpisode ? 'Loading next episode…' : 'Up next'} - - - S{nextEpisode.season_number}E{nextEpisode.episode_number} - {nextEpisode.name ? `: ${nextEpisode.name}` : ''} - - {isLoadingNextEpisode && ( - - {nextLoadingProvider ? `${nextLoadingProvider}` : 'Finding source…'} - {nextLoadingQuality ? ` • ${nextLoadingQuality}p` : ''} - {nextLoadingTitle ? ` • ${nextLoadingTitle}` : ''} - - )} - - - - )} + {/* Next Episode Button (reusable) */} + = 768 ? 120 : 100} + /> { 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(null); const [nextLoadingQuality, setNextLoadingQuality] = useState(null); const [nextLoadingTitle, setNextLoadingTitle] = useState(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(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 = () => { )} - {/* Next Episode Button */} - {showNextEpisodeButton && nextEpisode && ( - - - {isLoadingNextEpisode ? ( - - ) : ( - - )} - - - {isLoadingNextEpisode ? 'Loading next episode…' : 'Up next'} - - - S{nextEpisode.season_number}E{nextEpisode.episode_number} - {nextEpisode.name ? `: ${nextEpisode.name}` : ''} - - {isLoadingNextEpisode && ( - - {nextLoadingProvider ? `${nextLoadingProvider}` : 'Finding source…'} - {nextLoadingQuality ? ` • ${nextLoadingQuality}p` : ''} - {nextLoadingTitle ? ` • ${nextLoadingTitle}` : ''} - - )} - - - - )} + {/* Next Episode Button (reusable) */} + = 768 ? 126 : 106} + /> void; + metadata?: { poster?: string; id?: string }; // Added metadata prop + controlsVisible?: boolean; + controlsFixedOffset?: number; +} + +const UpNextButton: React.FC = ({ + 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 ( + + + {/* Thumbnail fills card */} + {imageUri ? ( + + ) : ( + + + + )} + + {/* Bottom overlay text */} + + + {isLoading ? ( + + ) : ( + + )} + + {isLoading ? 'Loading next…' : 'Up next'} + + + + S{nextEpisode.season_number}E{nextEpisode.episode_number} + {nextEpisode.name ? `: ${nextEpisode.name}` : ''} + + {isLoading && ( + + {nextLoadingProvider ? `${nextLoadingProvider}` : 'Finding source…'} + {nextLoadingQuality ? ` • ${nextLoadingQuality}p` : ''} + {nextLoadingTitle ? ` • ${nextLoadingTitle}` : ''} + + )} + + + + ); +} + +export default UpNextButton; + +