mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
imrpoved subtitles UI
This commit is contained in:
parent
51550316ec
commit
dff3a66d7b
4 changed files with 637 additions and 392 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' : 'Built‑in 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: 'Built‑in' },
|
||||
{ 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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
Loading…
Reference in a new issue