NuvioStreaming/src/components/player/overlays/SkipIntroButton.tsx

316 lines
11 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Text, TouchableOpacity, StyleSheet, Platform } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withSpring,
Easing,
} from 'react-native-reanimated';
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 { useTheme } from '../../../contexts/ThemeContext';
import { logger } from '../../../utils/logger';
interface SkipIntroButtonProps {
imdbId: string | undefined;
type: 'movie' | 'series' | string;
season?: number;
episode?: number;
malId?: string;
kitsuId?: string;
currentTime: number;
onSkip: (endTime: number) => void;
controlsVisible?: boolean;
controlsFixedOffset?: number;
}
export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
imdbId,
type,
season,
episode,
malId,
kitsuId,
currentTime,
onSkip,
controlsVisible = false,
controlsFixedOffset = 100,
}) => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
// 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
const opacity = useSharedValue(0);
const scale = useSharedValue(0.8);
const translateY = useSharedValue(0);
// Fetch skip data when episode changes
useEffect(() => {
const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`;
// 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]);
// Determine active interval based on current playback position
useEffect(() => {
if (skipIntervals.length === 0) {
setCurrentInterval(null);
return;
}
// Find an interval that contains the current time
const active = skipIntervals.find(
interval => currentTime >= interval.startTime && currentTime < (interval.endTime - 0.5)
);
if (active) {
// If we found a new active interval that is different from the previous one
if (!currentInterval ||
active.startTime !== currentInterval.startTime ||
active.type !== currentInterval.type) {
logger.log(`[SkipIntroButton] Entering interval: ${active.type} (${active.startTime}-${active.endTime})`);
setCurrentInterval(active);
setHasSkippedCurrent(false); // Reset skipped state for new interval
setAutoHidden(false); // Reset auto-hide for new interval
}
} else {
// No active interval
if (currentInterval) {
logger.log('[SkipIntroButton] Exiting interval');
setCurrentInterval(null);
}
}
}, [currentTime, skipIntervals]);
// Determine if button should show
const shouldShowButton = useCallback(() => {
if (!currentInterval || hasSkippedCurrent) return false;
// If auto-hidden, only show when controls are visible
if (autoHidden && !controlsVisible) return false;
return true;
}, [currentInterval, hasSkippedCurrent, autoHidden, controlsVisible]);
// Handle visibility animations
useEffect(() => {
const shouldShow = shouldShowButton();
if (shouldShow && !isVisible) {
setIsVisible(true);
opacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.cubic) });
scale.value = withSpring(1, { damping: 15, stiffness: 150 });
// Start 15-second auto-hide timer
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
autoHideTimerRef.current = setTimeout(() => {
if (!hasSkippedCurrent) {
setAutoHidden(true);
opacity.value = withTiming(0, { duration: 200 });
scale.value = withTiming(0.8, { duration: 200 });
setTimeout(() => setIsVisible(false), 250);
}
}, 15000);
} else if (!shouldShow && isVisible) {
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
opacity.value = withTiming(0, { duration: 200 });
scale.value = withTiming(0.8, { duration: 200 });
// Delay hiding to allow animation to complete
setTimeout(() => setIsVisible(false), 250);
}
}, [shouldShowButton, isVisible, hasSkippedCurrent]);
// Re-show when controls become visible (if still in interval and was auto-hidden)
useEffect(() => {
if (controlsVisible && autoHidden && currentInterval && !hasSkippedCurrent) {
setAutoHidden(false);
}
}, [controlsVisible, autoHidden, currentInterval, hasSkippedCurrent]);
// Cleanup timer on unmount
useEffect(() => {
return () => {
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
};
}, []);
// Animate position based on controls visibility
useEffect(() => {
// Android needs more offset to clear the slider
const androidOffset = controlsFixedOffset - 8;
const iosOffset = controlsFixedOffset / 2;
const target = controlsVisible ? -(Platform.OS === 'android' ? androidOffset : iosOffset) : 0;
translateY.value = withTiming(target, { duration: 220, easing: Easing.out(Easing.cubic) });
}, [controlsVisible, controlsFixedOffset]);
// Handle skip action
const handleSkip = useCallback(() => {
if (!currentInterval) return;
logger.log(`[SkipIntroButton] User pressed Skip - seeking to ${currentInterval.endTime}s`);
setHasSkippedCurrent(true);
onSkip(currentInterval.endTime);
}, [currentInterval, onSkip]);
// Get display text based on skip type
const getButtonText = () => {
if (!currentInterval) return 'Skip';
switch (currentInterval.type) {
case 'op':
case 'mixed-op':
case 'intro':
return 'Skip Intro';
case 'ed':
case 'mixed-ed':
case 'outro':
return 'Skip Ending';
case 'recap':
return 'Skip Recap';
default:
return 'Skip';
}
};
// Animated styles
const containerStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
transform: [{ scale: scale.value }, { translateY: translateY.value }],
}));
// Don't render if not visible (and animation complete)
if (!isVisible && opacity.value === 0) {
return null;
}
return (
<Animated.View
style={[
styles.container,
containerStyle,
{
bottom: 24 + insets.bottom,
left: (Platform.OS === 'android' ? 12 : 4) + insets.left,
},
]}
pointerEvents="box-none"
>
<TouchableOpacity
style={styles.button}
onPress={handleSkip}
activeOpacity={0.85}
>
<BlurView
intensity={60}
tint="dark"
style={styles.blurContainer}
>
<MaterialIcons
name="skip-next"
size={20}
color="#FFFFFF"
style={styles.icon}
/>
<Text style={styles.text}>{getButtonText()}</Text>
<Animated.View
style={[
styles.accentBar,
{ backgroundColor: currentTheme.colors.primary }
]}
/>
</BlurView>
</TouchableOpacity>
</Animated.View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
zIndex: 55,
},
button: {
borderRadius: 12,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
blurContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 18,
backgroundColor: 'rgba(30, 30, 30, 0.7)',
},
icon: {
marginRight: 8,
},
text: {
color: '#FFFFFF',
fontSize: 14,
fontWeight: '600',
letterSpacing: 0.3,
},
accentBar: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 2,
},
});
export default SkipIntroButton;