diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index cf74a2c..ae06204 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -40,6 +40,8 @@ import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal'; import { ErrorModal } from './modals/ErrorModal'; import { CustomSubtitles } from './subtitles/CustomSubtitles'; import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; +import SkipIntroButton from './overlays/SkipIntroButton'; +import UpNextButton from './common/UpNextButton'; // Android-specific components import { VideoSurface } from './android/components/VideoSurface'; @@ -698,6 +700,41 @@ const AndroidVideoPlayer: React.FC = () => { episode={episode} shouldShow={playerState.isVideoLoaded && !playerState.showControls && !playerState.paused} /> + + {/* Skip Intro Button - Shows during intro section of TV episodes */} + controlsHook.seekToTime(endTime)} + controlsVisible={playerState.showControls} + controlsFixedOffset={100} + /> + + {/* Up Next Button - Shows near end of episodes */} + { + 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} + /> { const { ksPlayerRef, seek } = useKSPlayer(); const customSubs = useCustomSubtitles(); + // Next Episode Hook + const { nextEpisode, currentEpisodeDescription } = useNextEpisode({ + type, + season, + episode, + groupedEpisodes: groupedEpisodes as any, + episodeId + }); + const controls = usePlayerControls({ playerRef: ksPlayerRef, paused, @@ -684,10 +694,22 @@ const KSPlayerCore: React.FC = () => { shouldShow={isVideoLoaded && !showControls && !paused} /> + {/* Skip Intro Button - Shows during intro section of TV episodes */} + controls.seekToTime(endTime)} + controlsVisible={showControls} + controlsFixedOffset={126} + /> + {/* Up Next Button */} { nextLoadingProvider={null} nextLoadingQuality={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} controlsVisible={showControls} controlsFixedOffset={126} diff --git a/src/components/player/common/UpNextButton.tsx b/src/components/player/common/UpNextButton.tsx index 6141fec..7acc5ba 100644 --- a/src/components/player/common/UpNextButton.tsx +++ b/src/components/player/common/UpNextButton.tsx @@ -105,7 +105,7 @@ const UpNextButton: React.FC = ({ // Animate vertical offset based on controls visibility useEffect(() => { - const target = controlsVisible ? -Math.max(0, controlsFixedOffset - 8) : 0; + const target = controlsVisible ? -(controlsFixedOffset / 2) : 0; Animated.timing(translateY, { toValue: target, duration: 220, diff --git a/src/components/player/overlays/SkipIntroButton.tsx b/src/components/player/overlays/SkipIntroButton.tsx new file mode 100644 index 0000000..a3da6df --- /dev/null +++ b/src/components/player/overlays/SkipIntroButton.tsx @@ -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 = ({ + imdbId, + type, + season, + episode, + currentTime, + onSkip, + controlsVisible = false, + controlsFixedOffset = 100, +}) => { + const { currentTheme } = useTheme(); + const insets = useSafeAreaInsets(); + const [introData, setIntroData] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const [hasSkipped, setHasSkipped] = useState(false); + const fetchedRef = useRef(false); + const lastEpisodeRef = useRef(''); + + // 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 ( + + + + + Skip Intro + + + + + ); +}; + +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; diff --git a/src/services/introService.ts b/src/services/introService.ts new file mode 100644 index 0000000..9f03fd7 --- /dev/null +++ b/src/services/introService.ts @@ -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 { + try { + const response = await axios.get(`${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;