upnext button UI changes

This commit is contained in:
tapframe 2025-10-30 23:26:41 +05:30
parent 33d13c74d3
commit 84b8cb7817
3 changed files with 292 additions and 242 deletions

View file

@ -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}

View file

@ -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}

View 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;