Updated Subtitle Sync Modal

This commit is contained in:
tapframe 2025-12-31 02:41:13 +05:30
parent 6906ad99b7
commit 3285ecbe04
4 changed files with 646 additions and 0 deletions

View file

@ -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<RouteProp<RootStackParamList, 'PlayerAndroid'>>();
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 */}
<SubtitleSyncModal
visible={showSyncModal}
onClose={() => setShowSyncModal(false)}
onConfirm={(offset) => setSubtitleOffsetSec(offset)}
currentOffset={subtitleOffsetSec}
currentTime={playerState.currentTime}
subtitles={customSubtitles}
primaryColor={currentTheme.colors.primary}
/>
<SourcesModal

View file

@ -11,6 +11,7 @@ import { PlayerControls } from './controls/PlayerControls';
import AudioTrackModal from './modals/AudioTrackModal';
import SpeedModal from './modals/SpeedModal';
import SubtitleModals from './modals/SubtitleModals';
import { SubtitleSyncModal } from './modals/SubtitleSyncModal';
import SourcesModal from './modals/SourcesModal';
import EpisodesModal from './modals/EpisodesModal';
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
@ -53,6 +54,7 @@ import { WyzieSubtitle } from './utils/playerTypes';
import { parseSRT } from './utils/subtitleParser';
import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
import { useSettings } from '../../hooks/useSettings';
import { useTheme } from '../../contexts/ThemeContext';
// Player route params interface
interface PlayerRouteParams {
@ -133,10 +135,33 @@ const KSPlayerCore: React.FC = () => {
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 */}
<SubtitleSyncModal
visible={showSyncModal}
onClose={() => setShowSyncModal(false)}
onConfirm={(offset) => customSubs.setSubtitleOffsetSec(offset)}
currentOffset={customSubs.subtitleOffsetSec}
currentTime={currentTime}
subtitles={customSubs.customSubtitles}
primaryColor={currentTheme.colors.primary}
/>
<SourcesModal

View file

@ -60,6 +60,7 @@ interface SubtitleModalsProps {
subtitleOffsetSec: number;
setSubtitleOffsetSec: (n: number) => 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<SubtitleModalsProps> = ({
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<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
</View>
{/* Visual Sync Button */}
{onOpenSyncModal && (
<TouchableOpacity
onPress={() => {
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)',
}}
>
<MaterialIcons name="sync" color="#fff" size={18} style={{ marginRight: 8 }} />
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 14 }}>Visual Sync</Text>
</TouchableOpacity>
)}
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text>
</View>
)}

View file

@ -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 (
<Animated.View
layout={Layout.duration(200)}
style={styles.subtitleRow}
>
<Animated.View style={animatedStyle}>
<Animated.Text
style={[
styles.subtitleText,
animatedTextStyle,
]}
numberOfLines={2}
>
{cue.text}
</Animated.Text>
</Animated.View>
</Animated.View>
);
});
export const SubtitleSyncModal: React.FC<SubtitleSyncModalProps> = ({
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 (
<Animated.View
style={[styles.container, StyleSheet.absoluteFill, { zIndex: 9999 }]}
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
>
<StatusBar hidden />
{/* Two-sided layout */}
<View style={[styles.content, { paddingLeft: insets.left, paddingRight: insets.right }]}>
{/* Left side - Subtitles */}
<View style={styles.leftPanel}>
<View style={styles.subtitleArea}>
{visibleSubtitles.length > 0 ? (
visibleSubtitles.map(({ cue, isCurrent, position }, index) => (
<SubtitleRow
key={`${cue.start}-${cue.text.substring(0, 20)}`}
cue={cue}
isCurrent={isCurrent}
position={position}
primaryColor={primaryColor}
/>
))
) : (
<Text style={[styles.noSubtitles, isLargeScreen && styles.noSubtitlesLarge]}>
No subtitles near current position
</Text>
)}
</View>
</View>
{/* Right side - Controls */}
<View style={[
styles.rightPanel,
{
width: rightPanelWidth,
paddingBottom: Math.max(insets.bottom, isLargeScreen ? 48 : 16),
paddingTop: isLargeScreen ? 48 : 32
}
]}>
{/* Offset display */}
<View style={styles.offsetContainer}>
<Text style={[styles.offsetLabel, isLargeScreen && styles.offsetLabelLarge]}>Delay</Text>
<Text style={[
styles.offsetValue,
{ color: primaryColor },
isLargeScreen && styles.offsetValueLarge
]}>
{formatOffset(tempOffset)}
</Text>
</View>
{/* Slider - horizontal with buttons */}
<View style={styles.sliderContainer}>
<View style={styles.sliderRow}>
<TouchableOpacity
onPress={() => handleSliderChange(tempOffset - 0.1)}
style={[styles.nudgeBtn, isLargeScreen && styles.nudgeBtnLarge]}
>
<MaterialIcons name="remove" size={isLargeScreen ? 28 : 20} color="#fff" />
</TouchableOpacity>
<Slider
style={styles.slider}
minimumValue={-10}
maximumValue={10}
step={0.1}
value={tempOffset}
onValueChange={handleSliderChange}
minimumTrackTintColor={primaryColor}
maximumTrackTintColor="rgba(255,255,255,0.25)"
thumbTintColor={primaryColor}
/>
<TouchableOpacity
onPress={() => handleSliderChange(tempOffset + 0.1)}
style={[styles.nudgeBtn, isLargeScreen && styles.nudgeBtnLarge]}
>
<MaterialIcons name="add" size={isLargeScreen ? 28 : 20} color="#fff" />
</TouchableOpacity>
</View>
<View style={styles.sliderLabels}>
<Text style={styles.sliderLabel}>-10s</Text>
<Text style={styles.sliderLabel}>+10s</Text>
</View>
</View>
{/* Reset Button */}
<View style={styles.resetBtnContainer}>
<TouchableOpacity
onPress={handleReset}
style={[
styles.resetBtn,
isLargeScreen && styles.resetBtnLarge
]}
activeOpacity={0.7}
>
<MaterialIcons name="refresh" size={isLargeScreen ? 16 : 14} color="#fff" style={{ marginRight: 4 }} />
<Text style={[styles.resetBtnText, isLargeScreen && styles.resetBtnTextLarge]}>Reset</Text>
</TouchableOpacity>
</View>
{/* Buttons - stacked vertically */}
<View style={[styles.buttons, isLargeScreen && { gap: 16 }]}>
<TouchableOpacity
style={[
styles.okBtn,
{ backgroundColor: primaryColor },
isLargeScreen && styles.btnLarge
]}
onPress={handleConfirm}
activeOpacity={0.7}
>
<Text style={[styles.okBtnText, isLargeScreen && styles.btnTextLarge]}>OK</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.cancelBtn,
isLargeScreen && styles.btnLarge
]}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={[styles.cancelBtnText, isLargeScreen && styles.btnTextLarge]}>CANCEL</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Animated.View>
);
};
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;