mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-30 12:44:51 +00:00
Updated Subtitle Sync Modal
This commit is contained in:
parent
6906ad99b7
commit
3285ecbe04
4 changed files with 646 additions and 0 deletions
|
|
@ -33,6 +33,7 @@ import LoadingOverlay from './modals/LoadingOverlay';
|
||||||
import PlayerControls from './controls/PlayerControls';
|
import PlayerControls from './controls/PlayerControls';
|
||||||
import { AudioTrackModal } from './modals/AudioTrackModal';
|
import { AudioTrackModal } from './modals/AudioTrackModal';
|
||||||
import { SubtitleModals } from './modals/SubtitleModals';
|
import { SubtitleModals } from './modals/SubtitleModals';
|
||||||
|
import { SubtitleSyncModal } from './modals/SubtitleSyncModal';
|
||||||
import SpeedModal from './modals/SpeedModal';
|
import SpeedModal from './modals/SpeedModal';
|
||||||
import { SourcesModal } from './modals/SourcesModal';
|
import { SourcesModal } from './modals/SourcesModal';
|
||||||
import { EpisodesModal } from './modals/EpisodesModal';
|
import { EpisodesModal } from './modals/EpisodesModal';
|
||||||
|
|
@ -57,6 +58,7 @@ import { storageService } from '../../services/storageService';
|
||||||
import stremioService from '../../services/stremioService';
|
import stremioService from '../../services/stremioService';
|
||||||
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
|
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
|
||||||
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
||||||
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const DEBUG_MODE = false;
|
const DEBUG_MODE = false;
|
||||||
|
|
@ -65,6 +67,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const route = useRoute<RouteProp<RootStackParamList, 'PlayerAndroid'>>();
|
const route = useRoute<RouteProp<RootStackParamList, 'PlayerAndroid'>>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
uri, title = 'Episode Name', season, episode, episodeTitle, quality, year,
|
uri, title = 'Episode Name', season, episode, episodeTitle, quality, year,
|
||||||
|
|
@ -138,9 +141,31 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2);
|
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2);
|
||||||
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0);
|
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0);
|
||||||
|
|
||||||
|
// Subtitle sync modal state
|
||||||
|
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||||
|
|
||||||
// Track auto-selection ref to prevent duplicate selections
|
// Track auto-selection ref to prevent duplicate selections
|
||||||
const hasAutoSelectedTracks = useRef(false);
|
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 metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
|
||||||
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
|
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
|
||||||
const hasLogo = metadata && metadata.logo;
|
const hasLogo = metadata && metadata.logo;
|
||||||
|
|
@ -987,6 +1012,18 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
subtitleOffsetSec={subtitleOffsetSec}
|
subtitleOffsetSec={subtitleOffsetSec}
|
||||||
setSubtitleOffsetSec={setSubtitleOffsetSec}
|
setSubtitleOffsetSec={setSubtitleOffsetSec}
|
||||||
selectedExternalSubtitleId={selectedExternalSubtitleId}
|
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
|
<SourcesModal
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { PlayerControls } from './controls/PlayerControls';
|
||||||
import AudioTrackModal from './modals/AudioTrackModal';
|
import AudioTrackModal from './modals/AudioTrackModal';
|
||||||
import SpeedModal from './modals/SpeedModal';
|
import SpeedModal from './modals/SpeedModal';
|
||||||
import SubtitleModals from './modals/SubtitleModals';
|
import SubtitleModals from './modals/SubtitleModals';
|
||||||
|
import { SubtitleSyncModal } from './modals/SubtitleSyncModal';
|
||||||
import SourcesModal from './modals/SourcesModal';
|
import SourcesModal from './modals/SourcesModal';
|
||||||
import EpisodesModal from './modals/EpisodesModal';
|
import EpisodesModal from './modals/EpisodesModal';
|
||||||
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
import { EpisodeStreamsModal } from './modals/EpisodeStreamsModal';
|
||||||
|
|
@ -53,6 +54,7 @@ import { WyzieSubtitle } from './utils/playerTypes';
|
||||||
import { parseSRT } from './utils/subtitleParser';
|
import { parseSRT } from './utils/subtitleParser';
|
||||||
import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
||||||
import { useSettings } from '../../hooks/useSettings';
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
|
||||||
// Player route params interface
|
// Player route params interface
|
||||||
interface PlayerRouteParams {
|
interface PlayerRouteParams {
|
||||||
|
|
@ -133,10 +135,33 @@ const KSPlayerCore: React.FC = () => {
|
||||||
const { ksPlayerRef, seek } = useKSPlayer();
|
const { ksPlayerRef, seek } = useKSPlayer();
|
||||||
const customSubs = useCustomSubtitles();
|
const customSubs = useCustomSubtitles();
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
|
// Subtitle sync modal state
|
||||||
|
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||||
|
|
||||||
// Track auto-selection refs to prevent duplicate selections
|
// Track auto-selection refs to prevent duplicate selections
|
||||||
const hasAutoSelectedTracks = useRef(false);
|
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
|
// Next Episode Hook
|
||||||
const { nextEpisode, currentEpisodeDescription } = useNextEpisode({
|
const { nextEpisode, currentEpisodeDescription } = useNextEpisode({
|
||||||
type,
|
type,
|
||||||
|
|
@ -878,6 +903,18 @@ const KSPlayerCore: React.FC = () => {
|
||||||
handleSelectTextTrack(-1);
|
handleSelectTextTrack(-1);
|
||||||
}}
|
}}
|
||||||
selectedExternalSubtitleId={customSubs.selectedExternalSubtitleId}
|
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
|
<SourcesModal
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,7 @@ interface SubtitleModalsProps {
|
||||||
subtitleOffsetSec: number;
|
subtitleOffsetSec: number;
|
||||||
setSubtitleOffsetSec: (n: number) => void;
|
setSubtitleOffsetSec: (n: number) => void;
|
||||||
selectedExternalSubtitleId?: string | null; // ID of currently selected external/addon subtitle
|
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) => {
|
const MorphingTab = ({ label, isSelected, onPress }: any) => {
|
||||||
|
|
@ -93,6 +94,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
||||||
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, subtitleOffsetSec, setSubtitleOffsetSec,
|
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, subtitleOffsetSec, setSubtitleOffsetSec,
|
||||||
setSubtitlesAutoSelect,
|
setSubtitlesAutoSelect,
|
||||||
selectedExternalSubtitleId,
|
selectedExternalSubtitleId,
|
||||||
|
onOpenSyncModal,
|
||||||
}) => {
|
}) => {
|
||||||
const { width, height } = useWindowDimensions();
|
const { width, height } = useWindowDimensions();
|
||||||
const isIos = Platform.OS === 'ios';
|
const isIos = Platform.OS === 'ios';
|
||||||
|
|
@ -489,6 +491,29 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</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>
|
<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>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
547
src/components/player/modals/SubtitleSyncModal.tsx
Normal file
547
src/components/player/modals/SubtitleSyncModal.tsx
Normal 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;
|
||||||
Loading…
Reference in a new issue