mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-19 05:45:41 +00:00
Merge pull request #465 from paregi12/Introdb
feat: update IntroDB integration to support recap and outro segments
This commit is contained in:
commit
d3ed746975
8 changed files with 310 additions and 112 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 { 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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ export { usePlayerSetup } from './usePlayerSetup';
|
|||
// Content
|
||||
export { useNextEpisode } from './useNextEpisode';
|
||||
export { useWatchProgress } from './useWatchProgress';
|
||||
export { useSkipSegments } from './useSkipSegments';
|
||||
|
|
|
|||
100
src/components/player/hooks/useSkipSegments.ts
Normal file
100
src/components/player/hooks/useSkipSegments.ts
Normal 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
|
||||
};
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue