diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 3c5c004..b64379c 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -33,6 +33,7 @@ import LoadingOverlay from './modals/LoadingOverlay'; import PlayerControls from './controls/PlayerControls'; import { AudioTrackModal } from './modals/AudioTrackModal'; import { SubtitleModals } from './modals/SubtitleModals'; +import { SubtitleSyncModal } from './modals/SubtitleSyncModal'; import SpeedModal from './modals/SpeedModal'; import { SourcesModal } from './modals/SourcesModal'; import { EpisodesModal } from './modals/EpisodesModal'; @@ -57,6 +58,7 @@ import { storageService } from '../../services/storageService'; import stremioService from '../../services/stremioService'; import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes'; import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils'; +import { useTheme } from '../../contexts/ThemeContext'; import axios from 'axios'; const DEBUG_MODE = false; @@ -65,6 +67,7 @@ const AndroidVideoPlayer: React.FC = () => { const navigation = useNavigation(); const route = useRoute>(); const insets = useSafeAreaInsets(); + const { currentTheme } = useTheme(); const { uri, title = 'Episode Name', season, episode, episodeTitle, quality, year, @@ -138,9 +141,31 @@ const AndroidVideoPlayer: React.FC = () => { const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2); const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0); + // Subtitle sync modal state + const [showSyncModal, setShowSyncModal] = useState(false); + // Track auto-selection ref to prevent duplicate selections const hasAutoSelectedTracks = useRef(false); + // Track previous video session to reset subtitle offset only when video actually changes + const previousVideoRef = useRef<{ uri?: string; episodeId?: string }>({}); + + // Reset subtitle offset when starting a new video session + useEffect(() => { + const currentVideo = { uri, episodeId }; + const previousVideo = previousVideoRef.current; + + // Only reset if this is actually a new video (uri or episodeId changed) + if (previousVideo.uri !== undefined && + (previousVideo.uri !== currentVideo.uri || previousVideo.episodeId !== currentVideo.episodeId)) { + setSubtitleOffsetSec(0); + } + + // Update the ref for next comparison + previousVideoRef.current = currentVideo; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uri, episodeId]); + const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) }); const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] }; const hasLogo = metadata && metadata.logo; @@ -987,6 +1012,18 @@ const AndroidVideoPlayer: React.FC = () => { subtitleOffsetSec={subtitleOffsetSec} setSubtitleOffsetSec={setSubtitleOffsetSec} selectedExternalSubtitleId={selectedExternalSubtitleId} + onOpenSyncModal={() => setShowSyncModal(true)} + /> + + {/* Visual Subtitle Sync Modal */} + setShowSyncModal(false)} + onConfirm={(offset) => setSubtitleOffsetSec(offset)} + currentOffset={subtitleOffsetSec} + currentTime={playerState.currentTime} + subtitles={customSubtitles} + primaryColor={currentTheme.colors.primary} /> { const { ksPlayerRef, seek } = useKSPlayer(); const customSubs = useCustomSubtitles(); const { settings } = useSettings(); + const { currentTheme } = useTheme(); + + // Subtitle sync modal state + const [showSyncModal, setShowSyncModal] = useState(false); // Track auto-selection refs to prevent duplicate selections const hasAutoSelectedTracks = useRef(false); + // Track previous video session to reset subtitle offset only when video actually changes + const previousVideoRef = useRef<{ uri?: string; episodeId?: string }>({}); + + // Reset subtitle offset when starting a new video session + useEffect(() => { + const currentVideo = { uri, episodeId }; + const previousVideo = previousVideoRef.current; + + // Only reset if this is actually a new video (uri or episodeId changed) + if (previousVideo.uri !== undefined && + (previousVideo.uri !== currentVideo.uri || previousVideo.episodeId !== currentVideo.episodeId)) { + customSubs.setSubtitleOffsetSec(0); + } + + // Update the ref for next comparison + previousVideoRef.current = currentVideo; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uri, episodeId]); + // Next Episode Hook const { nextEpisode, currentEpisodeDescription } = useNextEpisode({ type, @@ -878,6 +903,18 @@ const KSPlayerCore: React.FC = () => { handleSelectTextTrack(-1); }} selectedExternalSubtitleId={customSubs.selectedExternalSubtitleId} + onOpenSyncModal={() => setShowSyncModal(true)} + /> + + {/* Visual Subtitle Sync Modal */} + setShowSyncModal(false)} + onConfirm={(offset) => customSubs.setSubtitleOffsetSec(offset)} + currentOffset={customSubs.subtitleOffsetSec} + currentTime={currentTime} + subtitles={customSubs.customSubtitles} + primaryColor={currentTheme.colors.primary} /> void; selectedExternalSubtitleId?: string | null; // ID of currently selected external/addon subtitle + onOpenSyncModal?: () => void; // Callback to open the visual sync modal } const MorphingTab = ({ label, isSelected, onPress }: any) => { @@ -93,6 +94,7 @@ export const SubtitleModals: React.FC = ({ subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, subtitleOffsetSec, setSubtitleOffsetSec, setSubtitlesAutoSelect, selectedExternalSubtitleId, + onOpenSyncModal, }) => { const { width, height } = useWindowDimensions(); const isIos = Platform.OS === 'ios'; @@ -489,6 +491,29 @@ export const SubtitleModals: React.FC = ({ + {/* Visual Sync Button */} + {onOpenSyncModal && ( + { + setShowSubtitleModal(false); + setTimeout(() => onOpenSyncModal(), 100); + }} + style={{ + marginTop: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + padding: 12, + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.08)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.15)', + }} + > + + Visual Sync + + )} Nudge subtitles earlier (-) or later (+) to sync if needed. )} diff --git a/src/components/player/modals/SubtitleSyncModal.tsx b/src/components/player/modals/SubtitleSyncModal.tsx new file mode 100644 index 0000000..34603cc --- /dev/null +++ b/src/components/player/modals/SubtitleSyncModal.tsx @@ -0,0 +1,547 @@ +/** + * SubtitleSyncModal - Visual subtitle sync adjustment UI + * Two-sided layout: subtitles on left, controls on right + * Smooth animations for subtitle transitions (no spring) + */ +import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { + View, + Text, + TouchableOpacity, + StyleSheet, + useWindowDimensions, + StatusBar, + BackHandler, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import Slider from '@react-native-community/slider'; +import Animated, { + FadeIn, + FadeOut, + SlideInUp, + SlideOutUp, + SlideInDown, + SlideOutDown, + withTiming, + useAnimatedStyle, + useSharedValue, + interpolateColor, + Layout, +} from 'react-native-reanimated'; +import { SubtitleCue } from '../utils/playerTypes'; + +interface SubtitleSyncModalProps { + visible: boolean; + onClose: () => void; + onConfirm: (offset: number) => void; + currentOffset: number; + currentTime: number; + subtitles: SubtitleCue[]; + primaryColor?: string; +} + +// Safe haptic feedback +const triggerHaptic = async (style: 'light' | 'medium' = 'medium') => { + try { + const Haptics = require('expo-haptics'); + if (Haptics?.impactAsync) { + const feedbackStyle = style === 'light' + ? Haptics.ImpactFeedbackStyle.Light + : Haptics.ImpactFeedbackStyle.Medium; + await Haptics.impactAsync(feedbackStyle); + } + } catch (e) { } +}; + +// Get subtitles around the current time for display +const getVisibleSubtitles = ( + subtitles: SubtitleCue[], + currentTime: number, + offset: number, + count: number = 7 +): { cue: SubtitleCue; isCurrent: boolean; position: number }[] => { + if (!subtitles || subtitles.length === 0) return []; + + const adjustedTime = currentTime + offset; + + let currentIndex = subtitles.findIndex( + cue => adjustedTime >= cue.start && adjustedTime <= cue.end + ); + + if (currentIndex === -1) { + currentIndex = subtitles.findIndex(cue => cue.start > adjustedTime); + if (currentIndex === -1) currentIndex = subtitles.length - 1; + if (currentIndex > 0) currentIndex--; + } + + const halfCount = Math.floor(count / 2); + const startIndex = Math.max(0, currentIndex - halfCount); + const endIndex = Math.min(subtitles.length, startIndex + count); + + const visible: { cue: SubtitleCue; isCurrent: boolean; position: number }[] = []; + + for (let i = startIndex; i < endIndex; i++) { + const cue = subtitles[i]; + const isCurrent = adjustedTime >= cue.start && adjustedTime <= cue.end; + visible.push({ cue, isCurrent, position: i - currentIndex }); + } + + return visible; +}; + +// Animated subtitle row component +const SubtitleRow = React.memo(({ + cue, + isCurrent, + position, + primaryColor +}: { + cue: SubtitleCue; + isCurrent: boolean; + position: number; + primaryColor: string; +}) => { + const opacity = useSharedValue(0); + const scale = useSharedValue(isCurrent ? 1.1 : 0.95); + const colorWeight = useSharedValue(isCurrent ? 1 : 0); + + useEffect(() => { + opacity.value = withTiming( + isCurrent ? 1 : Math.max(0.3, 0.8 - Math.abs(position) * 0.2), + { duration: 200 } + ); + scale.value = withTiming(isCurrent ? 1.1 : 0.95, { duration: 200 }); + colorWeight.value = withTiming(isCurrent ? 1 : 0, { duration: 200 }); + }, [isCurrent, position]); + + const animatedStyle = useAnimatedStyle(() => ({ + opacity: opacity.value, + transform: [{ scale: scale.value }], + })); + + const animatedTextStyle = useAnimatedStyle(() => { + const textColor = interpolateColor( + colorWeight.value, + [0, 1], + ['rgba(255,255,255,0.6)', '#ffffff'] + ); + + const shadowColor = interpolateColor( + colorWeight.value, + [0, 1], + ['rgba(0,0,0,0)', primaryColor] + ); + + return { + color: textColor, + textShadowColor: shadowColor, + textShadowRadius: colorWeight.value * 10, + textShadowOffset: { width: 0, height: 0 }, + fontWeight: colorWeight.value > 0.5 ? '700' : '500', + }; + }); + + return ( + + + + {cue.text} + + + + ); +}); + +export const SubtitleSyncModal: React.FC = ({ + visible, + onClose, + onConfirm, + currentOffset, + currentTime, + subtitles, + primaryColor = '#007AFF', +}) => { + const insets = useSafeAreaInsets(); + const { width, height } = useWindowDimensions(); + const isLargeScreen = width >= 768; + const rightPanelWidth = isLargeScreen ? 400 : 320; + + const [tempOffset, setTempOffset] = useState(currentOffset); + const [zeroHapticTriggered, setZeroHapticTriggered] = useState(false); + + useEffect(() => { + if (visible) { + setTempOffset(currentOffset); + setZeroHapticTriggered(false); + } + }, [visible, currentOffset]); + + const visibleSubtitles = useMemo(() => { + return getVisibleSubtitles(subtitles, currentTime, tempOffset, isLargeScreen ? 9 : 7); + }, [subtitles, currentTime, tempOffset, isLargeScreen]); + + const handleSliderChange = useCallback((value: number) => { + const rounded = Math.round(value * 10) / 10; + setTempOffset(rounded); + + if (Math.abs(rounded) < 0.05 && !zeroHapticTriggered) { + triggerHaptic('medium'); + setZeroHapticTriggered(true); + } else if (Math.abs(rounded) >= 0.05) { + setZeroHapticTriggered(false); + } + }, [zeroHapticTriggered]); + + const handleReset = useCallback(() => { + triggerHaptic('medium'); + setTempOffset(0); + setZeroHapticTriggered(false); + }, []); + + const handleConfirm = useCallback(() => { + triggerHaptic('light'); + onConfirm(tempOffset); + onClose(); + }, [tempOffset, onConfirm, onClose]); + + const formatOffset = (value: number) => { + if (value === 0) return '0.0s'; + return `${value > 0 ? '+' : ''}${value.toFixed(1)}s`; + }; + + useEffect(() => { + const onBackPress = () => { + if (visible) { + onClose(); + return true; + } + return false; + }; + + const backHandler = BackHandler.addEventListener('hardwareBackPress', onBackPress); + return () => backHandler.remove(); + }, [visible, onClose]); + + if (!visible) return null; + + return ( + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.92)', + }, + content: { + flex: 1, + flexDirection: 'row', + }, + leftPanel: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 20, + }, + rightPanel: { + width: 320, + backgroundColor: 'rgba(255,255,255,0.05)', + paddingHorizontal: 24, + paddingVertical: 32, + justifyContent: 'space-between', + alignItems: 'center', + }, + subtitleArea: { + alignItems: 'center', + width: '100%', + }, + subtitleRow: { + marginVertical: 6, + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 10, + maxWidth: '100%', + }, + currentSubtitleRow: { + // Removed card styling + }, + subtitleText: { + color: 'rgba(255,255,255,0.6)', + fontSize: 18, + textAlign: 'center', + fontWeight: '500', + lineHeight: 26, + }, + // Large screen styles + noSubtitles: { + color: 'rgba(255,255,255,0.4)', + fontSize: 15, + textAlign: 'center', + }, + noSubtitlesLarge: { + fontSize: 20, + }, + offsetContainer: { + alignItems: 'center', + }, + offsetLabel: { + color: 'rgba(255,255,255,0.5)', + fontSize: 13, + marginBottom: 4, + }, + offsetLabelLarge: { + fontSize: 16, + marginBottom: 8, + }, + offsetValue: { + fontSize: 28, + fontWeight: '700', + }, + offsetValueLarge: { + fontSize: 42, + }, + sliderContainer: { + width: '100%', + marginVertical: 16, + }, + slider: { + flex: 1, + height: 40, + }, + sliderLabel: { + color: 'rgba(255,255,255,0.4)', + fontSize: 10, + textAlign: 'center', + }, + buttons: { + width: '100%', + gap: 10, + }, + cancelBtn: { + height: 44, + borderRadius: 10, + backgroundColor: 'rgba(255,255,255,0.1)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.15)', + alignItems: 'center', + justifyContent: 'center', + }, + cancelBtnText: { + color: 'rgba(255,255,255,0.8)', + fontSize: 14, + fontWeight: '600', + }, + okBtn: { + height: 44, + borderRadius: 10, + alignItems: 'center', + justifyContent: 'center', + }, + okBtnText: { + color: '#fff', + fontSize: 14, + fontWeight: '600', + }, + // Button variants for large screens + btnLarge: { + height: 56, + borderRadius: 12, + }, + btnTextLarge: { + fontSize: 18, + }, + sliderRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + sliderLabels: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingHorizontal: 34, // Align with slider track roughly + marginTop: 4, + }, + nudgeBtn: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'rgba(255,255,255,0.1)', + alignItems: 'center', + justifyContent: 'center', + }, + nudgeBtnLarge: { + width: 44, + height: 44, + borderRadius: 22, + }, + resetBtnContainer: { + width: '100%', + alignItems: 'flex-end', + marginBottom: 8, + }, + resetBtn: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 8, + backgroundColor: 'rgba(255,255,255,0.08)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.15)', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + resetBtnLarge: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 10, + }, + resetBtnText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + }, + resetBtnTextLarge: { + fontSize: 14, + }, +}); + +export default SubtitleSyncModal;