mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-20 16:22:04 +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 { storageService } from '../../services/storageService';
|
||||||
import stremioService from '../../services/stremioService';
|
import stremioService from '../../services/stremioService';
|
||||||
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
|
import { WyzieSubtitle, SubtitleCue } from './utils/playerTypes';
|
||||||
|
import { findBestSubtitleTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const DEBUG_MODE = false;
|
const DEBUG_MODE = false;
|
||||||
|
|
@ -120,6 +121,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
const [useCustomSubtitles, setUseCustomSubtitles] = useState(false);
|
const [useCustomSubtitles, setUseCustomSubtitles] = useState(false);
|
||||||
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]);
|
||||||
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
const [currentSubtitle, setCurrentSubtitle] = useState<string>('');
|
||||||
|
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Subtitle customization state
|
// Subtitle customization state
|
||||||
const [subtitleSize, setSubtitleSize] = useState(28);
|
const [subtitleSize, setSubtitleSize] = useState(28);
|
||||||
|
|
@ -136,6 +138,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2);
|
const [subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier] = useState(1.2);
|
||||||
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0);
|
const [subtitleOffsetSec, setSubtitleOffsetSec] = useState(0);
|
||||||
|
|
||||||
|
// Track auto-selection ref to prevent duplicate selections
|
||||||
|
const hasAutoSelectedTracks = useRef(false);
|
||||||
|
|
||||||
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
|
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
|
||||||
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
|
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
|
||||||
const hasLogo = metadata && metadata.logo;
|
const hasLogo = metadata && metadata.logo;
|
||||||
|
|
@ -299,6 +304,52 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
playerState.setIsVideoLoaded(true);
|
playerState.setIsVideoLoaded(true);
|
||||||
openingAnimation.completeOpeningAnimation();
|
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
|
// Handle Resume - check both initialPosition and initialSeekTargetRef
|
||||||
const resumeTarget = watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
|
const resumeTarget = watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
|
||||||
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && videoDuration > 0) {
|
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && videoDuration > 0) {
|
||||||
|
|
@ -332,6 +383,45 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [playerState.currentTime, playerState.isDragging, playerState.isSeeking, setupHook.isAppBackgrounded]);
|
}, [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
|
// Sync custom subtitle text with current playback time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!useCustomSubtitles || customSubtitles.length === 0) return;
|
if (!useCustomSubtitles || customSubtitles.length === 0) return;
|
||||||
|
|
@ -496,6 +586,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
|
|
||||||
setAvailableSubtitles(subs);
|
setAvailableSubtitles(subs);
|
||||||
logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`);
|
logger.info(`[AndroidVideoPlayer] Fetched ${subs.length} addon subtitles`);
|
||||||
|
// Auto-selection is now handled by useEffect that waits for internal tracks
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e);
|
logger.error('[AndroidVideoPlayer] Error fetching addon subtitles', e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -523,6 +614,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
const parsedCues = parseSRT(srtContent);
|
const parsedCues = parseSRT(srtContent);
|
||||||
setCustomSubtitles(parsedCues);
|
setCustomSubtitles(parsedCues);
|
||||||
setUseCustomSubtitles(true);
|
setUseCustomSubtitles(true);
|
||||||
|
setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
|
||||||
|
|
||||||
// Disable MPV's built-in subtitle track when using custom subtitles
|
// Disable MPV's built-in subtitle track when using custom subtitles
|
||||||
tracksHook.setSelectedTextTrack(-1);
|
tracksHook.setSelectedTextTrack(-1);
|
||||||
|
|
@ -549,6 +641,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
setUseCustomSubtitles(false);
|
setUseCustomSubtitles(false);
|
||||||
setCustomSubtitles([]);
|
setCustomSubtitles([]);
|
||||||
setCurrentSubtitle('');
|
setCurrentSubtitle('');
|
||||||
|
setSelectedExternalSubtitleId(null); // Clear external selection
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const cycleResizeMode = useCallback(() => {
|
const cycleResizeMode = useCallback(() => {
|
||||||
|
|
@ -893,6 +986,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier}
|
setSubtitleLineHeightMultiplier={setSubtitleLineHeightMultiplier}
|
||||||
subtitleOffsetSec={subtitleOffsetSec}
|
subtitleOffsetSec={subtitleOffsetSec}
|
||||||
setSubtitleOffsetSec={setSubtitleOffsetSec}
|
setSubtitleOffsetSec={setSubtitleOffsetSec}
|
||||||
|
selectedExternalSubtitleId={selectedExternalSubtitleId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SourcesModal
|
<SourcesModal
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ import { logger } from '../../utils/logger';
|
||||||
import { formatTime } from './utils/playerUtils';
|
import { formatTime } from './utils/playerUtils';
|
||||||
import { WyzieSubtitle } from './utils/playerTypes';
|
import { WyzieSubtitle } from './utils/playerTypes';
|
||||||
import { parseSRT } from './utils/subtitleParser';
|
import { parseSRT } from './utils/subtitleParser';
|
||||||
|
import { findBestSubtitleTrack, autoSelectAudioTrack, findBestAudioTrack } from './utils/trackSelectionUtils';
|
||||||
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
|
|
||||||
// Player route params interface
|
// Player route params interface
|
||||||
interface PlayerRouteParams {
|
interface PlayerRouteParams {
|
||||||
|
|
@ -130,6 +132,10 @@ const KSPlayerCore: React.FC = () => {
|
||||||
const tracks = usePlayerTracks();
|
const tracks = usePlayerTracks();
|
||||||
const { ksPlayerRef, seek } = useKSPlayer();
|
const { ksPlayerRef, seek } = useKSPlayer();
|
||||||
const customSubs = useCustomSubtitles();
|
const customSubs = useCustomSubtitles();
|
||||||
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
// Track auto-selection refs to prevent duplicate selections
|
||||||
|
const hasAutoSelectedTracks = useRef(false);
|
||||||
|
|
||||||
// Next Episode Hook
|
// Next Episode Hook
|
||||||
const { nextEpisode, currentEpisodeDescription } = useNextEpisode({
|
const { nextEpisode, currentEpisodeDescription } = useNextEpisode({
|
||||||
|
|
@ -267,19 +273,8 @@ const KSPlayerCore: React.FC = () => {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
customSubs.setAvailableSubtitles(subs);
|
customSubs.setAvailableSubtitles(subs);
|
||||||
|
// Auto-selection is now handled by useEffect that waits for internal tracks
|
||||||
if (autoSelectEnglish) {
|
// This ensures internal tracks are considered before falling back to external
|
||||||
const englishSubtitle = subs.find(sub =>
|
|
||||||
sub.language.includes('en') || sub.display.toLowerCase().includes('english')
|
|
||||||
);
|
|
||||||
if (englishSubtitle) {
|
|
||||||
loadWyzieSubtitle(englishSubtitle);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!autoSelectEnglish) {
|
|
||||||
modals.setShowSubtitleLanguageModal(true);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('[VideoPlayer] Error fetching subtitles', e);
|
logger.error('[VideoPlayer] Error fetching subtitles', e);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -302,6 +297,7 @@ const KSPlayerCore: React.FC = () => {
|
||||||
const parsedCues = parseSRT(srtContent);
|
const parsedCues = parseSRT(srtContent);
|
||||||
customSubs.setCustomSubtitles(parsedCues);
|
customSubs.setCustomSubtitles(parsedCues);
|
||||||
customSubs.setUseCustomSubtitles(true);
|
customSubs.setUseCustomSubtitles(true);
|
||||||
|
customSubs.setSelectedExternalSubtitleId(subtitle.id); // Track the selected external subtitle
|
||||||
tracks.selectTextTrack(-1);
|
tracks.selectTextTrack(-1);
|
||||||
|
|
||||||
const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0);
|
const adjustedTime = currentTime + (customSubs.subtitleOffsetSec || 0);
|
||||||
|
|
@ -322,6 +318,45 @@ const KSPlayerCore: React.FC = () => {
|
||||||
}
|
}
|
||||||
}, [imdbId]);
|
}, [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
|
// Sync custom subtitle text with current playback time
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!customSubs.useCustomSubtitles || customSubs.customSubtitles.length === 0) return;
|
if (!customSubs.useCustomSubtitles || customSubs.customSubtitles.length === 0) return;
|
||||||
|
|
@ -347,6 +382,45 @@ const KSPlayerCore: React.FC = () => {
|
||||||
setIsPlayerReady(true);
|
setIsPlayerReady(true);
|
||||||
openingAnim.completeOpeningAnimation();
|
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
|
// Initial Seek
|
||||||
const resumeTarget = routeInitialPosition || watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
|
const resumeTarget = routeInitialPosition || watchProgress.initialPosition || watchProgress.initialSeekTargetRef?.current;
|
||||||
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && data.duration > 0) {
|
if (resumeTarget && resumeTarget > 0 && !watchProgress.showResumeOverlay && data.duration > 0) {
|
||||||
|
|
@ -800,8 +874,10 @@ const KSPlayerCore: React.FC = () => {
|
||||||
selectTextTrack={handleSelectTextTrack}
|
selectTextTrack={handleSelectTextTrack}
|
||||||
disableCustomSubtitles={() => {
|
disableCustomSubtitles={() => {
|
||||||
customSubs.setUseCustomSubtitles(false);
|
customSubs.setUseCustomSubtitles(false);
|
||||||
|
customSubs.setSelectedExternalSubtitleId(null); // Clear external selection
|
||||||
handleSelectTextTrack(-1);
|
handleSelectTextTrack(-1);
|
||||||
}}
|
}}
|
||||||
|
selectedExternalSubtitleId={customSubs.selectedExternalSubtitleId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<SourcesModal
|
<SourcesModal
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ export const useCustomSubtitles = () => {
|
||||||
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
|
const [currentFormattedSegments, setCurrentFormattedSegments] = useState<SubtitleSegment[][]>([]);
|
||||||
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
|
const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]);
|
||||||
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
|
const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false);
|
||||||
|
const [selectedExternalSubtitleId, setSelectedExternalSubtitleId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Loading State
|
// Loading State
|
||||||
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
|
const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false);
|
||||||
|
|
@ -91,6 +92,7 @@ export const useCustomSubtitles = () => {
|
||||||
currentFormattedSegments, setCurrentFormattedSegments,
|
currentFormattedSegments, setCurrentFormattedSegments,
|
||||||
availableSubtitles, setAvailableSubtitles,
|
availableSubtitles, setAvailableSubtitles,
|
||||||
useCustomSubtitles, setUseCustomSubtitles,
|
useCustomSubtitles, setUseCustomSubtitles,
|
||||||
|
selectedExternalSubtitleId, setSelectedExternalSubtitleId,
|
||||||
isLoadingSubtitles, setIsLoadingSubtitles,
|
isLoadingSubtitles, setIsLoadingSubtitles,
|
||||||
isLoadingSubtitleList, setIsLoadingSubtitleList,
|
isLoadingSubtitleList, setIsLoadingSubtitleList,
|
||||||
subtitleSize, setSubtitleSize,
|
subtitleSize, setSubtitleSize,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@ interface SubtitleModalsProps {
|
||||||
setSubtitleLineHeightMultiplier: (n: number) => void;
|
setSubtitleLineHeightMultiplier: (n: number) => void;
|
||||||
subtitleOffsetSec: number;
|
subtitleOffsetSec: number;
|
||||||
setSubtitleOffsetSec: (n: number) => void;
|
setSubtitleOffsetSec: (n: number) => void;
|
||||||
|
selectedExternalSubtitleId?: string | null; // ID of currently selected external/addon subtitle
|
||||||
}
|
}
|
||||||
|
|
||||||
const MorphingTab = ({ label, isSelected, onPress }: any) => {
|
const MorphingTab = ({ label, isSelected, onPress }: any) => {
|
||||||
|
|
@ -91,11 +92,15 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
||||||
subtitleBottomOffset, setSubtitleBottomOffset, subtitleLetterSpacing, setSubtitleLetterSpacing,
|
subtitleBottomOffset, setSubtitleBottomOffset, subtitleLetterSpacing, setSubtitleLetterSpacing,
|
||||||
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, subtitleOffsetSec, setSubtitleOffsetSec,
|
subtitleLineHeightMultiplier, setSubtitleLineHeightMultiplier, subtitleOffsetSec, setSubtitleOffsetSec,
|
||||||
setSubtitlesAutoSelect,
|
setSubtitlesAutoSelect,
|
||||||
|
selectedExternalSubtitleId,
|
||||||
}) => {
|
}) => {
|
||||||
const { width, height } = useWindowDimensions();
|
const { width, height } = useWindowDimensions();
|
||||||
const isIos = Platform.OS === 'ios';
|
const isIos = Platform.OS === 'ios';
|
||||||
const isLandscape = width > height;
|
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 [activeTab, setActiveTab] = React.useState<'built-in' | 'addon' | 'appearance'>('built-in');
|
||||||
|
|
||||||
const isCompact = width < 360 || height < 640;
|
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)
|
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)
|
gpuMode: 'gpu' | 'gpu-next'; // GPU rendering mode: gpu (standard) or gpu-next (advanced HDR/color)
|
||||||
showDiscover: boolean;
|
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 = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
|
@ -183,6 +188,11 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
decoderMode: 'auto', // Default to auto (best compatibility and performance)
|
decoderMode: 'auto', // Default to auto (best compatibility and performance)
|
||||||
gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR)
|
gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR)
|
||||||
showDiscover: true, // Show Discover section in SearchScreen
|
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';
|
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useState, useCallback } from 'react';
|
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 { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
@ -9,6 +9,188 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
import ScreenHeader from '../../components/common/ScreenHeader';
|
import ScreenHeader from '../../components/common/ScreenHeader';
|
||||||
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
|
import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents';
|
||||||
import { useRealtimeConfig } from '../../hooks/useRealtimeConfig';
|
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 PlaybackSettingsScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
|
@ -17,6 +199,11 @@ const PlaybackSettingsScreen: React.FC = () => {
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const config = useRealtimeConfig();
|
const config = useRealtimeConfig();
|
||||||
|
|
||||||
|
// Modal states
|
||||||
|
const [showAudioLanguageModal, setShowAudioLanguageModal] = useState(false);
|
||||||
|
const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
|
||||||
|
const [showSubtitleSourceModal, setShowSubtitleSourceModal] = useState(false);
|
||||||
|
|
||||||
const isItemVisible = (itemId: string) => {
|
const isItemVisible = (itemId: string) => {
|
||||||
if (!config?.items) return true;
|
if (!config?.items) return true;
|
||||||
const item = config.items[itemId];
|
const item = config.items[itemId];
|
||||||
|
|
@ -28,6 +215,16 @@ const PlaybackSettingsScreen: React.FC = () => {
|
||||||
return itemIds.some(id => isItemVisible(id));
|
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 (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar barStyle="light-content" />
|
<StatusBar barStyle="light-content" />
|
||||||
|
|
@ -56,6 +253,43 @@ const PlaybackSettingsScreen: React.FC = () => {
|
||||||
</SettingsCard>
|
</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']) && (
|
{hasVisibleItems(['show_trailers', 'enable_downloads']) && (
|
||||||
<SettingsCard title="MEDIA">
|
<SettingsCard title="MEDIA">
|
||||||
{isItemVisible('show_trailers') && (
|
{isItemVisible('show_trailers') && (
|
||||||
|
|
@ -103,6 +337,28 @@ const PlaybackSettingsScreen: React.FC = () => {
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -117,6 +373,76 @@ const styles = StyleSheet.create({
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingTop: 16,
|
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;
|
export default PlaybackSettingsScreen;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue