mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
316 lines
11 KiB
TypeScript
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;
|