mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-18 15:22:05 +00:00
feat(player): implement smart Up Next trigger based on IntroDB outro segments
This commit is contained in:
parent
b857256916
commit
275a75b61d
6 changed files with 136 additions and 58 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ export { usePlayerSetup } from './usePlayerSetup';
|
|||
// Content
|
||||
export { useNextEpisode } from './useNextEpisode';
|
||||
export { useWatchProgress } from './useWatchProgress';
|
||||
export { useSkipSegments } from './useSkipSegments';
|
||||
|
|
|
|||
75
src/components/player/hooks/useSkipSegments.ts
Normal file
75
src/components/player/hooks/useSkipSegments.ts
Normal 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
|
||||
};
|
||||
};
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue