mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-10 20:10:54 +00:00
Added SkipIntro button
This commit is contained in:
parent
a89c7f5c5c
commit
1d9a3b645b
5 changed files with 360 additions and 5 deletions
|
|
@ -40,6 +40,8 @@ import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||||
import { ErrorModal } from './modals/ErrorModal';
|
import { ErrorModal } from './modals/ErrorModal';
|
||||||
import { CustomSubtitles } from './subtitles/CustomSubtitles';
|
import { CustomSubtitles } from './subtitles/CustomSubtitles';
|
||||||
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||||
|
import SkipIntroButton from './overlays/SkipIntroButton';
|
||||||
|
import UpNextButton from './common/UpNextButton';
|
||||||
|
|
||||||
// Android-specific components
|
// Android-specific components
|
||||||
import { VideoSurface } from './android/components/VideoSurface';
|
import { VideoSurface } from './android/components/VideoSurface';
|
||||||
|
|
@ -698,6 +700,41 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
episode={episode}
|
episode={episode}
|
||||||
shouldShow={playerState.isVideoLoaded && !playerState.showControls && !playerState.paused}
|
shouldShow={playerState.isVideoLoaded && !playerState.showControls && !playerState.paused}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Skip Intro Button - Shows during intro section of TV episodes */}
|
||||||
|
<SkipIntroButton
|
||||||
|
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
|
||||||
|
type={type || 'movie'}
|
||||||
|
season={season}
|
||||||
|
episode={episode}
|
||||||
|
currentTime={playerState.currentTime}
|
||||||
|
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
|
||||||
|
controlsVisible={playerState.showControls}
|
||||||
|
controlsFixedOffset={100}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Up Next Button - Shows near end of episodes */}
|
||||||
|
<UpNextButton
|
||||||
|
type={type || 'movie'}
|
||||||
|
nextEpisode={nextEpisodeHook.nextEpisode}
|
||||||
|
currentTime={playerState.currentTime}
|
||||||
|
duration={playerState.duration}
|
||||||
|
insets={insets}
|
||||||
|
isLoading={false}
|
||||||
|
nextLoadingProvider={null}
|
||||||
|
nextLoadingQuality={null}
|
||||||
|
nextLoadingTitle={null}
|
||||||
|
onPress={() => {
|
||||||
|
if (nextEpisodeHook.nextEpisode) {
|
||||||
|
logger.log(`[AndroidVideoPlayer] Opening streams for next episode: S${nextEpisodeHook.nextEpisode.season_number}E${nextEpisodeHook.nextEpisode.episode_number}`);
|
||||||
|
modals.setSelectedEpisodeForStreams(nextEpisodeHook.nextEpisode);
|
||||||
|
modals.setShowEpisodeStreamsModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined}
|
||||||
|
controlsVisible={playerState.showControls}
|
||||||
|
controlsFixedOffset={100}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<AudioTrackModal
|
<AudioTrackModal
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@ import { ErrorModal } from './modals/ErrorModal';
|
||||||
import CustomSubtitles from './subtitles/CustomSubtitles';
|
import CustomSubtitles from './subtitles/CustomSubtitles';
|
||||||
import ResumeOverlay from './modals/ResumeOverlay';
|
import ResumeOverlay from './modals/ResumeOverlay';
|
||||||
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||||
|
import SkipIntroButton from './overlays/SkipIntroButton';
|
||||||
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
|
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
|
||||||
|
|
||||||
// Platform-specific components
|
// Platform-specific components
|
||||||
import { KSPlayerSurface } from './ios/components/KSPlayerSurface';
|
import { KSPlayerSurface } from './ios/components/KSPlayerSurface';
|
||||||
|
|
||||||
// Shared Hooks
|
|
||||||
import {
|
import {
|
||||||
usePlayerState,
|
usePlayerState,
|
||||||
usePlayerModals,
|
usePlayerModals,
|
||||||
|
|
@ -33,7 +33,8 @@ import {
|
||||||
useCustomSubtitles,
|
useCustomSubtitles,
|
||||||
usePlayerControls,
|
usePlayerControls,
|
||||||
usePlayerSetup,
|
usePlayerSetup,
|
||||||
useWatchProgress
|
useWatchProgress,
|
||||||
|
useNextEpisode
|
||||||
} from './hooks';
|
} from './hooks';
|
||||||
|
|
||||||
// Platform-specific hooks
|
// Platform-specific hooks
|
||||||
|
|
@ -130,6 +131,15 @@ const KSPlayerCore: React.FC = () => {
|
||||||
const { ksPlayerRef, seek } = useKSPlayer();
|
const { ksPlayerRef, seek } = useKSPlayer();
|
||||||
const customSubs = useCustomSubtitles();
|
const customSubs = useCustomSubtitles();
|
||||||
|
|
||||||
|
// Next Episode Hook
|
||||||
|
const { nextEpisode, currentEpisodeDescription } = useNextEpisode({
|
||||||
|
type,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
groupedEpisodes: groupedEpisodes as any,
|
||||||
|
episodeId
|
||||||
|
});
|
||||||
|
|
||||||
const controls = usePlayerControls({
|
const controls = usePlayerControls({
|
||||||
playerRef: ksPlayerRef,
|
playerRef: ksPlayerRef,
|
||||||
paused,
|
paused,
|
||||||
|
|
@ -684,10 +694,22 @@ const KSPlayerCore: React.FC = () => {
|
||||||
shouldShow={isVideoLoaded && !showControls && !paused}
|
shouldShow={isVideoLoaded && !showControls && !paused}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Skip Intro Button - Shows during intro section of TV episodes */}
|
||||||
|
<SkipIntroButton
|
||||||
|
imdbId={imdbId || (id?.startsWith('tt') ? id : undefined)}
|
||||||
|
type={type}
|
||||||
|
season={season}
|
||||||
|
episode={episode}
|
||||||
|
currentTime={currentTime}
|
||||||
|
onSkip={(endTime) => controls.seekToTime(endTime)}
|
||||||
|
controlsVisible={showControls}
|
||||||
|
controlsFixedOffset={126}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Up Next Button */}
|
{/* Up Next Button */}
|
||||||
<UpNextButton
|
<UpNextButton
|
||||||
type={type}
|
type={type}
|
||||||
nextEpisode={null}
|
nextEpisode={nextEpisode}
|
||||||
currentTime={currentTime}
|
currentTime={currentTime}
|
||||||
duration={duration}
|
duration={duration}
|
||||||
insets={insets}
|
insets={insets}
|
||||||
|
|
@ -695,7 +717,13 @@ const KSPlayerCore: React.FC = () => {
|
||||||
nextLoadingProvider={null}
|
nextLoadingProvider={null}
|
||||||
nextLoadingQuality={null}
|
nextLoadingQuality={null}
|
||||||
nextLoadingTitle={null}
|
nextLoadingTitle={null}
|
||||||
onPress={() => { }}
|
onPress={() => {
|
||||||
|
if (nextEpisode) {
|
||||||
|
logger.log(`[KSPlayerCore] Opening streams for next episode: S${nextEpisode.season_number}E${nextEpisode.episode_number}`);
|
||||||
|
modals.setSelectedEpisodeForStreams(nextEpisode);
|
||||||
|
modals.setShowEpisodeStreamsModal(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
||||||
controlsVisible={showControls}
|
controlsVisible={showControls}
|
||||||
controlsFixedOffset={126}
|
controlsFixedOffset={126}
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
|
||||||
|
|
||||||
// Animate vertical offset based on controls visibility
|
// Animate vertical offset based on controls visibility
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = controlsVisible ? -Math.max(0, controlsFixedOffset - 8) : 0;
|
const target = controlsVisible ? -(controlsFixedOffset / 2) : 0;
|
||||||
Animated.timing(translateY, {
|
Animated.timing(translateY, {
|
||||||
toValue: target,
|
toValue: target,
|
||||||
duration: 220,
|
duration: 220,
|
||||||
|
|
|
||||||
223
src/components/player/overlays/SkipIntroButton.tsx
Normal file
223
src/components/player/overlays/SkipIntroButton.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import { Text, TouchableOpacity, StyleSheet, Platform } from 'react-native';
|
||||||
|
import Animated, {
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedStyle,
|
||||||
|
withTiming,
|
||||||
|
withSpring,
|
||||||
|
Easing,
|
||||||
|
} from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { introService, IntroTimestamps } from '../../../services/introService';
|
||||||
|
import { useTheme } from '../../../contexts/ThemeContext';
|
||||||
|
import { logger } from '../../../utils/logger';
|
||||||
|
|
||||||
|
interface SkipIntroButtonProps {
|
||||||
|
imdbId: string | undefined;
|
||||||
|
type: 'movie' | 'series' | string;
|
||||||
|
season?: number;
|
||||||
|
episode?: number;
|
||||||
|
currentTime: number;
|
||||||
|
onSkip: (endTime: number) => void;
|
||||||
|
controlsVisible?: boolean;
|
||||||
|
controlsFixedOffset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
||||||
|
imdbId,
|
||||||
|
type,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
currentTime,
|
||||||
|
onSkip,
|
||||||
|
controlsVisible = false,
|
||||||
|
controlsFixedOffset = 100,
|
||||||
|
}) => {
|
||||||
|
const { currentTheme } = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const [introData, setIntroData] = useState<IntroTimestamps | null>(null);
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [hasSkipped, setHasSkipped] = useState(false);
|
||||||
|
const fetchedRef = useRef(false);
|
||||||
|
const lastEpisodeRef = useRef<string>('');
|
||||||
|
|
||||||
|
// Animation values
|
||||||
|
const opacity = useSharedValue(0);
|
||||||
|
const scale = useSharedValue(0.8);
|
||||||
|
const translateY = useSharedValue(0);
|
||||||
|
|
||||||
|
// Fetch intro data when episode changes
|
||||||
|
useEffect(() => {
|
||||||
|
const episodeKey = `${imdbId}-${season}-${episode}`;
|
||||||
|
|
||||||
|
// Skip if not a series or missing required data
|
||||||
|
if (type !== 'series' || !imdbId || !season || !episode) {
|
||||||
|
logger.log(`[SkipIntroButton] Skipping fetch - type: ${type}, imdbId: ${imdbId}, season: ${season}, episode: ${episode}`);
|
||||||
|
setIntroData(null);
|
||||||
|
fetchedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if already fetched for this episode
|
||||||
|
if (lastEpisodeRef.current === episodeKey && fetchedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEpisodeRef.current = episodeKey;
|
||||||
|
fetchedRef.current = true;
|
||||||
|
setHasSkipped(false);
|
||||||
|
|
||||||
|
const fetchIntroData = async () => {
|
||||||
|
logger.log(`[SkipIntroButton] Fetching intro data for ${imdbId} S${season}E${episode}...`);
|
||||||
|
try {
|
||||||
|
const data = await introService.getIntroTimestamps(imdbId, season, episode);
|
||||||
|
setIntroData(data);
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
logger.log(`[SkipIntroButton] ✓ Found intro: ${data.start_sec}s - ${data.end_sec}s (confidence: ${data.confidence})`);
|
||||||
|
} else {
|
||||||
|
logger.log(`[SkipIntroButton] ✗ No intro data available for this episode`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[SkipIntroButton] Error fetching intro data:', error);
|
||||||
|
setIntroData(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchIntroData();
|
||||||
|
}, [imdbId, type, season, episode]);
|
||||||
|
|
||||||
|
// Determine if button should show based on current playback position
|
||||||
|
const shouldShowButton = useCallback(() => {
|
||||||
|
if (!introData || hasSkipped) return false;
|
||||||
|
// Show when within intro range, with a small buffer at the end
|
||||||
|
return currentTime >= introData.start_sec && currentTime < (introData.end_sec - 0.5);
|
||||||
|
}, [introData, currentTime, hasSkipped]);
|
||||||
|
|
||||||
|
// Handle visibility animations
|
||||||
|
useEffect(() => {
|
||||||
|
const shouldShow = shouldShowButton();
|
||||||
|
|
||||||
|
if (shouldShow && !isVisible) {
|
||||||
|
logger.log(`[SkipIntroButton] Showing button - currentTime: ${currentTime.toFixed(1)}s, intro: ${introData?.start_sec}s - ${introData?.end_sec}s`);
|
||||||
|
setIsVisible(true);
|
||||||
|
opacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.cubic) });
|
||||||
|
scale.value = withSpring(1, { damping: 15, stiffness: 150 });
|
||||||
|
} else if (!shouldShow && isVisible) {
|
||||||
|
logger.log(`[SkipIntroButton] Hiding button - currentTime: ${currentTime.toFixed(1)}s, hasSkipped: ${hasSkipped}`);
|
||||||
|
opacity.value = withTiming(0, { duration: 200 });
|
||||||
|
scale.value = withTiming(0.8, { duration: 200 });
|
||||||
|
// Delay hiding to allow animation to complete
|
||||||
|
setTimeout(() => setIsVisible(false), 250);
|
||||||
|
}
|
||||||
|
}, [shouldShowButton, isVisible]);
|
||||||
|
|
||||||
|
// Animate position based on controls visibility
|
||||||
|
useEffect(() => {
|
||||||
|
const target = controlsVisible ? -(controlsFixedOffset / 2) : 0;
|
||||||
|
translateY.value = withTiming(target, { duration: 220, easing: Easing.out(Easing.cubic) });
|
||||||
|
}, [controlsVisible, controlsFixedOffset]);
|
||||||
|
|
||||||
|
// Handle skip action
|
||||||
|
const handleSkip = useCallback(() => {
|
||||||
|
if (!introData) return;
|
||||||
|
|
||||||
|
logger.log(`[SkipIntroButton] User pressed Skip Intro - seeking to ${introData.end_sec}s (from ${currentTime.toFixed(1)}s)`);
|
||||||
|
setHasSkipped(true);
|
||||||
|
onSkip(introData.end_sec);
|
||||||
|
}, [introData, onSkip, currentTime]);
|
||||||
|
|
||||||
|
// Animated styles
|
||||||
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: opacity.value,
|
||||||
|
transform: [{ scale: scale.value }, { translateY: translateY.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Don't render if not visible or no intro data
|
||||||
|
if (!isVisible || !introData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
containerStyle,
|
||||||
|
{
|
||||||
|
bottom: 24 + insets.bottom,
|
||||||
|
left: (Platform.OS === 'android' ? 12 : 4) + insets.left,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
pointerEvents="box-none"
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.button}
|
||||||
|
onPress={handleSkip}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={60}
|
||||||
|
tint="dark"
|
||||||
|
style={styles.blurContainer}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="skip-next"
|
||||||
|
size={20}
|
||||||
|
color="#FFFFFF"
|
||||||
|
style={styles.icon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.text}>Skip Intro</Text>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.accentBar,
|
||||||
|
{ backgroundColor: currentTheme.colors.primary }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</BlurView>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: 55,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
blurContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 12,
|
||||||
|
paddingHorizontal: 18,
|
||||||
|
backgroundColor: 'rgba(30, 30, 30, 0.7)',
|
||||||
|
},
|
||||||
|
icon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
accentBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SkipIntroButton;
|
||||||
67
src/services/introService.ts
Normal file
67
src/services/introService.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
import axios from 'axios';
|
||||||
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IntroDB API service for fetching TV show intro timestamps
|
||||||
|
* API Documentation: https://api.introdb.app
|
||||||
|
*/
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.EXPO_PUBLIC_INTRODB_API_URL || 'https://api.introdb.app';
|
||||||
|
|
||||||
|
export interface IntroTimestamps {
|
||||||
|
imdb_id: string;
|
||||||
|
season: number;
|
||||||
|
episode: number;
|
||||||
|
start_sec: number;
|
||||||
|
end_sec: number;
|
||||||
|
start_ms: number;
|
||||||
|
end_ms: number;
|
||||||
|
confidence: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches intro timestamps for a TV show episode
|
||||||
|
* @param imdbId - IMDB ID of the show (e.g., tt0903747 for Breaking Bad)
|
||||||
|
* @param season - Season number (1-indexed)
|
||||||
|
* @param episode - Episode number (1-indexed)
|
||||||
|
* @returns Intro timestamps or null if not found
|
||||||
|
*/
|
||||||
|
export async function getIntroTimestamps(
|
||||||
|
imdbId: string,
|
||||||
|
season: number,
|
||||||
|
episode: number
|
||||||
|
): Promise<IntroTimestamps | null> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<IntroTimestamps>(`${API_BASE_URL}/intro`, {
|
||||||
|
params: {
|
||||||
|
imdb_id: imdbId,
|
||||||
|
season,
|
||||||
|
episode,
|
||||||
|
},
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log(`[IntroService] Found intro for ${imdbId} S${season}E${episode}:`, {
|
||||||
|
start: response.data.start_sec,
|
||||||
|
end: response.data.end_sec,
|
||||||
|
confidence: response.data.confidence,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
if (axios.isAxiosError(error) && error.response?.status === 404) {
|
||||||
|
// No intro data available for this episode - this is expected
|
||||||
|
logger.log(`[IntroService] No intro data for ${imdbId} S${season}E${episode}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error('[IntroService] Error fetching intro timestamps:', error?.message || error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const introService = {
|
||||||
|
getIntroTimestamps,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default introService;
|
||||||
Loading…
Reference in a new issue