mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-10 20:10:54 +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 CustomSubtitles from './subtitles/CustomSubtitles';
|
||||||
import { SourcesModal } from './modals/SourcesModal';
|
import { SourcesModal } from './modals/SourcesModal';
|
||||||
import { EpisodesModal } from './modals/EpisodesModal';
|
import { EpisodesModal } from './modals/EpisodesModal';
|
||||||
|
import UpNextButton from './common/UpNextButton';
|
||||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||||
import VlcVideoPlayer, { VlcPlayerRef } from './VlcVideoPlayer';
|
import VlcVideoPlayer, { VlcPlayerRef } from './VlcVideoPlayer';
|
||||||
import { stremioService } from '../../services/stremioService';
|
import { stremioService } from '../../services/stremioService';
|
||||||
|
|
@ -611,16 +612,12 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
const metadataOpacity = useRef(new Animated.Value(1)).current;
|
const metadataOpacity = useRef(new Animated.Value(1)).current;
|
||||||
const metadataScale = useRef(new Animated.Value(1)).current;
|
const metadataScale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
// Next episode button state
|
// Next episode loading state
|
||||||
const [showNextEpisodeButton, setShowNextEpisodeButton] = useState(false);
|
|
||||||
const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false);
|
const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false);
|
||||||
const [nextLoadingProvider, setNextLoadingProvider] = useState<string | null>(null);
|
const [nextLoadingProvider, setNextLoadingProvider] = useState<string | null>(null);
|
||||||
const [nextLoadingQuality, setNextLoadingQuality] = useState<string | null>(null);
|
const [nextLoadingQuality, setNextLoadingQuality] = useState<string | null>(null);
|
||||||
const [nextLoadingTitle, setNextLoadingTitle] = 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
|
// Cast display state
|
||||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
||||||
const [showCastDetails, setShowCastDetails] = useState(false);
|
const [showCastDetails, setShowCastDetails] = useState(false);
|
||||||
|
|
@ -701,30 +698,43 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [type, groupedEpisodes, episodeId, season, episode]);
|
}, [type, groupedEpisodes, episodeId, season, episode]);
|
||||||
|
|
||||||
// Find next episode for series
|
// Find next episode for series (use groupedEpisodes or fallback to metadataGroupedEpisodes)
|
||||||
const nextEpisode = useMemo(() => {
|
const nextEpisode = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
if ((type as any) !== 'series' || !season || !episode) return null;
|
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;
|
if (!allEpisodes || allEpisodes.length === 0) return null;
|
||||||
|
|
||||||
// First try next episode in same season
|
// First try next episode in same season
|
||||||
let nextEp = allEpisodes.find((ep: any) =>
|
let nextEp = allEpisodes.find((ep: any) =>
|
||||||
ep.season_number === season && ep.episode_number === episode + 1
|
ep.season_number === season && ep.episode_number === episode + 1
|
||||||
);
|
);
|
||||||
|
|
||||||
// If not found, try first episode of next season
|
// If not found, try first episode of next season
|
||||||
if (!nextEp) {
|
if (!nextEp) {
|
||||||
nextEp = allEpisodes.find((ep: any) =>
|
nextEp = allEpisodes.find((ep: any) =>
|
||||||
ep.season_number === season + 1 && ep.episode_number === 1
|
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;
|
return nextEp;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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
|
// 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.
|
// file which triggers the `onEnd` callback and causes playback to restart.
|
||||||
|
|
@ -2744,65 +2754,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
};
|
};
|
||||||
}, [paused]);
|
}, [paused]);
|
||||||
|
|
||||||
// Handle next episode button visibility based on current time and next episode availability
|
// Up Next visibility handled inside reusable component
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMounted.current = true;
|
isMounted.current = true;
|
||||||
|
|
@ -3752,59 +3704,22 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Next Episode Button */}
|
{/* Next Episode Button (reusable) */}
|
||||||
{showNextEpisodeButton && nextEpisode && (
|
<UpNextButton
|
||||||
<Animated.View
|
type={type as any}
|
||||||
style={{
|
nextEpisode={nextEpisode as any}
|
||||||
position: 'absolute',
|
currentTime={currentTime}
|
||||||
bottom: 80 + insets.bottom,
|
duration={duration}
|
||||||
right: 8 + insets.right,
|
insets={{ top: insets.top, right: insets.right, bottom: insets.bottom, left: insets.left }}
|
||||||
opacity: nextEpisodeButtonOpacity,
|
isLoading={isLoadingNextEpisode}
|
||||||
transform: [{ scale: nextEpisodeButtonScale }],
|
nextLoadingProvider={nextLoadingProvider}
|
||||||
zIndex: 50,
|
nextLoadingQuality={nextLoadingQuality}
|
||||||
}}
|
nextLoadingTitle={nextLoadingTitle}
|
||||||
>
|
onPress={handlePlayNextEpisode}
|
||||||
<TouchableOpacity
|
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
||||||
style={{
|
controlsVisible={showControls}
|
||||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 120 : 100}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CustomSubtitles
|
<CustomSubtitles
|
||||||
key={customSubtitleVersion}
|
key={customSubtitleVersion}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import { SpeedModal } from './modals/SpeedModal';
|
||||||
import PlayerControls from './controls/PlayerControls';
|
import PlayerControls from './controls/PlayerControls';
|
||||||
import CustomSubtitles from './subtitles/CustomSubtitles';
|
import CustomSubtitles from './subtitles/CustomSubtitles';
|
||||||
import { SourcesModal } from './modals/SourcesModal';
|
import { SourcesModal } from './modals/SourcesModal';
|
||||||
|
import UpNextButton from './common/UpNextButton';
|
||||||
import { EpisodesModal } from './modals/EpisodesModal';
|
import { EpisodesModal } from './modals/EpisodesModal';
|
||||||
import LoadingOverlay from './modals/LoadingOverlay';
|
import LoadingOverlay from './modals/LoadingOverlay';
|
||||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||||
|
|
@ -251,14 +252,11 @@ const KSPlayerCore: React.FC = () => {
|
||||||
const metadataOpacity = useRef(new Animated.Value(1)).current;
|
const metadataOpacity = useRef(new Animated.Value(1)).current;
|
||||||
const metadataScale = useRef(new Animated.Value(1)).current;
|
const metadataScale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
// Next episode button state
|
// Next episode loading state
|
||||||
const [showNextEpisodeButton, setShowNextEpisodeButton] = useState(false);
|
|
||||||
const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false);
|
const [isLoadingNextEpisode, setIsLoadingNextEpisode] = useState(false);
|
||||||
const [nextLoadingProvider, setNextLoadingProvider] = useState<string | null>(null);
|
const [nextLoadingProvider, setNextLoadingProvider] = useState<string | null>(null);
|
||||||
const [nextLoadingQuality, setNextLoadingQuality] = useState<string | null>(null);
|
const [nextLoadingQuality, setNextLoadingQuality] = useState<string | null>(null);
|
||||||
const [nextLoadingTitle, setNextLoadingTitle] = 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
|
// Cast display state
|
||||||
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
|
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(() => {
|
const nextEpisode = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
if (type !== 'series' || !season || !episode) return null;
|
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;
|
if (!allEpisodes || allEpisodes.length === 0) return null;
|
||||||
|
|
||||||
// First try next episode in same season
|
// 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;
|
return nextEp;
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
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
|
// 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.
|
// file which triggers the `onEnd` callback and causes playback to restart.
|
||||||
|
|
@ -2135,65 +2148,7 @@ const KSPlayerCore: React.FC = () => {
|
||||||
};
|
};
|
||||||
}, [paused]);
|
}, [paused]);
|
||||||
|
|
||||||
// Handle next episode button visibility based on current time and next episode availability
|
// Up Next visibility handled inside reusable component
|
||||||
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]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMounted.current = true;
|
isMounted.current = true;
|
||||||
|
|
@ -3049,59 +3004,22 @@ const KSPlayerCore: React.FC = () => {
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Next Episode Button */}
|
{/* Next Episode Button (reusable) */}
|
||||||
{showNextEpisodeButton && nextEpisode && (
|
<UpNextButton
|
||||||
<Animated.View
|
type={type as any}
|
||||||
style={{
|
nextEpisode={nextEpisode as any}
|
||||||
position: 'absolute',
|
currentTime={currentTime}
|
||||||
bottom: 80 + insets.bottom,
|
duration={duration}
|
||||||
right: 8 + insets.right,
|
insets={{ top: insets.top, right: insets.right, bottom: insets.bottom, left: insets.left }}
|
||||||
opacity: nextEpisodeButtonOpacity,
|
isLoading={isLoadingNextEpisode}
|
||||||
transform: [{ scale: nextEpisodeButtonScale }],
|
nextLoadingProvider={nextLoadingProvider}
|
||||||
zIndex: 50,
|
nextLoadingQuality={nextLoadingQuality}
|
||||||
}}
|
nextLoadingTitle={nextLoadingTitle}
|
||||||
>
|
onPress={handlePlayNextEpisode}
|
||||||
<TouchableOpacity
|
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
||||||
style={{
|
controlsVisible={showControls}
|
||||||
backgroundColor: 'rgba(255,255,255,0.95)',
|
controlsFixedOffset={Math.min(Dimensions.get('window').width, Dimensions.get('window').height) >= 768 ? 126 : 106}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CustomSubtitles
|
<CustomSubtitles
|
||||||
useCustomSubtitles={useCustomSubtitles}
|
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