diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 12a843e3..f6336425 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -11,7 +11,8 @@ import { usePlayerModals, useSpeedControl, useOpeningAnimation, - useWatchProgress + useWatchProgress, + useSkipSegments } from './hooks'; // Android-specific hooks @@ -222,6 +223,16 @@ const AndroidVideoPlayer: React.FC = () => { const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); + const { outroSegment } = useSkipSegments({ + imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), + type, + season, + episode, + malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, + kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + enabled: settings.skipIntroEnabled + }); + const fadeAnim = useRef(new Animated.Value(1)).current; useEffect(() => { @@ -1002,6 +1013,7 @@ const AndroidVideoPlayer: React.FC = () => { metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined} controlsVisible={playerState.showControls} controlsFixedOffset={100} + outroSegment={outroSegment} /> diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index b6ca3d47..dc794c94 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -36,7 +36,8 @@ import { usePlayerControls, usePlayerSetup, useWatchProgress, - useNextEpisode + useNextEpisode, + useSkipSegments } from './hooks'; // Platform-specific hooks @@ -209,6 +210,16 @@ const KSPlayerCore: React.FC = () => { episodeId }); + const { outroSegment } = useSkipSegments({ + imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), + type, + season, + episode, + malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, + kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + enabled: settings.skipIntroEnabled + }); + const controls = usePlayerControls({ playerRef: ksPlayerRef, paused, @@ -972,6 +983,7 @@ const KSPlayerCore: React.FC = () => { metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined} controlsVisible={showControls} controlsFixedOffset={126} + outroSegment={outroSegment} /> {/* Modals */} diff --git a/src/components/player/common/UpNextButton.tsx b/src/components/player/common/UpNextButton.tsx index 9958e0c6..235ca6a2 100644 --- a/src/components/player/common/UpNextButton.tsx +++ b/src/components/player/common/UpNextButton.tsx @@ -4,6 +4,7 @@ import { Animated } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { logger } from '../../../utils/logger'; import { LinearGradient } from 'expo-linear-gradient'; +import { SkipInterval } from '../../../services/introService'; export interface Insets { top: number; @@ -33,6 +34,7 @@ interface UpNextButtonProps { metadata?: { poster?: string; id?: string }; // Added metadata prop controlsVisible?: boolean; controlsFixedOffset?: number; + outroSegment?: SkipInterval | null; } const UpNextButton: React.FC = ({ @@ -49,6 +51,7 @@ const UpNextButton: React.FC = ({ metadata, controlsVisible = false, controlsFixedOffset = 100, + outroSegment, }) => { const [visible, setVisible] = useState(false); const opacity = useRef(new Animated.Value(0)).current; @@ -76,10 +79,21 @@ const UpNextButton: React.FC = ({ const shouldShow = useMemo(() => { if (!nextEpisode || duration <= 0) return false; + + // 1. Check for Outro-based trigger + if (outroSegment) { + const timeRemainingAtOutroEnd = duration - outroSegment.endTime; + // Only trigger if the outro ends within the last 5 minutes (300s) + // This prevents mid-episode "fake" outros from triggering it too early + if (timeRemainingAtOutroEnd < 300 && currentTime >= outroSegment.endTime) { + return true; + } + } + + // 2. Standard Fallback (60s remaining) 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]); + return timeRemaining < 61 && timeRemaining > 0; + }, [nextEpisode, duration, currentTime, outroSegment]); // Debug logging removed to reduce console noise // The state is computed in shouldShow useMemo above diff --git a/src/components/player/hooks/index.ts b/src/components/player/hooks/index.ts index 570d3de1..8041cb78 100644 --- a/src/components/player/hooks/index.ts +++ b/src/components/player/hooks/index.ts @@ -20,3 +20,4 @@ export { usePlayerSetup } from './usePlayerSetup'; // Content export { useNextEpisode } from './useNextEpisode'; export { useWatchProgress } from './useWatchProgress'; +export { useSkipSegments } from './useSkipSegments'; diff --git a/src/components/player/hooks/useSkipSegments.ts b/src/components/player/hooks/useSkipSegments.ts new file mode 100644 index 00000000..996fdf97 --- /dev/null +++ b/src/components/player/hooks/useSkipSegments.ts @@ -0,0 +1,75 @@ +import { useState, useEffect, useRef } from 'react'; +import { introService, SkipInterval } from '../../../services/introService'; +import { logger } from '../../../utils/logger'; + +interface UseSkipSegmentsProps { + imdbId?: string; + type?: string; + season?: number; + episode?: number; + malId?: string; + kitsuId?: string; + enabled: boolean; +} + +export const useSkipSegments = ({ + imdbId, + type, + season, + episode, + malId, + kitsuId, + enabled +}: UseSkipSegmentsProps) => { + const [segments, setSegments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const fetchedRef = useRef(false); + const lastKeyRef = useRef(''); + + useEffect(() => { + const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`; + + if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { + setSegments([]); + fetchedRef.current = false; + return; + } + + if (lastKeyRef.current === key && fetchedRef.current) { + return; + } + + lastKeyRef.current = key; + fetchedRef.current = true; + setIsLoading(true); + + const fetchSegments = async () => { + try { + const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId); + setSegments(intervals); + } catch (error) { + logger.error('[useSkipSegments] Error fetching skip data:', error); + setSegments([]); + } finally { + setIsLoading(false); + } + }; + + fetchSegments(); + }, [imdbId, type, season, episode, malId, kitsuId, enabled]); + + const getActiveSegment = (currentTime: number) => { + return segments.find( + s => currentTime >= s.startTime && currentTime < (s.endTime - 0.5) + ); + }; + + const outroSegment = segments.find(s => ['ed', 'outro', 'mixed-ed'].includes(s.type)); + + return { + segments, + getActiveSegment, + outroSegment, + isLoading + }; +}; diff --git a/src/components/player/overlays/SkipIntroButton.tsx b/src/components/player/overlays/SkipIntroButton.tsx index b5329c6a..476b4d08 100644 --- a/src/components/player/overlays/SkipIntroButton.tsx +++ b/src/components/player/overlays/SkipIntroButton.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Text, TouchableOpacity, StyleSheet, Platform } from 'react-native'; +import { Text, TouchableOpacity, StyleSheet, Platform, View } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, @@ -10,10 +10,11 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialIcons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; -import { introService, SkipInterval, SkipType } from '../../../services/introService'; +import { SkipInterval } from '../../../services/introService'; import { useTheme } from '../../../contexts/ThemeContext'; import { logger } from '../../../utils/logger'; import { useSettings } from '../../../hooks/useSettings'; +import { useSkipSegments } from '../hooks/useSkipSegments'; interface SkipIntroButtonProps { imdbId: string | undefined; @@ -46,16 +47,23 @@ export const SkipIntroButton: React.FC = ({ const skipIntroEnabled = settings.skipIntroEnabled; + const { segments: skipIntervals } = useSkipSegments({ + imdbId, + type, + season, + episode, + malId, + kitsuId, + enabled: skipIntroEnabled + }); + // State - const [skipIntervals, setSkipIntervals] = useState([]); const [currentInterval, setCurrentInterval] = useState(null); const [isVisible, setIsVisible] = useState(false); const [hasSkippedCurrent, setHasSkippedCurrent] = useState(false); const [autoHidden, setAutoHidden] = useState(false); // Refs - const fetchedRef = useRef(false); - const lastEpisodeRef = useRef(''); const autoHideTimerRef = useRef(null); // Animation values @@ -63,55 +71,11 @@ export const SkipIntroButton: React.FC = ({ const scale = useSharedValue(0.8); const translateY = useSharedValue(0); - // Fetch skip data when episode changes + // Reset skipped state when episode changes useEffect(() => { - const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`; - - if (!skipIntroEnabled) { - setSkipIntervals([]); - setCurrentInterval(null); - setIsVisible(false); - fetchedRef.current = false; - return; - } - - // Skip if not a series or missing required data (though MAL/Kitsu ID might be enough for some cases, usually need season/ep) - if (type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { - setSkipIntervals([]); - fetchedRef.current = false; - return; - } - - // Skip if already fetched for this episode - if (lastEpisodeRef.current === episodeKey && fetchedRef.current) { - return; - } - - lastEpisodeRef.current = episodeKey; - fetchedRef.current = true; setHasSkippedCurrent(false); setAutoHidden(false); - setSkipIntervals([]); - - const fetchSkipData = async () => { - logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`); - try { - const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId); - setSkipIntervals(intervals); - - if (intervals.length > 0) { - logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals); - } else { - logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`); - } - } catch (error) { - logger.error('[SkipIntroButton] Error fetching skip data:', error); - setSkipIntervals([]); - } - }; - - fetchSkipData(); - }, [imdbId, type, season, episode, malId, kitsuId, skipIntroEnabled]); + }, [imdbId, season, episode, malId, kitsuId]); // Determine active interval based on current playback position useEffect(() => { @@ -278,7 +242,7 @@ export const SkipIntroButton: React.FC = ({ style={styles.icon} /> {getButtonText()} -