imrpoved subtitles UI

This commit is contained in:
tapframe 2025-08-08 17:55:23 +05:30
parent 51550316ec
commit dff3a66d7b
4 changed files with 637 additions and 392 deletions

View file

@ -149,8 +149,10 @@ const AndroidVideoPlayer: React.FC = () => {
const [customSubtitleVersion, setCustomSubtitleVersion] = useState<number>(0);
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(true);
// iOS seeking helpers
const iosWasPausedDuringSeekRef = useRef<boolean | null>(null);
const wasPlayingBeforeDragRef = useRef<boolean>(false);
// External subtitle customization
const [subtitleFontFamily, setSubtitleFontFamily] = useState<string | undefined>(undefined);
const [subtitleTextColor, setSubtitleTextColor] = useState<string>('#FFFFFF');
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState<number>(0.7);
const [subtitleTextShadow, setSubtitleTextShadow] = useState<boolean>(true);
@ -161,6 +163,7 @@ const AndroidVideoPlayer: React.FC = () => {
const [subtitleBottomOffset, setSubtitleBottomOffset] = useState<number>(20);
const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState<number>(0);
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState<number>(1.2);
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState<number>(0);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
@ -445,17 +448,23 @@ const AndroidVideoPlayer: React.FC = () => {
isSeeking.current = true;
setSeekTime(timeInSeconds);
if (Platform.OS === 'ios') {
iosWasPausedDuringSeekRef.current = paused;
if (!paused) setPaused(true);
}
// Clear seek state after seek with longer timeout
// Clear seek state handled in onSeek; keep a fallback timeout
setTimeout(() => {
if (isMounted.current) {
if (isMounted.current && isSeeking.current) {
setSeekTime(null);
isSeeking.current = false;
if (DEBUG_MODE) {
logger.log(`[AndroidVideoPlayer] Seek completed to ${timeInSeconds.toFixed(2)}s`);
if (DEBUG_MODE) logger.log('[AndroidVideoPlayer] Seek fallback timeout cleared seeking state');
if (Platform.OS === 'ios' && iosWasPausedDuringSeekRef.current === false) {
setPaused(false);
iosWasPausedDuringSeekRef.current = null;
}
}
}
}, 500);
}, 1200);
} else {
if (DEBUG_MODE) {
logger.error(`[AndroidVideoPlayer] Seek failed: videoRef=${!!videoRef.current}, duration=${duration}, seeking=${isSeeking.current}`);
@ -466,10 +475,40 @@ const AndroidVideoPlayer: React.FC = () => {
// Handle seeking when seekTime changes
useEffect(() => {
if (seekTime !== null && videoRef.current && duration > 0) {
videoRef.current.seek(seekTime);
// Use tolerance on iOS for more reliable seeks
if (Platform.OS === 'ios') {
try {
(videoRef.current as any).seek(seekTime, 1);
} catch {
videoRef.current.seek(seekTime);
}
} else {
videoRef.current.seek(seekTime);
}
}
}, [seekTime, duration]);
const onSeek = (data: any) => {
if (DEBUG_MODE) logger.log('[AndroidVideoPlayer] onSeek', data);
if (isMounted.current) {
setSeekTime(null);
isSeeking.current = false;
// Resume playback on iOS if we paused for seeking
if (Platform.OS === 'ios') {
const shouldResume = wasPlayingBeforeDragRef.current || iosWasPausedDuringSeekRef.current === false;
if (shouldResume) {
logger.log('[AndroidVideoPlayer] onSeek: resuming after drag/seek (iOS)');
setPaused(false);
} else {
logger.log('[AndroidVideoPlayer] onSeek: staying paused (iOS)');
}
// Reset flags
wasPlayingBeforeDragRef.current = false;
iosWasPausedDuringSeekRef.current = null;
}
}
};
// Slider callback functions for React Native Community Slider
const handleSliderValueChange = (value: number) => {
if (isDragging && duration > 0) {
@ -481,6 +520,12 @@ const AndroidVideoPlayer: React.FC = () => {
const handleSlidingStart = () => {
setIsDragging(true);
// On iOS, pause during drag for more reliable seeks
if (Platform.OS === 'ios') {
wasPlayingBeforeDragRef.current = !paused;
if (!paused) setPaused(true);
logger.log('[AndroidVideoPlayer] handleSlidingStart: pausing for iOS drag');
}
};
const handleSlidingComplete = (value: number) => {
@ -891,7 +936,7 @@ const AndroidVideoPlayer: React.FC = () => {
}
};
const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => {
const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => {
const targetImdbId = imdbIdParam || imdbId;
if (!targetImdbId) {
logger.error('[AndroidVideoPlayer] No IMDb ID available for subtitle search');
@ -926,7 +971,19 @@ const AndroidVideoPlayer: React.FC = () => {
}
return acc;
}, [] as WyzieSubtitle[]);
uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display));
// Sort with English languages first, then alphabetical
const isEnglish = (s: WyzieSubtitle) => {
const lang = (s.language || '').toLowerCase();
const disp = (s.display || '').toLowerCase();
return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english');
};
uniqueSubtitles.sort((a, b) => {
const aIsEn = isEnglish(a);
const bIsEn = isEnglish(b);
if (aIsEn && !bIsEn) return -1;
if (!aIsEn && bIsEn) return 1;
return (a.display || '').localeCompare(b.display || '');
});
setAvailableSubtitles(uniqueSubtitles);
if (autoSelectEnglish) {
const englishSubtitle = uniqueSubtitles.find(sub =>
@ -940,6 +997,7 @@ const AndroidVideoPlayer: React.FC = () => {
}
}
if (!autoSelectEnglish) {
// If no English found and not auto-selecting, still open the modal
setShowSubtitleLanguageModal(true);
}
} catch (error) {
@ -1017,7 +1075,8 @@ const AndroidVideoPlayer: React.FC = () => {
// Immediately set current subtitle based on currentTime to avoid waiting for next onProgress
try {
const cueNow = parsedCues.find(cue => currentTime >= cue.start && currentTime <= cue.end);
const adjustedTime = currentTime + (subtitleOffsetSec || 0);
const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
const textNow = cueNow ? cueNow.text : '';
setCurrentSubtitle(textNow);
logger.log('[AndroidVideoPlayer] currentSubtitle set immediately after apply (iOS)');
@ -1025,36 +1084,7 @@ const AndroidVideoPlayer: React.FC = () => {
logger.error('[AndroidVideoPlayer] Error setting immediate subtitle', e);
}
// Micro-seek nudge to force AVPlayer to refresh rendering
try {
if (videoRef.current && duration > 0) {
const wasPaused = paused;
const original = currentTime;
const forward = Math.min(original + 0.05, Math.max(duration - 0.1, 0));
logger.log('[AndroidVideoPlayer] Performing micro-seek nudge (iOS)', { original, forward });
if (wasPaused) {
setPaused(false);
}
// Give state a moment to apply before seeking
setTimeout(() => {
try {
videoRef.current?.seek(forward);
setTimeout(() => {
videoRef.current?.seek(original);
if (wasPaused) {
setPaused(true);
}
logger.log('[AndroidVideoPlayer] Micro-seek nudge complete (iOS)');
}, 150);
} catch (e) {
logger.warn('[AndroidVideoPlayer] Inner micro-seek failed (iOS)', e);
if (wasPaused) setPaused(true);
}
}, 50);
}
} catch(e) {
logger.warn('[AndroidVideoPlayer] Outer micro-seek failed (iOS)', e);
}
// Removed micro-seek nudge on iOS
} else {
// Android works immediately
setCustomSubtitles(parsedCues);
@ -1066,7 +1096,8 @@ const AndroidVideoPlayer: React.FC = () => {
setIsLoadingSubtitles(false);
logger.log('[AndroidVideoPlayer] (Android) isLoadingSubtitles -> false');
try {
const cueNow = parsedCues.find(cue => currentTime >= cue.start && currentTime <= cue.end);
const adjustedTime = currentTime + (subtitleOffsetSec || 0);
const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
const textNow = cueNow ? cueNow.text : '';
setCurrentSubtitle(textNow);
logger.log('[AndroidVideoPlayer] currentSubtitle set immediately after apply (Android)');
@ -1117,17 +1148,75 @@ const AndroidVideoPlayer: React.FC = () => {
}
return;
}
const adjustedTime = currentTime + (subtitleOffsetSec || 0);
const currentCue = customSubtitles.find(cue =>
currentTime >= cue.start && currentTime <= cue.end
adjustedTime >= cue.start && adjustedTime <= cue.end
);
const newSubtitle = currentCue ? currentCue.text : '';
setCurrentSubtitle(newSubtitle);
}, [currentTime, customSubtitles, useCustomSubtitles]);
}, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]);
useEffect(() => {
loadSubtitleSize();
}, []);
// Load global subtitle settings
useEffect(() => {
(async () => {
try {
const saved = await storageService.getSubtitleSettings();
if (saved) {
if (typeof saved.subtitleSize === 'number') setSubtitleSize(saved.subtitleSize);
if (typeof saved.subtitleBackground === 'boolean') setSubtitleBackground(saved.subtitleBackground);
if (typeof saved.subtitleTextColor === 'string') setSubtitleTextColor(saved.subtitleTextColor);
if (typeof saved.subtitleBgOpacity === 'number') setSubtitleBgOpacity(saved.subtitleBgOpacity);
if (typeof saved.subtitleTextShadow === 'boolean') setSubtitleTextShadow(saved.subtitleTextShadow);
if (typeof saved.subtitleOutline === 'boolean') setSubtitleOutline(saved.subtitleOutline);
if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor);
if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth);
if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right');
if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing);
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
}
} catch {}
})();
}, []);
// Persist global subtitle settings on change
useEffect(() => {
storageService.saveSubtitleSettings({
subtitleSize,
subtitleBackground,
subtitleTextColor,
subtitleBgOpacity,
subtitleTextShadow,
subtitleOutline,
subtitleOutlineColor,
subtitleOutlineWidth,
subtitleAlign,
subtitleBottomOffset,
subtitleLetterSpacing,
subtitleLineHeightMultiplier,
subtitleOffsetSec,
});
}, [
subtitleSize,
subtitleBackground,
subtitleTextColor,
subtitleBgOpacity,
subtitleTextShadow,
subtitleOutline,
subtitleOutlineColor,
subtitleOutlineWidth,
subtitleAlign,
subtitleBottomOffset,
subtitleLetterSpacing,
subtitleLineHeightMultiplier,
subtitleOffsetSec,
]);
const increaseSubtitleSize = () => {
const newSize = Math.min(subtitleSize + 2, 32);
saveSubtitleSize(newSize);
@ -1396,6 +1485,7 @@ const AndroidVideoPlayer: React.FC = () => {
logger.log('[AndroidVideoPlayer] onLoad fired', { duration: e?.duration });
onLoad(e);
}}
onSeek={onSeek}
onEnd={onEnd}
onError={(err) => {
logger.error('[AndroidVideoPlayer] onError', err);
@ -1462,7 +1552,6 @@ const AndroidVideoPlayer: React.FC = () => {
subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground}
zoomScale={zoomScale}
fontFamily={subtitleFontFamily}
textColor={subtitleTextColor}
backgroundOpacity={subtitleBgOpacity}
textShadow={subtitleTextShadow}
@ -1515,8 +1604,6 @@ const AndroidVideoPlayer: React.FC = () => {
increaseSubtitleSize={increaseSubtitleSize}
decreaseSubtitleSize={decreaseSubtitleSize}
toggleSubtitleBackground={toggleSubtitleBackground}
subtitleFontFamily={subtitleFontFamily}
setSubtitleFontFamily={setSubtitleFontFamily}
subtitleTextColor={subtitleTextColor}
setSubtitleTextColor={setSubtitleTextColor}
subtitleBgOpacity={subtitleBgOpacity}
@ -1537,6 +1624,8 @@ const AndroidVideoPlayer: React.FC = () => {
setSubtitleLetterSpacing={setSubtitleLetterSpacing}
subtitleLineHeightMultiplier={subtitleLineHeightMultiplier}
setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier}
subtitleOffsetSec={subtitleOffsetSec}
setSubtitleOffsetSec={setSubtitleOffsetSec}
/>
<SourcesModal

View file

@ -42,19 +42,23 @@ import { stremioService } from '../../services/stremioService';
const VideoPlayer: React.FC = () => {
const route = useRoute<RouteProp<RootStackParamList, 'Player'>>();
const { streamProvider, uri, headers } = route.params;
const { streamProvider, uri, headers, forceVlc } = route.params as any;
// Check if the stream is from Xprime
const isXprimeStream = streamProvider === 'xprime' || streamProvider === 'Xprime';
// Check if the file format is MKV
const isMkvFile = uri && (uri.toLowerCase().includes('.mkv') || uri.toLowerCase().includes('mkv'));
const lowerUri = (uri || '').toLowerCase();
const contentType = (headers && (headers['Content-Type'] || headers['content-type'])) || '';
const isMkvByHeader = typeof contentType === 'string' && contentType.includes('matroska');
const isMkvByPath = lowerUri.includes('.mkv') || /[?&]ext=mkv\b/.test(lowerUri) || /format=mkv\b/.test(lowerUri) || /container=mkv\b/.test(lowerUri);
const isMkvFile = Boolean(isMkvByHeader || isMkvByPath);
// Use AndroidVideoPlayer for:
// - Android devices
// - Xprime streams on any platform
// - Non-MKV files on iOS
if (Platform.OS === 'android' || isXprimeStream || (Platform.OS === 'ios' && !isMkvFile)) {
if (Platform.OS === 'android' || isXprimeStream || (Platform.OS === 'ios' && !isMkvFile && !forceVlc)) {
return <AndroidVideoPlayer />;
}
@ -161,7 +165,6 @@ const VideoPlayer: React.FC = () => {
const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE);
const [subtitleBackground, setSubtitleBackground] = useState<boolean>(true);
// External subtitle customization
const [subtitleFontFamily, setSubtitleFontFamily] = useState<string | undefined>(undefined);
const [subtitleTextColor, setSubtitleTextColor] = useState<string>('#FFFFFF');
const [subtitleBgOpacity, setSubtitleBgOpacity] = useState<number>(0.7);
const [subtitleTextShadow, setSubtitleTextShadow] = useState<boolean>(true);
@ -172,6 +175,7 @@ const VideoPlayer: React.FC = () => {
const [subtitleBottomOffset, setSubtitleBottomOffset] = useState<number>(20);
const [subtitleLetterSpacing, setSubtitleLetterSpacing] = useState<number>(0);
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState<number>(1.2);
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState<number>(0);
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
@ -907,7 +911,7 @@ const VideoPlayer: React.FC = () => {
}
};
const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => {
const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = true) => {
const targetImdbId = imdbIdParam || imdbId;
if (!targetImdbId) {
logger.error('[VideoPlayer] No IMDb ID available for subtitle search');
@ -942,7 +946,19 @@ const VideoPlayer: React.FC = () => {
}
return acc;
}, [] as WyzieSubtitle[]);
uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display));
// Sort with English languages first, then alphabetical
const isEnglish = (s: WyzieSubtitle) => {
const lang = (s.language || '').toLowerCase();
const disp = (s.display || '').toLowerCase();
return lang === 'en' || lang === 'eng' || /^en([-_]|$)/.test(lang) || disp.includes('english');
};
uniqueSubtitles.sort((a, b) => {
const aIsEn = isEnglish(a);
const bIsEn = isEnglish(b);
if (aIsEn && !bIsEn) return -1;
if (!aIsEn && bIsEn) return 1;
return (a.display || '').localeCompare(b.display || '');
});
setAvailableSubtitles(uniqueSubtitles);
if (autoSelectEnglish) {
const englishSubtitle = uniqueSubtitles.find(sub =>
@ -956,6 +972,7 @@ const VideoPlayer: React.FC = () => {
}
}
if (!autoSelectEnglish) {
// If no English found and not auto-selecting, still open the modal
setShowSubtitleLanguageModal(true);
}
} catch (error) {
@ -1023,7 +1040,8 @@ const VideoPlayer: React.FC = () => {
// Immediately set current subtitle text
try {
const cueNow = parsedCues.find(cue => currentTime >= cue.start && currentTime <= cue.end);
const adjustedTime = currentTime + (subtitleOffsetSec || 0);
const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
const textNow = cueNow ? cueNow.text : '';
setCurrentSubtitle(textNow);
logger.log('[VideoPlayer] currentSubtitle set immediately after apply');
@ -1031,33 +1049,7 @@ const VideoPlayer: React.FC = () => {
logger.error('[VideoPlayer] Error setting immediate subtitle', e);
}
// VLC micro-seek nudge
try {
if (vlcRef.current && duration > 0) {
const wasPaused = paused;
const original = currentTime;
const forward = Math.min(original + 0.05, Math.max(duration - 0.1, 0));
logger.log('[VideoPlayer] Performing micro-seek nudge', { original, forward });
if (wasPaused) setPaused(false);
setTimeout(() => {
try {
// @ts-ignore - VLCPlayer seek method
vlcRef.current?.seek(forward);
setTimeout(() => {
// @ts-ignore
vlcRef.current?.seek(original);
if (wasPaused) setPaused(true);
logger.log('[VideoPlayer] Micro-seek nudge complete');
}, 150);
} catch (e) {
logger.warn('[VideoPlayer] Micro-seek nudge failed', e);
if (wasPaused) setPaused(true);
}
}, 50);
}
} catch(e) {
logger.warn('[VideoPlayer] Outer micro-seek failed', e);
}
// Removed micro-seek nudge
} catch (error) {
logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error);
setIsLoadingSubtitles(false);
@ -1096,12 +1088,70 @@ const VideoPlayer: React.FC = () => {
}
return;
}
const adjustedTime = currentTime + (subtitleOffsetSec || 0);
const currentCue = customSubtitles.find(cue =>
currentTime >= cue.start && currentTime <= cue.end
adjustedTime >= cue.start && adjustedTime <= cue.end
);
const newSubtitle = currentCue ? currentCue.text : '';
setCurrentSubtitle(newSubtitle);
}, [currentTime, customSubtitles, useCustomSubtitles]);
}, [currentTime, customSubtitles, useCustomSubtitles, subtitleOffsetSec]);
// Load global subtitle settings
useEffect(() => {
(async () => {
try {
const saved = await storageService.getSubtitleSettings();
if (saved) {
if (typeof saved.subtitleSize === 'number') setSubtitleSize(saved.subtitleSize);
if (typeof saved.subtitleBackground === 'boolean') setSubtitleBackground(saved.subtitleBackground);
if (typeof saved.subtitleTextColor === 'string') setSubtitleTextColor(saved.subtitleTextColor);
if (typeof saved.subtitleBgOpacity === 'number') setSubtitleBgOpacity(saved.subtitleBgOpacity);
if (typeof saved.subtitleTextShadow === 'boolean') setSubtitleTextShadow(saved.subtitleTextShadow);
if (typeof saved.subtitleOutline === 'boolean') setSubtitleOutline(saved.subtitleOutline);
if (typeof saved.subtitleOutlineColor === 'string') setSubtitleOutlineColor(saved.subtitleOutlineColor);
if (typeof saved.subtitleOutlineWidth === 'number') setSubtitleOutlineWidth(saved.subtitleOutlineWidth);
if (typeof saved.subtitleAlign === 'string') setSubtitleAlign(saved.subtitleAlign as 'center' | 'left' | 'right');
if (typeof saved.subtitleBottomOffset === 'number') setSubtitleBottomOffset(saved.subtitleBottomOffset);
if (typeof saved.subtitleLetterSpacing === 'number') setSubtitleLetterSpacing(saved.subtitleLetterSpacing);
if (typeof saved.subtitleLineHeightMultiplier === 'number') setSubtitleLineHeightMultiplier(saved.subtitleLineHeightMultiplier);
if (typeof saved.subtitleOffsetSec === 'number') setSubtitleOffsetSec(saved.subtitleOffsetSec);
}
} catch {}
})();
}, []);
// Persist global subtitle settings on change
useEffect(() => {
storageService.saveSubtitleSettings({
subtitleSize,
subtitleBackground,
subtitleTextColor,
subtitleBgOpacity,
subtitleTextShadow,
subtitleOutline,
subtitleOutlineColor,
subtitleOutlineWidth,
subtitleAlign,
subtitleBottomOffset,
subtitleLetterSpacing,
subtitleLineHeightMultiplier,
subtitleOffsetSec,
});
}, [
subtitleSize,
subtitleBackground,
subtitleTextColor,
subtitleBgOpacity,
subtitleTextShadow,
subtitleOutline,
subtitleOutlineColor,
subtitleOutlineWidth,
subtitleAlign,
subtitleBottomOffset,
subtitleLetterSpacing,
subtitleLineHeightMultiplier,
subtitleOffsetSec,
]);
useEffect(() => {
loadSubtitleSize();
@ -1434,7 +1484,6 @@ const VideoPlayer: React.FC = () => {
subtitleSize={subtitleSize}
subtitleBackground={subtitleBackground}
zoomScale={zoomScale}
fontFamily={subtitleFontFamily}
textColor={subtitleTextColor}
backgroundOpacity={subtitleBgOpacity}
textShadow={subtitleTextShadow}
@ -1487,8 +1536,6 @@ const VideoPlayer: React.FC = () => {
increaseSubtitleSize={increaseSubtitleSize}
decreaseSubtitleSize={decreaseSubtitleSize}
toggleSubtitleBackground={toggleSubtitleBackground}
subtitleFontFamily={subtitleFontFamily}
setSubtitleFontFamily={setSubtitleFontFamily}
subtitleTextColor={subtitleTextColor}
setSubtitleTextColor={setSubtitleTextColor}
subtitleBgOpacity={subtitleBgOpacity}
@ -1509,6 +1556,8 @@ const VideoPlayer: React.FC = () => {
setSubtitleLetterSpacing={setSubtitleLetterSpacing}
subtitleLineHeightMultiplier={subtitleLineHeightMultiplier}
setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier}
subtitleOffsetSec={subtitleOffsetSec}
setSubtitleOffsetSec={setSubtitleOffsetSec}
/>
<SourcesModal

View file

@ -32,8 +32,6 @@ interface SubtitleModalsProps {
decreaseSubtitleSize: () => void;
toggleSubtitleBackground: () => void;
// Customization props
subtitleFontFamily?: string;
setSubtitleFontFamily: (f?: string) => void;
subtitleTextColor: string;
setSubtitleTextColor: (c: string) => void;
subtitleBgOpacity: number;
@ -54,6 +52,8 @@ interface SubtitleModalsProps {
setSubtitleLetterSpacing: (n: number) => void;
subtitleLineHeightMultiplier: number;
setSubtitleLineHeightMultiplier: (n: number) => void;
subtitleOffsetSec: number;
setSubtitleOffsetSec: (n: number) => void;
}
const { width, height } = Dimensions.get('window');
@ -79,8 +79,6 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
increaseSubtitleSize,
decreaseSubtitleSize,
toggleSubtitleBackground,
subtitleFontFamily,
setSubtitleFontFamily,
subtitleTextColor,
setSubtitleTextColor,
subtitleBgOpacity,
@ -101,12 +99,23 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
setSubtitleLetterSpacing,
subtitleLineHeightMultiplier,
setSubtitleLineHeightMultiplier,
subtitleOffsetSec,
setSubtitleOffsetSec,
}) => {
// Track which specific online subtitle is currently loaded
const [selectedOnlineSubtitleId, setSelectedOnlineSubtitleId] = React.useState<string | null>(null);
// Track which online subtitle is currently loading to show spinner per-item
const [loadingSubtitleId, setLoadingSubtitleId] = React.useState<string | null>(null);
// Active tab for better organization
const [activeTab, setActiveTab] = React.useState<'built-in' | 'online' | 'appearance'>(useCustomSubtitles ? 'online' : 'built-in');
// Responsive tuning
const isCompact = width < 360 || height < 640;
const sectionPad = isCompact ? 12 : 16;
const chipPadH = isCompact ? 8 : 12;
const chipPadV = isCompact ? 6 : 8;
const controlBtn = { size: isCompact ? 28 : 32, radius: isCompact ? 14 : 16 };
const previewHeight = isCompact ? 90 : 120;
React.useEffect(() => {
if (showSubtitleModal && !isLoadingSubtitleList && availableSubtitles.length === 0) {
fetchAvailableSubtitles();
@ -127,7 +136,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}
}, [isLoadingSubtitles]);
// Only OpenSubtitles are provided now; render as a single list
// Keep tab in sync with current usage
React.useEffect(() => {
setActiveTab(useCustomSubtitles ? 'online' : 'built-in');
}, [useCustomSubtitles]);
const handleClose = () => {
setShowSubtitleModal(false);
@ -194,17 +206,18 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
justifyContent: 'space-between',
paddingHorizontal: 20,
paddingTop: 60,
paddingBottom: 20,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
}}>
<Text style={{
color: '#FFFFFF',
fontSize: 22,
fontWeight: '700',
}}>
Subtitles
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 10 }}>
<Text style={{ color: '#FFFFFF', fontSize: 22, fontWeight: '700' }}>Subtitles</Text>
<View style={{ paddingHorizontal: 10, paddingVertical: 4, borderRadius: 12, backgroundColor: useCustomSubtitles ? 'rgba(34,197,94,0.2)' : 'rgba(59,130,246,0.2)' }}>
<Text style={{ color: useCustomSubtitles ? '#22C55E' : '#3B82F6', fontSize: 11, fontWeight: '700' }}>
{useCustomSubtitles ? 'Online in use' : 'Builtin in use'}
</Text>
</View>
</View>
<TouchableOpacity
style={{
width: 36,
@ -221,239 +234,36 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
{/* Segmented Tabs */}
<View style={{ flexDirection: 'row', gap: 8, paddingHorizontal: 20, paddingTop: 10, paddingBottom: 6 }}>
{([
{ key: 'built-in', label: 'Builtin' },
{ key: 'online', label: 'Online' },
{ key: 'appearance', label: 'Appearance' },
] as const).map(tab => (
<TouchableOpacity
key={tab.key}
onPress={() => setActiveTab(tab.key)}
style={{
paddingHorizontal: chipPadH,
paddingVertical: chipPadV,
borderRadius: 16,
backgroundColor: activeTab === tab.key ? 'rgba(255,255,255,0.15)' : 'rgba(255,255,255,0.06)',
borderWidth: 1,
borderColor: activeTab === tab.key ? 'rgba(255,255,255,0.3)' : 'rgba(255,255,255,0.1)'
}}
>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 13 }}>{tab.label}</Text>
</TouchableOpacity>
))}
</View>
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20, paddingBottom: 40 }}
contentContainerStyle={{ padding: 20, paddingBottom: isCompact ? 24 : 40 }}
showsVerticalScrollIndicator={false}
>
{/* Font Size Section - Only show for custom subtitles */}
{useCustomSubtitles && (
<View style={{ marginBottom: 30 }}>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
fontWeight: '600',
marginBottom: 15,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}>
Font Size
</Text>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
}}>
<TouchableOpacity
style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={decreaseSubtitleSize}
>
<MaterialIcons name="remove" size={20} color="#FFFFFF" />
</TouchableOpacity>
<Text style={{
color: '#FFFFFF',
fontSize: 18,
fontWeight: '600',
}}>
{subtitleSize}
</Text>
<TouchableOpacity
style={{
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={increaseSubtitleSize}
>
<MaterialIcons name="add" size={20} color="#FFFFFF" />
</TouchableOpacity>
</View>
</View>
)}
{/* Background Toggle Section - Only show for custom subtitles */}
{useCustomSubtitles && (
<View style={{ marginBottom: 30 }}>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
fontWeight: '600',
marginBottom: 15,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}>
Background
</Text>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
}}>
<Text style={{
color: 'white',
fontSize: 16,
fontWeight: '500',
}}>
Show Background
</Text>
<TouchableOpacity
style={{
width: 50,
height: 28,
backgroundColor: subtitleBackground ? '#007AFF' : 'rgba(255, 255, 255, 0.2)',
borderRadius: 14,
justifyContent: 'center',
alignItems: subtitleBackground ? 'flex-end' : 'flex-start',
paddingHorizontal: 2,
}}
onPress={toggleSubtitleBackground}
>
<View style={{
width: 24,
height: 24,
backgroundColor: 'white',
borderRadius: 12,
}} />
</TouchableOpacity>
</View>
</View>
)}
{/* Customization Section - Only for custom subtitles */}
{useCustomSubtitles && (
<View style={{ marginBottom: 30, gap: 10 }}>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14, fontWeight: '600', marginBottom: 10, textTransform: 'uppercase' }}>Appearance</Text>
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: 16, gap: 12 }}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Text Color</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' }} />
))}
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Align</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{(['left','center','right'] as const).map(a => (
<TouchableOpacity key={a} onPress={() => setSubtitleAlign(a)} style={{ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: subtitleAlign === a ? 'rgba(255,255,255,0.2)' : 'transparent', borderWidth: 1, borderColor: 'rgba(255,255,255,0.2)' }}>
<Text style={{ color: 'white', textTransform: 'capitalize' }}>{a}</Text>
</TouchableOpacity>
))}
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Bottom Offset</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="keyboard-arrow-down" color="#fff" size={20} />
</TouchableOpacity>
<Text style={{ color: 'white', width: 40, textAlign: 'center' }}>{subtitleBottomOffset}</Text>
<TouchableOpacity onPress={() => setSubtitleBottomOffset(subtitleBottomOffset + 5)} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="keyboard-arrow-up" color="#fff" size={20} />
</TouchableOpacity>
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Background Opacity</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<Text style={{ color: 'white', width: 40, textAlign: 'center' }}>{subtitleBgOpacity.toFixed(1)}</Text>
<TouchableOpacity onPress={() => setSubtitleBgOpacity(Math.min(1, +(subtitleBgOpacity + 0.1).toFixed(1)))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Text Shadow</Text>
<TouchableOpacity onPress={() => setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.2)' : 'transparent', borderWidth: 1, borderColor: 'rgba(255,255,255,0.2)' }}>
<Text style={{ color: 'white' }}>{subtitleTextShadow ? 'On' : 'Off'}</Text>
</TouchableOpacity>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Outline</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOutline(!subtitleOutline)} style={{ paddingHorizontal: 10, paddingVertical: 6, borderRadius: 6, backgroundColor: subtitleOutline ? 'rgba(255,255,255,0.2)' : 'transparent', borderWidth: 1, borderColor: 'rgba(255,255,255,0.2)' }}>
<Text style={{ color: 'white' }}>{subtitleOutline ? 'On' : 'Off'}</Text>
</TouchableOpacity>
{['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)' }} />
))}
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Outline Width</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<Text style={{ color: 'white', width: 40, textAlign: 'center' }}>{subtitleOutlineWidth}</Text>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Letter Spacing</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<Text style={{ color: 'white', width: 40, textAlign: 'center' }}>{subtitleLetterSpacing.toFixed(1)}</Text>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(+(subtitleLetterSpacing + 0.5).toFixed(1))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<Text style={{ color: 'white' }}>Line Height</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<Text style={{ color: 'white', width: 40, textAlign: 'center' }}>{subtitleLineHeightMultiplier.toFixed(1)}</Text>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(+(subtitleLineHeightMultiplier + 0.1).toFixed(1))} style={{ width: 30, height: 30, borderRadius: 15, backgroundColor: 'rgba(255,255,255,0.2)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
</View>
</View>
)}
{/* Built-in Subtitles */}
{vlcTextTracks.length > 0 && (
{activeTab === 'built-in' && (
<View style={{ marginBottom: 30 }}>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
@ -475,7 +285,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
style={{
backgroundColor: isSelected ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
padding: sectionPad,
borderWidth: 1,
borderColor: isSelected ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255, 255, 255, 0.1)',
}}
@ -488,7 +298,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{
color: '#FFFFFF',
fontSize: 15,
fontSize: isCompact ? 14 : 15,
fontWeight: '500',
flex: 1,
}}>
@ -505,7 +315,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
)}
{/* Online Subtitles */}
{activeTab === 'online' && (
<View style={{ marginBottom: 30 }}>
<View style={{
flexDirection: 'row',
@ -515,7 +325,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
}}>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
fontSize: isCompact ? 13 : 14,
fontWeight: '600',
textTransform: 'uppercase',
letterSpacing: 0.5,
@ -526,8 +336,8 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
style={{
backgroundColor: 'rgba(34, 197, 94, 0.15)',
borderRadius: 12,
paddingHorizontal: 12,
paddingVertical: 6,
paddingHorizontal: chipPadH,
paddingVertical: chipPadV-2,
flexDirection: 'row',
alignItems: 'center',
}}
@ -541,7 +351,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
)}
<Text style={{
color: '#22C55E',
fontSize: 12,
fontSize: isCompact ? 11 : 12,
fontWeight: '600',
marginLeft: 6,
}}>
@ -555,7 +365,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 20,
padding: isCompact ? 14 : 20,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
@ -567,7 +377,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<MaterialIcons name="cloud-download" size={24} color="rgba(255,255,255,0.4)" />
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 14,
fontSize: isCompact ? 13 : 14,
marginTop: 8,
textAlign: 'center',
}}>
@ -578,13 +388,13 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
<View style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 20,
padding: isCompact ? 14 : 20,
alignItems: 'center',
}}>
<ActivityIndicator size="large" color="#22C55E" />
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 14,
fontSize: isCompact ? 13 : 14,
marginTop: 12,
}}>
Searching...
@ -600,7 +410,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
style={{
backgroundColor: isSelected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
padding: sectionPad,
borderWidth: 1,
borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)',
}}
@ -633,60 +443,338 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</View>
)}
</View>
)}
{/* Turn Off Subtitles */}
<View>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
fontWeight: '600',
marginBottom: 15,
textTransform: 'uppercase',
letterSpacing: 0.5,
}}>
Options
</Text>
<TouchableOpacity
style={{
backgroundColor: selectedTextTrack === -1 && !useCustomSubtitles
? 'rgba(239, 68, 68, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
borderWidth: 1,
borderColor: selectedTextTrack === -1 && !useCustomSubtitles
? 'rgba(239, 68, 68, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
}}
onPress={() => {
selectTextTrack(-1);
setSelectedOnlineSubtitleId(null);
}}
activeOpacity={0.7}
>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center', flex: 1 }}>
<MaterialIcons
name="visibility-off"
size={20}
color={selectedTextTrack === -1 && !useCustomSubtitles ? "#EF4444" : "rgba(255,255,255,0.6)"}
style={{ marginRight: 12 }}
/>
<Text style={{
color: '#FFFFFF',
fontSize: 15,
fontWeight: '500',
}}>
Turn Off Subtitles
</Text>
{activeTab === 'appearance' && (
<View style={{ gap: isCompact ? 12 : 16, paddingBottom: 8 }}>
{/* Live Preview */}
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
<MaterialIcons name="visibility" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Preview</Text>
</View>
<View style={{ height: previewHeight, justifyContent: 'flex-end' }}>
<View style={{ alignItems: subtitleAlign === 'center' ? 'center' : subtitleAlign === 'left' ? 'flex-start' : 'flex-end', marginBottom: Math.min(80, subtitleBottomOffset) }}>
<View style={{
backgroundColor: subtitleBackground ? `rgba(0,0,0,${subtitleBgOpacity})` : 'transparent',
borderRadius: 8,
paddingHorizontal: isCompact ? 10 : 12,
paddingVertical: isCompact ? 6 : 8,
}}>
<Text style={{
color: subtitleTextColor,
fontSize: subtitleSize,
letterSpacing: subtitleLetterSpacing,
lineHeight: subtitleSize * subtitleLineHeightMultiplier,
textAlign: subtitleAlign,
textShadowColor: subtitleOutline
? subtitleOutlineColor
: (subtitleTextShadow ? 'rgba(0,0,0,0.9)' : undefined),
textShadowOffset: (subtitleOutline || subtitleTextShadow) ? { width: 2, height: 2 } : undefined,
textShadowRadius: subtitleOutline ? Math.max(1, subtitleOutlineWidth) : (subtitleTextShadow ? 4 : undefined),
}}>
The quick brown fox jumps over the lazy dog.
</Text>
</View>
</View>
</View>
{selectedTextTrack === -1 && !useCustomSubtitles && (
<MaterialIcons name="check" size={20} color="#EF4444" />
)}
</View>
</TouchableOpacity>
</View>
{/* Quick Presets */}
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
<MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Quick Presets</Text>
</View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
<TouchableOpacity
onPress={() => {
setSubtitleTextColor('#FFFFFF');
setSubtitleBgOpacity(0.7);
setSubtitleTextShadow(true);
setSubtitleOutline(false);
setSubtitleOutlineColor('#000000');
setSubtitleOutlineWidth(2);
setSubtitleAlign('center');
setSubtitleBottomOffset(20);
setSubtitleLetterSpacing(0);
setSubtitleLineHeightMultiplier(1.2);
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 11 : 12 }}>Default</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setSubtitleTextColor('#FFD700');
setSubtitleOutline(true);
setSubtitleOutlineColor('#000000');
setSubtitleOutlineWidth(2);
setSubtitleBgOpacity(0.3);
setSubtitleTextShadow(false);
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,215,0,0.12)', borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)' }}
>
<Text style={{ color: '#FFD700', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Yellow</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setSubtitleTextColor('#FFFFFF');
setSubtitleOutline(true);
setSubtitleOutlineColor('#000000');
setSubtitleOutlineWidth(3);
setSubtitleBgOpacity(0.0);
setSubtitleTextShadow(false);
setSubtitleLetterSpacing(0.5);
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(34,197,94,0.12)', borderWidth: 1, borderColor: 'rgba(34,197,94,0.35)' }}
>
<Text style={{ color: '#22C55E', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>High Contrast</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
setSubtitleTextColor('#FFFFFF');
setSubtitleBgOpacity(0.6);
setSubtitleTextShadow(true);
setSubtitleOutline(false);
setSubtitleAlign('center');
setSubtitleLineHeightMultiplier(1.3);
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(59,130,246,0.12)', borderWidth: 1, borderColor: 'rgba(59,130,246,0.35)' }}
>
<Text style={{ color: '#3B82F6', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Large</Text>
</TouchableOpacity>
</View>
</View>
{/* Core controls */}
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
<MaterialIcons name="tune" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Core</Text>
</View>
{/* Font Size */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="format-size" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Font Size</Text>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<TouchableOpacity onPress={decreaseSubtitleSize} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}>
<MaterialIcons name="remove" size={18} color="#FFFFFF" />
</TouchableOpacity>
<View style={{ minWidth: 42, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: '#fff', textAlign: 'center', fontWeight: '700' }}>{subtitleSize}</Text>
</View>
<TouchableOpacity onPress={increaseSubtitleSize} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}>
<MaterialIcons name="add" size={18} color="#FFFFFF" />
</TouchableOpacity>
</View>
</View>
{/* Background toggle */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Show Background</Text>
</View>
<TouchableOpacity
style={{ width: isCompact ? 48 : 54, height: isCompact ? 28 : 30, backgroundColor: subtitleBackground ? '#22C55E' : 'rgba(255,255,255,0.25)', borderRadius: 15, justifyContent: 'center', alignItems: subtitleBackground ? 'flex-end' : 'flex-start', paddingHorizontal: 3 }}
onPress={toggleSubtitleBackground}
>
<View style={{ width: 24, height: 24, backgroundColor: 'white', borderRadius: 12 }} />
</TouchableOpacity>
</View>
</View>
{/* Advanced controls */}
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="build" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Advanced</Text>
</View>
{/* Text Color */}
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
<Text style={{ color: 'white', marginLeft: 8, fontWeight: '600' }}>Text Color</Text>
</View>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
{['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
))}
</View>
</View>
{/* Align */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Align</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{([
{ key: 'left', icon: 'format-align-left' },
{ key: 'center', icon: 'format-align-center' },
{ key: 'right', icon: 'format-align-right' },
] as const).map(a => (
<TouchableOpacity
key={a.key}
onPress={() => setSubtitleAlign(a.key)}
style={{ paddingHorizontal: isCompact ? 8 : 10, paddingVertical: isCompact ? 4 : 6, borderRadius: 8, backgroundColor: subtitleAlign === a.key ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
>
<MaterialIcons name={a.icon as any} size={18} color="#FFFFFF" />
</TouchableOpacity>
))}
</View>
</View>
{/* Bottom Offset */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="keyboard-arrow-down" color="#fff" size={20} />
</TouchableOpacity>
<View style={{ minWidth: 46, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleBottomOffset}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleBottomOffset(subtitleBottomOffset + 5)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="keyboard-arrow-up" color="#fff" size={20} />
</TouchableOpacity>
</View>
</View>
{/* Background Opacity */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleBgOpacity.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleBgOpacity(Math.min(1, +(subtitleBgOpacity + 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
{/* Shadow & Outline */}
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
{/* Shadow */}
<View style={{ flex: 1, gap: 8 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
<TouchableOpacity onPress={() => setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? 'On' : 'Off'}</Text>
</TouchableOpacity>
</View>
{/* Outline */}
<View style={{ flex: 1, gap: 8 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Outline</Text>
<TouchableOpacity onPress={() => setSubtitleOutline(!subtitleOutline)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleOutline ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}>
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleOutline ? 'On' : 'Off'}</Text>
</TouchableOpacity>
</View>
</View>
{/* Outline color & width */}
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Color</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
{['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => (
<TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
))}
</View>
</View>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white' }}>Outline Width</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 42, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleOutlineWidth}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(subtitleOutlineWidth + 1)} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
{/* Spacing (two columns) */}
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Letter Spacing</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleLetterSpacing.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(+(subtitleLetterSpacing + 0.5).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
<View style={{ flex: 1 }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Line Height</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 48, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleLineHeightMultiplier.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(+(subtitleLineHeightMultiplier + 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
</View>
{/* Timing Offset */}
<View style={{ marginTop: 4 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text>
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
<TouchableOpacity onPress={() => setSubtitleOffsetSec(+(subtitleOffsetSec - 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="remove" color="#fff" size={18} />
</TouchableOpacity>
<View style={{ minWidth: 60, paddingHorizontal: 6, paddingVertical: 4, borderRadius: 10, backgroundColor: 'rgba(255,255,255,0.12)' }}>
<Text style={{ color: 'white', textAlign: 'center', fontWeight: '700' }}>{subtitleOffsetSec.toFixed(1)}</Text>
</View>
<TouchableOpacity onPress={() => setSubtitleOffsetSec(+(subtitleOffsetSec + 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
<MaterialIcons name="add" color="#fff" size={18} />
</TouchableOpacity>
</View>
</View>
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text>
</View>
{/* Reset to defaults */}
<View style={{ alignItems: 'flex-end', marginTop: 8 }}>
<TouchableOpacity
onPress={() => {
setSubtitleTextColor('#FFFFFF');
setSubtitleBgOpacity(0.7);
setSubtitleTextShadow(true);
setSubtitleOutline(false);
setSubtitleOutlineColor('#000000');
setSubtitleOutlineWidth(2);
setSubtitleAlign('center');
setSubtitleBottomOffset(20);
setSubtitleLetterSpacing(0);
setSubtitleLineHeightMultiplier(1.2);
setSubtitleOffsetSec(0);
}}
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.1)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
>
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 14 }}>Reset to defaults</Text>
</TouchableOpacity>
</View>
</View>
</View>
)}
</ScrollView>
</Animated.View>
</>

View file

@ -14,6 +14,7 @@ class StorageService {
private static instance: StorageService;
private readonly WATCH_PROGRESS_KEY = '@watch_progress:';
private readonly CONTENT_DURATION_KEY = '@content_duration:';
private readonly SUBTITLE_SETTINGS_KEY = '@subtitle_settings';
private watchProgressSubscribers: (() => void)[] = [];
private notificationDebounceTimer: NodeJS.Timeout | null = null;
private lastNotificationTime: number = 0;
@ -420,6 +421,24 @@ class StorageService {
logger.error('Error merging with Trakt progress:', error);
}
}
public async saveSubtitleSettings(settings: Record<string, any>): Promise<void> {
try {
await AsyncStorage.setItem(this.SUBTITLE_SETTINGS_KEY, JSON.stringify(settings));
} catch (error) {
logger.error('Error saving subtitle settings:', error);
}
}
public async getSubtitleSettings(): Promise<Record<string, any> | null> {
try {
const data = await AsyncStorage.getItem(this.SUBTITLE_SETTINGS_KEY);
return data ? JSON.parse(data) : null;
} catch (error) {
logger.error('Error loading subtitle settings:', error);
return null;
}
}
}
export const storageService = StorageService.getInstance();