feat(player): implement smart Up Next trigger based on IntroDB outro segments

This commit is contained in:
paregi12 2026-02-09 10:21:43 +05:30
parent b857256916
commit 275a75b61d
6 changed files with 136 additions and 58 deletions

View file

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

View file

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

View file

@ -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<UpNextButtonProps> = ({
@ -49,6 +51,7 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
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<UpNextButtonProps> = ({
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

View file

@ -20,3 +20,4 @@ export { usePlayerSetup } from './usePlayerSetup';
// Content
export { useNextEpisode } from './useNextEpisode';
export { useWatchProgress } from './useWatchProgress';
export { useSkipSegments } from './useSkipSegments';

View file

@ -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<SkipInterval[]>([]);
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
};
};

View file

@ -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<SkipIntroButtonProps> = ({
const skipIntroEnabled = settings.skipIntroEnabled;
const { segments: skipIntervals } = useSkipSegments({
imdbId,
type,
season,
episode,
malId,
kitsuId,
enabled: skipIntroEnabled
});
// State
const [skipIntervals, setSkipIntervals] = useState<SkipInterval[]>([]);
const [currentInterval, setCurrentInterval] = useState<SkipInterval | null>(null);
const [isVisible, setIsVisible] = useState(false);
const [hasSkippedCurrent, setHasSkippedCurrent] = useState(false);
const [autoHidden, setAutoHidden] = useState(false);
// Refs
const fetchedRef = useRef(false);
const lastEpisodeRef = useRef<string>('');
const autoHideTimerRef = useRef<NodeJS.Timeout | null>(null);
// Animation values
@ -63,55 +71,11 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
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<SkipIntroButtonProps> = ({
style={styles.icon}
/>
<Text style={styles.text}>{getButtonText()}</Text>
<Animated.View
<View
style={[
styles.accentBar,
{ backgroundColor: currentTheme.colors.primary }
@ -329,4 +293,4 @@ const styles = StyleSheet.create({
},
});
export default SkipIntroButton;
export default SkipIntroButton;