Merge pull request #465 from paregi12/Introdb

feat: update IntroDB integration to support recap and outro segments
This commit is contained in:
Nayif 2026-02-12 11:52:49 +05:30 committed by GitHub
commit d3ed746975
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 310 additions and 112 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 { segments: skipIntervals, 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(() => {
@ -975,6 +986,7 @@ const AndroidVideoPlayer: React.FC = () => {
episode={episode}
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
skipIntervals={skipIntervals}
currentTime={playerState.currentTime}
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
controlsVisible={playerState.showControls}
@ -1002,6 +1014,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 { segments: skipIntervals, 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,
@ -945,6 +956,7 @@ const KSPlayerCore: React.FC = () => {
episode={episode}
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
skipIntervals={skipIntervals}
currentTime={currentTime}
onSkip={(endTime) => controls.seekToTime(endTime)}
controlsVisible={showControls}
@ -972,6 +984,7 @@ const KSPlayerCore: React.FC = () => {
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
controlsVisible={showControls}
controlsFixedOffset={126}
outroSegment={outroSegment}
/>
{/* Modals */}
@ -1102,4 +1115,4 @@ const KSPlayerCore: React.FC = () => {
);
};
export default KSPlayerCore;
export default KSPlayerCore;

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,20 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
const shouldShow = useMemo(() => {
if (!nextEpisode || duration <= 0) return false;
// 1. Determine if we have a valid ending outro (within last 5 mins)
const hasValidEndingOutro = outroSegment && (duration - outroSegment.endTime < 300);
if (hasValidEndingOutro) {
// If we have a valid outro, ONLY show after it finishes
// This prevents the 60s fallback from "jumping the gun"
return currentTime >= outroSegment.endTime;
}
// 2. Standard Fallback (only if no valid ending outro was found)
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,100 @@
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([]);
setIsLoading(false);
fetchedRef.current = false;
lastKeyRef.current = '';
return;
}
if (lastKeyRef.current === key && fetchedRef.current) {
return;
}
// Clear stale intervals while resolving a new episode/key.
if (lastKeyRef.current !== key) {
setSegments([]);
fetchedRef.current = false;
}
lastKeyRef.current = key;
setIsLoading(true);
let cancelled = false;
const fetchSegments = async () => {
try {
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId);
// Ignore stale responses from old requests.
if (cancelled || lastKeyRef.current !== key) return;
setSegments(intervals);
fetchedRef.current = true;
} catch (error) {
if (cancelled || lastKeyRef.current !== key) return;
logger.error('[useSkipSegments] Error fetching skip data:', error);
setSegments([]);
// Keep this key retryable on transient failures.
fetchedRef.current = false;
} finally {
if (cancelled || lastKeyRef.current !== key) return;
setIsLoading(false);
}
};
fetchSegments();
return () => {
cancelled = true;
};
}, [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
.filter(s => ['ed', 'outro', 'mixed-ed'].includes(s.type))
.reduce<SkipInterval | null>((latest, interval) => {
if (!latest || interval.endTime > latest.endTime) return interval;
return latest;
}, null);
return {
segments,
getActiveSegment,
outroSegment,
isLoading
};
};

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react';
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator } from 'react-native';
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator, ScrollView } from 'react-native';
import { Ionicons, MaterialIcons } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import Animated, {
@ -9,7 +9,7 @@ import Animated, {
SlideOutDown,
} from 'react-native-reanimated';
import { useSettings } from '../../../hooks/useSettings';
import { introService } from '../../../services/introService';
import { introService, SkipType } from '../../../services/introService';
import { toastService } from '../../../services/toastService';
interface SubmitIntroModalProps {
@ -67,6 +67,7 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
const [startTimeStr, setStartTimeStr] = useState('00:00');
const [endTimeStr, setEndTimeStr] = useState(formatSecondsToMMSS(currentTime));
const [segmentType, setSegmentType] = useState<SkipType>('intro');
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
@ -107,14 +108,15 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
season,
episode,
startSec,
endSec
endSec,
segmentType
);
if (success) {
toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Intro submitted successfully' }));
toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Segment submitted successfully' }));
onClose();
} else {
toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit intro' }));
toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit segment' }));
}
} catch (error) {
toastService.error('Error', 'An unexpected error occurred');
@ -123,9 +125,11 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
}
};
const startVal = parseTimeToSeconds(startTimeStr);
const endVal = parseTimeToSeconds(endTimeStr);
const durationSec = (startVal !== null && endVal !== null) ? endVal - startVal : 0;
const segmentTypes: { label: string; value: SkipType; icon: any }[] = [
{ label: 'Intro', value: 'intro', icon: 'play-circle-outline' },
{ label: 'Recap', value: 'recap', icon: 'replay' },
{ label: 'Outro', value: 'outro', icon: 'stop-circle' },
];
return (
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
@ -144,13 +148,42 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
style={[localStyles.modalContainer, { width: Math.min(width * 0.85, 380) }]}
>
<View style={localStyles.header}>
<Text style={localStyles.title}>Submit Intro Timestamp</Text>
<Text style={localStyles.title}>Submit Timestamps</Text>
<TouchableOpacity onPress={onClose} style={localStyles.closeButton}>
<Ionicons name="close" size={24} color="rgba(255,255,255,0.5)" />
</TouchableOpacity>
</View>
<View style={localStyles.content}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={localStyles.content}>
{/* Segment Type Selector */}
<View>
<Text style={localStyles.label}>Segment Type</Text>
<View style={localStyles.typeRow}>
{segmentTypes.map((type) => (
<TouchableOpacity
key={type.value}
onPress={() => setSegmentType(type.value)}
style={[
localStyles.typeButton,
segmentType === type.value && localStyles.typeButtonActive
]}
>
<MaterialIcons
name={type.icon}
size={18}
color={segmentType === type.value ? 'black' : 'rgba(255,255,255,0.6)'}
/>
<Text style={[
localStyles.typeButtonText,
segmentType === type.value && localStyles.typeButtonTextActive
]}>
{type.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Start Time Input */}
<View style={localStyles.inputRow}>
<View style={{ flex: 1 }}>
@ -214,7 +247,7 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
)}
</TouchableOpacity>
</View>
</View>
</ScrollView>
</Animated.View>
</View>
</View>
@ -239,6 +272,7 @@ const localStyles = StyleSheet.create({
shadowOpacity: 0.5,
shadowRadius: 15,
elevation: 20,
maxHeight: '80%',
},
header: {
flexDirection: 'row',
@ -257,6 +291,34 @@ const localStyles = StyleSheet.create({
content: {
gap: 20,
},
typeRow: {
flexDirection: 'row',
gap: 8,
},
typeButton: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 6,
backgroundColor: 'rgba(255,255,255,0.05)',
borderRadius: 12,
paddingVertical: 10,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
},
typeButtonActive: {
backgroundColor: 'white',
borderColor: 'white',
},
typeButtonText: {
color: 'rgba(255,255,255,0.6)',
fontSize: 13,
fontWeight: '600',
},
typeButtonTextActive: {
color: 'black',
},
inputRow: {
flexDirection: 'row',
alignItems: 'flex-end',
@ -295,22 +357,6 @@ const localStyles = StyleSheet.create({
fontSize: 13,
fontWeight: '600',
},
summaryBox: {
backgroundColor: 'rgba(255,255,255,0.03)',
borderRadius: 16,
padding: 16,
marginTop: 8,
},
summaryText: {
color: 'rgba(255,255,255,0.5)',
fontSize: 14,
marginBottom: 4,
},
hintText: {
color: 'rgba(255,255,255,0.3)',
fontSize: 12,
fontStyle: 'italic',
},
buttonRow: {
flexDirection: 'row',
gap: 12,
@ -345,3 +391,4 @@ const localStyles = StyleSheet.create({
fontWeight: '700',
},
});

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;
@ -22,6 +23,7 @@ interface SkipIntroButtonProps {
episode?: number;
malId?: string;
kitsuId?: string;
skipIntervals?: SkipInterval[] | null;
currentTime: number;
onSkip: (endTime: number) => void;
controlsVisible?: boolean;
@ -35,6 +37,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
episode,
malId,
kitsuId,
skipIntervals: externalSkipIntervals,
currentTime,
onSkip,
controlsVisible = false,
@ -46,16 +49,25 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
const skipIntroEnabled = settings.skipIntroEnabled;
const { segments: fetchedSkipIntervals } = useSkipSegments({
imdbId,
type,
season,
episode,
malId,
kitsuId,
// Allow parent components to provide pre-fetched intervals to avoid duplicate requests.
enabled: skipIntroEnabled && !externalSkipIntervals
});
const skipIntervals = externalSkipIntervals ?? fetchedSkipIntervals;
// 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 +75,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 +246,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 }

View file

@ -26,11 +26,21 @@ export interface IntroTimestamps {
imdb_id: string;
season: number;
episode: number;
start_sec: number;
end_sec: number;
start_ms: number;
end_ms: number;
confidence: number;
intro?: {
start_sec: number;
end_sec: number;
confidence: number;
};
recap?: {
start_sec: number;
end_sec: number;
confidence: number;
};
outro?: {
start_sec: number;
end_sec: number;
confidence: number;
};
}
async function getMalIdFromArm(imdbId: string): Promise<string | null> {
@ -154,7 +164,7 @@ async function fetchFromAniSkip(malId: string, episode: number): Promise<SkipInt
async function fetchFromIntroDb(imdbId: string, season: number, episode: number): Promise<SkipInterval[]> {
try {
const response = await axios.get<IntroTimestamps>(`${INTRODB_API_URL}/intro`, {
const response = await axios.get<IntroTimestamps>(`${INTRODB_API_URL}/segments`, {
params: {
imdb_id: imdbId,
season,
@ -163,26 +173,48 @@ async function fetchFromIntroDb(imdbId: string, season: number, episode: number)
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,
});
const intervals: SkipInterval[] = [];
return [{
startTime: response.data.start_sec,
endTime: response.data.end_sec,
type: 'intro',
provider: 'introdb'
}];
if (response.data.intro) {
intervals.push({
startTime: response.data.intro.start_sec,
endTime: response.data.intro.end_sec,
type: 'intro',
provider: 'introdb'
});
}
if (response.data.recap) {
intervals.push({
startTime: response.data.recap.start_sec,
endTime: response.data.recap.end_sec,
type: 'recap',
provider: 'introdb'
});
}
if (response.data.outro) {
intervals.push({
startTime: response.data.outro.start_sec,
endTime: response.data.outro.end_sec,
type: 'outro',
provider: 'introdb'
});
}
if (intervals.length > 0) {
logger.log(`[IntroService] Found ${intervals.length} segments for ${imdbId} S${season}E${episode}`);
}
return intervals;
} 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}`);
logger.log(`[IntroService] No segment data for ${imdbId} S${season}E${episode}`);
return [];
}
logger.error('[IntroService] Error fetching intro timestamps:', error?.message || error);
logger.error('[IntroService] Error fetching segments from IntroDB:', error?.message || error);
return [];
}
}
@ -230,7 +262,8 @@ export async function submitIntro(
season: number,
episode: number,
startTime: number, // in seconds
endTime: number // in seconds
endTime: number, // in seconds
segmentType: SkipType = 'intro'
): Promise<boolean> {
try {
if (!apiKey) {
@ -240,8 +273,12 @@ export async function submitIntro(
const response = await axios.post(`${INTRODB_API_URL}/submit`, {
imdb_id: imdbId,
segment_type: segmentType === 'op' ? 'intro' : (segmentType === 'ed' ? 'outro' : segmentType),
season,
episode,
start_sec: startTime,
end_sec: endTime,
// Keep start_ms/end_ms for backward compatibility if the server still expects it
start_ms: Math.round(startTime * 1000),
end_ms: Math.round(endTime * 1000),
}, {
@ -319,18 +356,24 @@ export async function getIntroTimestamps(
imdbId: string,
season: number,
episode: number
): Promise<IntroTimestamps | null> {
): Promise<any | null> {
const intervals = await fetchFromIntroDb(imdbId, season, episode);
if (intervals.length > 0) {
const intro = intervals.find(i => i.type === 'intro');
if (intro) {
return {
imdb_id: imdbId,
season,
episode,
start_sec: intervals[0].startTime,
end_sec: intervals[0].endTime,
start_ms: intervals[0].startTime * 1000,
end_ms: intervals[0].endTime * 1000,
confidence: 1.0
start_sec: intro.startTime,
end_sec: intro.endTime,
start_ms: intro.startTime * 1000,
end_ms: intro.endTime * 1000,
confidence: 1.0,
intro: {
start_sec: intro.startTime,
end_sec: intro.endTime,
confidence: 1.0
}
};
}
return null;