mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +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 { 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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
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