mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
added subtitle/audio track selection menu
This commit is contained in:
parent
ec28f73df9
commit
be9473adf7
7 changed files with 760 additions and 15 deletions
|
|
@ -56,6 +56,7 @@ import { formatTime, isHlsStream, getHlsHeaders, defaultAndroidHeaders, parseSRT
|
|||
import { storageService } from '../../services/storageService';
|
||||
import stremioService from '../../services/stremioService';
|
||||
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
|
||||
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
||||
import axios from 'axios';
|
||||
|
||||
const DEBUG_MODE = false;
|
||||
|
|
@ -120,6 +121,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const [useCustomSubtitles, setUseCustomSubtitles] = useState(false);
|
||||
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
||||
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
||||
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
|
||||
|
||||
// Subtitle customization state
|
||||
const [subtitleSize, setSubtitleSize] = useState(28);
|
||||
|
|
@ -136,6 +138,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2);
|
||||
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0);
|
||||
|
||||
// Track auto-selection ref to prevent duplicate selections
|
||||
const hasAutoSelectedTracks = useRef(false);
|
||||
|
||||
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
|
||||
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
|
||||
const hasLogo = metadata && metadata.logo;
|
||||
|
|
@ -299,6 +304,52 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
playerState.setIsVideoLoaded(true);
|
||||
openingAnimation.completeOpeningAnimation();
|
||||
|
||||
// Auto-select audio track based on preferences
|
||||
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
|
||||
const formatted = data.audioTracks.map((t: any, i: number) => ({
|
||||
id: t.index !== undefined ? t.index : i,
|
||||
name: t.title || t.name || `Track ${i + 1}`,
|
||||
language: t.language
|
||||
}));
|
||||
const bestAudioTrack = findBestAudioTrack(formatted, settings.preferredAudioLanguage);
|
||||
if (bestAudioTrack !== null) {
|
||||
logger.debug(`[AndroidVideoPlayer] Auto-selecting audio track ${bestAudioTrack} for language: ${settings.preferredAudioLanguage}`);
|
||||
tracksHook.setSelectedAudioTrack({ type: 'index', value: bestAudioTrack });
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select subtitle track based on preferences
|
||||
// Only auto-select internal tracks here if preference is 'internal' or 'any'
|
||||
// If preference is 'external', we wait for the useEffect to handle selection after external subs load
|
||||
if (data.textTracks && data.textTracks.length > 0 && !hasAutoSelectedTracks.current && settings?.enableSubtitleAutoSelect) {
|
||||
const sourcePreference = settings?.subtitleSourcePreference || 'internal';
|
||||
|
||||
// Only pre-select internal if preference is internal or any
|
||||
if (sourcePreference === 'internal' || sourcePreference === 'any') {
|
||||
const formatted = data.textTracks.map((t: any, i: number) => ({
|
||||
id: t.index !== undefined ? t.index : i,
|
||||
name: t.title || t.name || `Track ${i + 1}`,
|
||||
language: t.language
|
||||
}));
|
||||
const subtitleSelection = findBestSubtitleTrack(
|
||||
formatted,
|
||||
[], // External subtitles not yet loaded
|
||||
{
|
||||
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
|
||||
subtitleSourcePreference: sourcePreference,
|
||||
enableSubtitleAutoSelect: true
|
||||
}
|
||||
);
|
||||
|
||||
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
|
||||
logger.debug(`[AndroidVideoPlayer] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
|
||||
tracksHook.setSelectedTextTrack(subtitleSelection.internalTrackId);
|
||||
hasAutoSelectedTracks.current = true;
|
||||
}
|
||||
}
|
||||
// If preference is 'external', don't select anything here - useEffect will handle it
|
||||
}
|
||||
|
||||
// Handle Resume - check both initialPosition and initialSeekTargetRef
|
||||
const resumeTarget = watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
|
||||
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && videoDuration > 0) {
|
||||
|
|
@ -332,6 +383,45 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
}, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]);
|
||||
|
||||
// Auto-select subtitles when both internal tracks and video are loaded
|
||||
// This ensures we wait for internal tracks before falling back to external
|
||||
useEffect(() => {
|
||||
if (!playerState.isVideoLoaded || hasAutoSelectedTracks.current || !settings?.enableSubtitleAutoSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const internalTracks = tracksHook.ksTextTracks;
|
||||
const externalSubs = availableSubtitles;
|
||||
|
||||
// Wait a short delay to ensure tracks are fully populated
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (hasAutoSelectedTracks.current) return;
|
||||
|
||||
const subtitleSelection = findBestSubtitleTrack(
|
||||
internalTracks,
|
||||
externalSubs,
|
||||
{
|
||||
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
|
||||
subtitleSourcePreference: settings?.subtitleSourcePreference || 'internal',
|
||||
enableSubtitleAutoSelect: true
|
||||
}
|
||||
);
|
||||
|
||||
// Trust the findBestSubtitleTrack function's decision - it already implements priority logic
|
||||
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
|
||||
logger.debug(`[AndroidVideoPlayer] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
|
||||
tracksHook.setSelectedTextTrack(subtitleSelection.internalTrackId);
|
||||
hasAutoSelectedTracks.current = true;
|
||||
} else if (subtitleSelection.type === 'external' && subtitleSelection.externalSubtitle) {
|
||||
logger.debug(`[AndroidVideoPlayer] Auto-selecting external subtitle: ${subtitleSelection.externalSubtitle.display}`);
|
||||
loadWyzieSubtitle(subtitleSelection.externalSubtitle);
|
||||
hasAutoSelectedTracks.current = true;
|
||||
}
|
||||
}, 500); // Short delay to ensure tracks are populated
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [playerState.isVideoLoaded, tracksHook.ksTextTracks, availableSubtitles, settings]);
|
||||
|
||||
// Sync custom subtitle text with current playback time
|
||||
useEffect(() => {
|
||||
if (!useCustomSubtitles || customSubtitles.length === 0) return;
|
||||
|
|
@ -496,6 +586,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
setAvailableSubtitles(subs);
|
||||
logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`);
|
||||
// Auto-selection is now handled by useEffect that waits for internal tracks
|
||||
} catch (e) {
|
||||
logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e);
|
||||
} finally {
|
||||
|
|
@ -523,6 +614,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const parsedCues = parseSRT(srtContent);
|
||||
setCustomSubtitles(parsedCues);
|
||||
setUseCustomSubtitles(true);
|
||||
setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
|
||||
|
||||
// Disable MPV's built-in subtitle track when using custom subtitles
|
||||
tracksHook.setSelectedTextTrack(-1);
|
||||
|
|
@ -549,6 +641,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
setUseCustomSubtitles(false);
|
||||
setCustomSubtitles([]);
|
||||
setCurrentSubtitle('');
|
||||
setSelectedExternalSubtitleId(null); // Clear external selection
|
||||
}, []);
|
||||
|
||||
const cycleResizeMode = useCallback(() => {
|
||||
|
|
@ -893,6 +986,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier}
|
||||
subtitleOffsetSec={subtitleOffsetSec}
|
||||
setSubtitleOffsetSec={setSubtitleOffsetSec}
|
||||
selectedExternalSubtitleId={selectedExternalSubtitleId}
|
||||
/>
|
||||
|
||||
<SourcesModal
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ import { logger } from '../../utils/logger';
|
|||
import { formatTime } from './utils/playerUtils';
|
||||
import { WyzieSubtitle } from './utils/playerTypes';
|
||||
import { parseSRT } from './utils/subtitleParser';
|
||||
import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
|
||||
// Player route params interface
|
||||
interface PlayerRouteParams {
|
||||
|
|
@ -130,6 +132,10 @@ const KSPlayerCore: React.FC = () => {
|
|||
const tracks = usePlayerTracks();
|
||||
const { ksPlayerRef, seek } = useKSPlayer();
|
||||
const customSubs = useCustomSubtitles();
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Track auto-selection refs to prevent duplicate selections
|
||||
const hasAutoSelectedTracks = useRef(false);
|
||||
|
||||
// Next Episode Hook
|
||||
const { nextEpisode, currentEpisodeDescription } = useNextEpisode({
|
||||
|
|
@ -267,19 +273,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
}));
|
||||
|
||||
customSubs.setAvailableSubtitles(subs);
|
||||
|
||||
if (autoSelectEnglish) {
|
||||
const englishSubtitle = subs.find(sub =>
|
||||
sub.language.includes('en') || sub.display.toLowerCase().includes('english')
|
||||
);
|
||||
if (englishSubtitle) {
|
||||
loadWyzieSubtitle(englishSubtitle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!autoSelectEnglish) {
|
||||
modals.setShowSubtitleLanguageModal(true);
|
||||
}
|
||||
// Auto-selection is now handled by useEffect that waits for internal tracks
|
||||
// This ensures internal tracks are considered before falling back to external
|
||||
} catch (e) {
|
||||
logger.error('[VideoPlayer] Error fetching subtitles', e);
|
||||
} finally {
|
||||
|
|
@ -302,6 +297,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
const parsedCues = parseSRT(srtContent);
|
||||
customSubs.setCustomSubtitles(parsedCues);
|
||||
customSubs.setUseCustomSubtitles(true);
|
||||
customSubs.setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
|
||||
tracks.selectTextTrack(-1);
|
||||
|
||||
const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0);
|
||||
|
|
@ -322,6 +318,45 @@ const KSPlayerCore: React.FC = () => {
|
|||
}
|
||||
}, [imdbId]);
|
||||
|
||||
// Auto-select subtitles when both internal tracks and video are loaded
|
||||
// This ensures we wait for internal tracks before falling back to external
|
||||
useEffect(() => {
|
||||
if (!isVideoLoaded || hasAutoSelectedTracks.current || !settings?.enableSubtitleAutoSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const internalTracks = tracks.ksTextTracks;
|
||||
const externalSubs = customSubs.availableSubtitles;
|
||||
|
||||
// Wait a short delay to ensure tracks are fully populated
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (hasAutoSelectedTracks.current) return;
|
||||
|
||||
const subtitleSelection = findBestSubtitleTrack(
|
||||
internalTracks,
|
||||
externalSubs,
|
||||
{
|
||||
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
|
||||
subtitleSourcePreference: settings?.subtitleSourcePreference || 'internal',
|
||||
enableSubtitleAutoSelect: true
|
||||
}
|
||||
);
|
||||
|
||||
// Trust the findBestSubtitleTrack function's decision - it already implements priority logic
|
||||
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
|
||||
logger.debug(`[KSPlayerCore] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId}`);
|
||||
tracks.selectTextTrack(subtitleSelection.internalTrackId);
|
||||
hasAutoSelectedTracks.current = true;
|
||||
} else if (subtitleSelection.type === 'external' && subtitleSelection.externalSubtitle) {
|
||||
logger.debug(`[KSPlayerCore] Auto-selecting external subtitle: ${subtitleSelection.externalSubtitle.display}`);
|
||||
loadWyzieSubtitle(subtitleSelection.externalSubtitle);
|
||||
hasAutoSelectedTracks.current = true;
|
||||
}
|
||||
}, 500); // Short delay to ensure tracks are populated
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isVideoLoaded, tracks.ksTextTracks, customSubs.availableSubtitles, settings]);
|
||||
|
||||
// Sync custom subtitle text with current playback time
|
||||
useEffect(() => {
|
||||
if (!customSubs.useCustomSubtitles || customSubs.customSubtitles.length === 0) return;
|
||||
|
|
@ -347,6 +382,45 @@ const KSPlayerCore: React.FC = () => {
|
|||
setIsPlayerReady(true);
|
||||
openingAnim.completeOpeningAnimation();
|
||||
|
||||
// Auto-select audio track based on preferences
|
||||
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
|
||||
const bestAudioTrack = findBestAudioTrack(data.audioTracks, settings.preferredAudioLanguage);
|
||||
if (bestAudioTrack !== null) {
|
||||
logger.debug(`[KSPlayerCore] Auto-selecting audio track ${bestAudioTrack} for language: ${settings.preferredAudioLanguage}`);
|
||||
tracks.selectAudioTrack(bestAudioTrack);
|
||||
if (ksPlayerRef.current) {
|
||||
ksPlayerRef.current.setAudioTrack(bestAudioTrack);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select subtitle track based on preferences
|
||||
// Only auto-select internal tracks here if preference is 'internal' or 'any'
|
||||
// If preference is 'external', we wait for the useEffect to handle selection after external subs load
|
||||
if (data.textTracks && data.textTracks.length > 0 && !hasAutoSelectedTracks.current && settings?.enableSubtitleAutoSelect) {
|
||||
const sourcePreference = settings?.subtitleSourcePreference || 'internal';
|
||||
|
||||
// Only pre-select internal if preference is internal or any
|
||||
if (sourcePreference === 'internal' || sourcePreference === 'any') {
|
||||
const subtitleSelection = findBestSubtitleTrack(
|
||||
data.textTracks,
|
||||
[], // External subtitles not yet loaded
|
||||
{
|
||||
preferredSubtitleLanguage: settings?.preferredSubtitleLanguage || 'en',
|
||||
subtitleSourcePreference: sourcePreference,
|
||||
enableSubtitleAutoSelect: true
|
||||
}
|
||||
);
|
||||
|
||||
if (subtitleSelection.type === 'internal' && subtitleSelection.internalTrackId !== undefined) {
|
||||
logger.debug(`[KSPlayerCore] Auto-selecting internal subtitle track ${subtitleSelection.internalTrackId} on load`);
|
||||
tracks.selectTextTrack(subtitleSelection.internalTrackId);
|
||||
hasAutoSelectedTracks.current = true;
|
||||
}
|
||||
}
|
||||
// If preference is 'external', don't select anything here - useEffect will handle it
|
||||
}
|
||||
|
||||
// Initial Seek
|
||||
const resumeTarget = routeInitialPosition || watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
|
||||
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && data.duration > 0) {
|
||||
|
|
@ -800,8 +874,10 @@ const KSPlayerCore: React.FC = () => {
|
|||
selectTextTrack={handleSelectTextTrack}
|
||||
disableCustomSubtitles={() => {
|
||||
customSubs.setUseCustomSubtitles(false);
|
||||
customSubs.setSelectedExternalSubtitleId(null); // Clear external selection
|
||||
handleSelectTextTrack(-1);
|
||||
}}
|
||||
selectedExternalSubtitleId={customSubs.selectedExternalSubtitleId}
|
||||
/>
|
||||
|
||||
<SourcesModal
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ export const useCustomSubtitles = () => {
|
|||
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
|
||||
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
|
||||
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
|
||||
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
|
||||
|
||||
// Loading State
|
||||
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
|
||||
|
|
@ -91,6 +92,7 @@ export const useCustomSubtitles = () => {
|
|||
currentFormattedSegments, setCurrentFormattedSegments,
|
||||
availableSubtitles, setAvailableSubtitles,
|
||||
useCustomSubtitles, setUseCustomSubtitles,
|
||||
selectedExternalSubtitleId, setSelectedExternalSubtitleId,
|
||||
isLoadingSubtitles, setIsLoadingSubtitles,
|
||||
isLoadingSubtitleList, setIsLoadingSubtitleList,
|
||||
subtitleSize, setSubtitleSize,
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ interface SubtitleModalsProps {
|
|||
setSubtitleLineHeightMultiplier: (n: number) => void;
|
||||
subtitleOffsetSec: number;
|
||||
setSubtitleOffsetSec: (n: number) => void;
|
||||
selectedExternalSubtitleId?: string | null; // ID of currently selected external/addon subtitle
|
||||
}
|
||||
|
||||
const MorphingTab = ({ label, isSelected, onPress }: any) => {
|
||||
|
|
@ -91,11 +92,15 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
subtitleBottomOffset, setSubtitleBottomOffset, subtitleLetterSpacing, setSubtitleLetterSpacing,
|
||||
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, subtitleOffsetSec, setSubtitleOffsetSec,
|
||||
setSubtitlesAutoSelect,
|
||||
selectedExternalSubtitleId,
|
||||
}) => {
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isIos = Platform.OS === 'ios';
|
||||
const isLandscape = width > height;
|
||||
const [selectedOnlineSubtitleId, setSelectedOnlineSubtitleId] = React.useState<string | null>(null);
|
||||
// Use prop value if provided (for auto-selected subtitles), otherwise use local state
|
||||
const [localSelectedId, setLocalSelectedId] = React.useState<string | null>(null);
|
||||
const selectedOnlineSubtitleId = selectedExternalSubtitleId ?? localSelectedId;
|
||||
const setSelectedOnlineSubtitleId = setLocalSelectedId;
|
||||
const [activeTab, setActiveTab] = React.useState<'built-in' | 'addon' | 'appearance'>('built-in');
|
||||
|
||||
const isCompact = width < 360 || height < 640;
|
||||
|
|
|
|||
232
src/components/player/utils/trackSelectionUtils.ts
Normal file
232
src/components/player/utils/trackSelectionUtils.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
/**
|
||||
* Track Selection Utilities
|
||||
* Logic for auto-selecting audio and subtitle tracks based on user preferences
|
||||
*/
|
||||
|
||||
import { AppSettings } from '../../../hooks/useSettings';
|
||||
import { languageMap } from './playerUtils';
|
||||
import { WyzieSubtitle } from './playerTypes';
|
||||
|
||||
interface Track {
|
||||
id: number;
|
||||
name: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a language code or name to a standard 2-letter ISO code
|
||||
*/
|
||||
export const normalizeLanguageCode = (langInput?: string): string => {
|
||||
if (!langInput) return '';
|
||||
|
||||
const normalized = langInput.toLowerCase().trim();
|
||||
|
||||
// If it's already a short code that we know, return it
|
||||
if (languageMap[normalized]) {
|
||||
// Convert 3-letter codes to 2-letter codes
|
||||
const twoLetterCodes: { [key: string]: string } = {
|
||||
'eng': 'en', 'spa': 'es', 'fre': 'fr', 'ger': 'de', 'ita': 'it',
|
||||
'jpn': 'ja', 'kor': 'ko', 'chi': 'zh', 'rus': 'ru', 'por': 'pt',
|
||||
'hin': 'hi', 'ara': 'ar', 'dut': 'nl', 'swe': 'sv', 'nor': 'no',
|
||||
'fin': 'fi', 'dan': 'da', 'pol': 'pl', 'tur': 'tr', 'cze': 'cs',
|
||||
'hun': 'hu', 'gre': 'el', 'tha': 'th', 'vie': 'vi'
|
||||
};
|
||||
return twoLetterCodes[normalized] || normalized;
|
||||
}
|
||||
|
||||
// Check if it's a full language name
|
||||
for (const [code, name] of Object.entries(languageMap)) {
|
||||
if (name.toLowerCase() === normalized) {
|
||||
// Return the 2-letter code
|
||||
const twoLetterCodes: { [key: string]: string } = {
|
||||
'eng': 'en', 'spa': 'es', 'fre': 'fr', 'ger': 'de', 'ita': 'it',
|
||||
'jpn': 'ja', 'kor': 'ko', 'chi': 'zh', 'rus': 'ru', 'por': 'pt',
|
||||
'hin': 'hi', 'ara': 'ar', 'dut': 'nl', 'swe': 'sv', 'nor': 'no',
|
||||
'fin': 'fi', 'dan': 'da', 'pol': 'pl', 'tur': 'tr', 'cze': 'cs',
|
||||
'hun': 'hu', 'gre': 'el', 'tha': 'th', 'vie': 'vi'
|
||||
};
|
||||
return twoLetterCodes[code] || code;
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a track matches the preferred language
|
||||
*/
|
||||
export const trackMatchesLanguage = (track: Track, preferredLang: string): boolean => {
|
||||
const trackLang = normalizeLanguageCode(track.language);
|
||||
const trackNameLower = (track.name || '').toLowerCase();
|
||||
const prefLang = normalizeLanguageCode(preferredLang);
|
||||
|
||||
if (!prefLang) return false;
|
||||
|
||||
// Direct language code match
|
||||
if (trackLang === prefLang) return true;
|
||||
|
||||
// Check if the track name contains the language
|
||||
const langName = languageMap[prefLang] || languageMap[prefLang + 'g']; // handle 'en' -> 'eng' mapping
|
||||
if (langName && trackNameLower.includes(langName.toLowerCase())) return true;
|
||||
|
||||
// Check for common language indicators in track name
|
||||
const languagePatterns: { [key: string]: RegExp } = {
|
||||
'en': /\b(english|eng|en)\b/i,
|
||||
'es': /\b(spanish|spa|es|español|espanol)\b/i,
|
||||
'fr': /\b(french|fre|fr|français|francais)\b/i,
|
||||
'de': /\b(german|ger|de|deutsch)\b/i,
|
||||
'it': /\b(italian|ita|it|italiano)\b/i,
|
||||
'ja': /\b(japanese|jpn|ja|日本語)\b/i,
|
||||
'ko': /\b(korean|kor|ko|한국어)\b/i,
|
||||
'zh': /\b(chinese|chi|zh|中文)\b/i,
|
||||
'ru': /\b(russian|rus|ru|русский)\b/i,
|
||||
'pt': /\b(portuguese|por|pt|português)\b/i,
|
||||
'hi': /\b(hindi|hin|hi|हिन्दी)\b/i,
|
||||
'ar': /\b(arabic|ara|ar|العربية)\b/i,
|
||||
};
|
||||
|
||||
const pattern = languagePatterns[prefLang];
|
||||
if (pattern && pattern.test(trackNameLower)) return true;
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the best matching audio track based on user preferences
|
||||
* Returns the track ID to select, or null if no preference match found
|
||||
*/
|
||||
export const findBestAudioTrack = (
|
||||
tracks: Track[],
|
||||
preferredLanguage: string
|
||||
): number | null => {
|
||||
if (!tracks || tracks.length === 0) return null;
|
||||
|
||||
// Try to find a track matching the preferred language
|
||||
const matchingTrack = tracks.find(track => trackMatchesLanguage(track, preferredLanguage));
|
||||
|
||||
if (matchingTrack) {
|
||||
return matchingTrack.id;
|
||||
}
|
||||
|
||||
// No match found - return first track as fallback (or null to use system default)
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the best matching subtitle track based on user preferences
|
||||
* Implements the priority: internal first → external fallback → first available
|
||||
*
|
||||
* @param internalTracks - Embedded subtitle tracks from the video
|
||||
* @param externalSubtitles - Available external/addon subtitles
|
||||
* @param settings - User's subtitle preferences
|
||||
* @returns Object with selected track info
|
||||
*/
|
||||
export const findBestSubtitleTrack = (
|
||||
internalTracks: Track[],
|
||||
externalSubtitles: WyzieSubtitle[],
|
||||
settings: {
|
||||
preferredSubtitleLanguage: string;
|
||||
subtitleSourcePreference: 'internal' | 'external' | 'any';
|
||||
enableSubtitleAutoSelect: boolean;
|
||||
}
|
||||
): {
|
||||
type: 'internal' | 'external' | 'none';
|
||||
internalTrackId?: number;
|
||||
externalSubtitle?: WyzieSubtitle;
|
||||
} => {
|
||||
// If auto-select is disabled, don't select anything
|
||||
if (!settings.enableSubtitleAutoSelect) {
|
||||
return { type: 'none' };
|
||||
}
|
||||
|
||||
const preferredLang = settings.preferredSubtitleLanguage || 'en';
|
||||
const sourcePreference = settings.subtitleSourcePreference || 'internal';
|
||||
|
||||
// Find matching internal track
|
||||
const matchingInternalTrack = internalTracks.find(track =>
|
||||
trackMatchesLanguage(track, preferredLang)
|
||||
);
|
||||
|
||||
// Find matching external subtitle
|
||||
const matchingExternalSub = externalSubtitles.find(sub => {
|
||||
const subLang = normalizeLanguageCode(sub.language);
|
||||
const prefLang = normalizeLanguageCode(preferredLang);
|
||||
return subLang === prefLang ||
|
||||
sub.language.toLowerCase().includes(preferredLang.toLowerCase()) ||
|
||||
sub.display.toLowerCase().includes(languageMap[preferredLang]?.toLowerCase() || preferredLang);
|
||||
});
|
||||
|
||||
// Apply source preference priority
|
||||
if (sourcePreference === 'internal') {
|
||||
// 1. Try internal track matching preferred language
|
||||
if (matchingInternalTrack) {
|
||||
return { type: 'internal', internalTrackId: matchingInternalTrack.id };
|
||||
}
|
||||
// 2. Fallback to external subtitle matching preferred language
|
||||
if (matchingExternalSub) {
|
||||
return { type: 'external', externalSubtitle: matchingExternalSub };
|
||||
}
|
||||
// 3. Fallback to first internal track if any available
|
||||
if (internalTracks.length > 0) {
|
||||
return { type: 'internal', internalTrackId: internalTracks[0].id };
|
||||
}
|
||||
// 4. Fallback to first external subtitle if any available
|
||||
if (externalSubtitles.length > 0) {
|
||||
return { type: 'external', externalSubtitle: externalSubtitles[0] };
|
||||
}
|
||||
} else if (sourcePreference === 'external') {
|
||||
// 1. Try external subtitle matching preferred language
|
||||
if (matchingExternalSub) {
|
||||
return { type: 'external', externalSubtitle: matchingExternalSub };
|
||||
}
|
||||
// 2. Fallback to internal track matching preferred language
|
||||
if (matchingInternalTrack) {
|
||||
return { type: 'internal', internalTrackId: matchingInternalTrack.id };
|
||||
}
|
||||
// 3. Fallback to first external subtitle if any available
|
||||
if (externalSubtitles.length > 0) {
|
||||
return { type: 'external', externalSubtitle: externalSubtitles[0] };
|
||||
}
|
||||
// 4. Fallback to first internal track if any available
|
||||
if (internalTracks.length > 0) {
|
||||
return { type: 'internal', internalTrackId: internalTracks[0].id };
|
||||
}
|
||||
} else {
|
||||
// 'any' - prefer matching language regardless of source, internal first
|
||||
if (matchingInternalTrack) {
|
||||
return { type: 'internal', internalTrackId: matchingInternalTrack.id };
|
||||
}
|
||||
if (matchingExternalSub) {
|
||||
return { type: 'external', externalSubtitle: matchingExternalSub };
|
||||
}
|
||||
// Fallback to first available
|
||||
if (internalTracks.length > 0) {
|
||||
return { type: 'internal', internalTrackId: internalTracks[0].id };
|
||||
}
|
||||
if (externalSubtitles.length > 0) {
|
||||
return { type: 'external', externalSubtitle: externalSubtitles[0] };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: 'none' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Find best audio track from available tracks
|
||||
*/
|
||||
export const autoSelectAudioTrack = (
|
||||
tracks: Track[],
|
||||
preferredLanguage: string
|
||||
): number | null => {
|
||||
if (!tracks || tracks.length === 0) return null;
|
||||
|
||||
// Try to find a track matching the preferred language
|
||||
const matchingTrack = tracks.find(track => trackMatchesLanguage(track, preferredLanguage));
|
||||
|
||||
if (matchingTrack) {
|
||||
return matchingTrack.id;
|
||||
}
|
||||
|
||||
// Return null to let the player use its default
|
||||
return null;
|
||||
};
|
||||
|
|
@ -105,6 +105,11 @@ export interface AppSettings {
|
|||
decoderMode: 'auto' | 'sw' | 'hw' | 'hw+'; // Decoder mode: auto (auto-copy), sw (software), hw (mediacodec-copy), hw+ (mediacodec)
|
||||
gpuMode: 'gpu' | 'gpu-next'; // GPU rendering mode: gpu (standard) or gpu-next (advanced HDR/color)
|
||||
showDiscover: boolean;
|
||||
// Audio/Subtitle Language Preferences
|
||||
preferredSubtitleLanguage: string; // Preferred language for subtitles (ISO 639-1 code, e.g., 'en', 'es', 'fr')
|
||||
preferredAudioLanguage: string; // Preferred language for audio tracks (ISO 639-1 code)
|
||||
subtitleSourcePreference: 'internal' | 'external' | 'any'; // Prefer internal (embedded), external (addon), or any
|
||||
enableSubtitleAutoSelect: boolean; // Auto-select subtitles based on preferences
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
|
|
@ -183,6 +188,11 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
decoderMode: 'auto', // Default to auto (best compatibility and performance)
|
||||
gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR)
|
||||
showDiscover: true, // Show Discover section in SearchScreen
|
||||
// Audio/Subtitle Language Preferences
|
||||
preferredSubtitleLanguage: 'en', // Default to English subtitles
|
||||
preferredAudioLanguage: 'en', // Default to English audio
|
||||
subtitleSourcePreference: 'internal', // Prefer internal/embedded subtitles first
|
||||
enableSubtitleAutoSelect: true, // Auto-select subtitles by default
|
||||
};
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useCallback } from 'react';
|
||||
import { View, StyleSheet, ScrollView, StatusBar, Platform } from 'react-native';
|
||||
import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Modal, FlatList } from 'react-native';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
|
@ -9,6 +9,188 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
|
|||
import ScreenHeader from '../../components/common/ScreenHeader';
|
||||
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
|
||||
import { useRealtimeConfig } from '../../hooks/useRealtimeConfig';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
||||
// Available languages for audio/subtitle selection
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
{ code: 'ru', name: 'Russian' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'hi', name: 'Hindi' },
|
||||
{ code: 'ar', name: 'Arabic' },
|
||||
{ code: 'nl', name: 'Dutch' },
|
||||
{ code: 'sv', name: 'Swedish' },
|
||||
{ code: 'no', name: 'Norwegian' },
|
||||
{ code: 'fi', name: 'Finnish' },
|
||||
{ code: 'da', name: 'Danish' },
|
||||
{ code: 'pl', name: 'Polish' },
|
||||
{ code: 'tr', name: 'Turkish' },
|
||||
{ code: 'cs', name: 'Czech' },
|
||||
{ code: 'hu', name: 'Hungarian' },
|
||||
{ code: 'el', name: 'Greek' },
|
||||
{ code: 'th', name: 'Thai' },
|
||||
{ code: 'vi', name: 'Vietnamese' },
|
||||
{ code: 'id', name: 'Indonesian' },
|
||||
{ code: 'ms', name: 'Malay' },
|
||||
{ code: 'ta', name: 'Tamil' },
|
||||
{ code: 'te', name: 'Telugu' },
|
||||
{ code: 'bn', name: 'Bengali' },
|
||||
{ code: 'uk', name: 'Ukrainian' },
|
||||
{ code: 'he', name: 'Hebrew' },
|
||||
{ code: 'fa', name: 'Persian' },
|
||||
];
|
||||
|
||||
const SUBTITLE_SOURCE_OPTIONS = [
|
||||
{ value: 'internal', label: 'Internal First', description: 'Prefer embedded subtitles, then external' },
|
||||
{ value: 'external', label: 'External First', description: 'Prefer addon subtitles, then embedded' },
|
||||
{ value: 'any', label: 'Any Available', description: 'Use first available subtitle track' },
|
||||
];
|
||||
|
||||
interface LanguagePickerModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
selectedLanguage: string;
|
||||
onSelectLanguage: (code: string) => void;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const LanguagePickerModal: React.FC<LanguagePickerModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
selectedLanguage,
|
||||
onSelectLanguage,
|
||||
title,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const renderItem = ({ item }: { item: { code: string; name: string } }) => {
|
||||
const isSelected = item.code === selectedLanguage;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.languageItem,
|
||||
isSelected && { backgroundColor: currentTheme.colors.primary + '20' }
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelectLanguage(item.code);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.languageName, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
<Text style={[styles.languageCode, { color: 'rgba(255,255,255,0.5)' }]}>
|
||||
{item.code.toUpperCase()}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: '#1a1a1a', paddingBottom: insets.bottom }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>{title}</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialIcons name="close" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<FlatList
|
||||
data={AVAILABLE_LANGUAGES}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.code}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.languageList}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface SubtitleSourceModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
selectedSource: string;
|
||||
onSelectSource: (value: 'internal' | 'external' | 'any') => void;
|
||||
}
|
||||
|
||||
const SubtitleSourceModal: React.FC<SubtitleSourceModalProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
selectedSource,
|
||||
onSelectSource,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={[styles.modalContent, { backgroundColor: '#1a1a1a', paddingBottom: insets.bottom, maxHeight: 400 }]}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Text style={styles.modalTitle}>Subtitle Source Priority</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialIcons name="close" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.languageList}>
|
||||
{SUBTITLE_SOURCE_OPTIONS.map((option) => {
|
||||
const isSelected = option.value === selectedSource;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.value}
|
||||
style={[
|
||||
styles.sourceItem,
|
||||
isSelected && { backgroundColor: currentTheme.colors.primary + '20', borderColor: currentTheme.colors.primary }
|
||||
]}
|
||||
onPress={() => {
|
||||
onSelectSource(option.value as 'internal' | 'external' | 'any');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<View style={styles.sourceItemContent}>
|
||||
<Text style={[styles.sourceLabel, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
<Text style={styles.sourceDescription}>
|
||||
{option.description}
|
||||
</Text>
|
||||
</View>
|
||||
{isSelected && (
|
||||
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const PlaybackSettingsScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
|
@ -17,6 +199,11 @@ const PlaybackSettingsScreen: React.FC = () => {
|
|||
const insets = useSafeAreaInsets();
|
||||
const config = useRealtimeConfig();
|
||||
|
||||
// Modal states
|
||||
const [showAudioLanguageModal, setShowAudioLanguageModal] = useState(false);
|
||||
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
|
||||
const [showSubtitleSourceModal, setShowSubtitleSourceModal] = useState(false);
|
||||
|
||||
const isItemVisible = (itemId: string) => {
|
||||
if (!config?.items) return true;
|
||||
const item = config.items[itemId];
|
||||
|
|
@ -28,6 +215,16 @@ const PlaybackSettingsScreen: React.FC = () => {
|
|||
return itemIds.some(id => isItemVisible(id));
|
||||
};
|
||||
|
||||
const getLanguageName = (code: string) => {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === code);
|
||||
return lang ? lang.name : code.toUpperCase();
|
||||
};
|
||||
|
||||
const getSourceLabel = (value: string) => {
|
||||
const option = SUBTITLE_SOURCE_OPTIONS.find(o => o.value === value);
|
||||
return option ? option.label : 'Internal First';
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
|
@ -56,6 +253,43 @@ const PlaybackSettingsScreen: React.FC = () => {
|
|||
</SettingsCard>
|
||||
)}
|
||||
|
||||
{/* Audio & Subtitle Preferences */}
|
||||
<SettingsCard title="AUDIO & SUBTITLES">
|
||||
<SettingItem
|
||||
title="Preferred Audio Language"
|
||||
description={getLanguageName(settings?.preferredAudioLanguage || 'en')}
|
||||
icon="volume-up"
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => setShowAudioLanguageModal(true)}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Preferred Subtitle Language"
|
||||
description={getLanguageName(settings?.preferredSubtitleLanguage || 'en')}
|
||||
icon="subtitles"
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => setShowSubtitleLanguageModal(true)}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Subtitle Source Priority"
|
||||
description={getSourceLabel(settings?.subtitleSourcePreference || 'internal')}
|
||||
icon="source"
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => setShowSubtitleSourceModal(true)}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Auto-Select Subtitles"
|
||||
description="Automatically select subtitles matching your preferences"
|
||||
icon="auto-fix"
|
||||
renderControl={() => (
|
||||
<CustomSwitch
|
||||
value={settings?.enableSubtitleAutoSelect ?? true}
|
||||
onValueChange={(value) => updateSetting('enableSubtitleAutoSelect', value)}
|
||||
/>
|
||||
)}
|
||||
isLast
|
||||
/>
|
||||
</SettingsCard>
|
||||
|
||||
{hasVisibleItems(['show_trailers', 'enable_downloads']) && (
|
||||
<SettingsCard title="MEDIA">
|
||||
{isItemVisible('show_trailers') && (
|
||||
|
|
@ -103,6 +337,28 @@ const PlaybackSettingsScreen: React.FC = () => {
|
|||
</SettingsCard>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Language Picker Modals */}
|
||||
<LanguagePickerModal
|
||||
visible={showAudioLanguageModal}
|
||||
onClose={() => setShowAudioLanguageModal(false)}
|
||||
selectedLanguage={settings?.preferredAudioLanguage || 'en'}
|
||||
onSelectLanguage={(code) => updateSetting('preferredAudioLanguage', code)}
|
||||
title="Preferred Audio Language"
|
||||
/>
|
||||
<LanguagePickerModal
|
||||
visible={showSubtitleLanguageModal}
|
||||
onClose={() => setShowSubtitleLanguageModal(false)}
|
||||
selectedLanguage={settings?.preferredSubtitleLanguage || 'en'}
|
||||
onSelectLanguage={(code) => updateSetting('preferredSubtitleLanguage', code)}
|
||||
title="Preferred Subtitle Language"
|
||||
/>
|
||||
<SubtitleSourceModal
|
||||
visible={showSubtitleSourceModal}
|
||||
onClose={() => setShowSubtitleSourceModal(false)}
|
||||
selectedSource={settings?.subtitleSourcePreference || 'internal'}
|
||||
onSelectSource={(value) => updateSetting('subtitleSourcePreference', value)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
|
@ -117,6 +373,76 @@ const styles = StyleSheet.create({
|
|||
scrollContent: {
|
||||
paddingTop: 16,
|
||||
},
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
maxHeight: '70%',
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
modalTitle: {
|
||||
color: '#fff',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
closeButton: {
|
||||
padding: 4,
|
||||
},
|
||||
languageList: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
languageItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
marginVertical: 2,
|
||||
},
|
||||
languageName: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
},
|
||||
languageCode: {
|
||||
fontSize: 12,
|
||||
marginRight: 12,
|
||||
},
|
||||
sourceItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
marginVertical: 6,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
},
|
||||
sourceItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
sourceLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
sourceDescription: {
|
||||
fontSize: 13,
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
},
|
||||
});
|
||||
|
||||
export default PlaybackSettingsScreen;
|
||||
|
|
|
|||
Loading…
Reference in a new issue