refactor(settings): unify language picker across app and playback settings

This commit is contained in:
YagUber 2026-04-13 19:15:12 -05:00
parent eec51f787c
commit 681a20dd8a
No known key found for this signature in database
3 changed files with 57 additions and 145 deletions

View file

@ -436,7 +436,13 @@ const SettingsScreen: React.FC = () => {
return ( return (
<> <>
<SettingsCard title="GENERAL" isTablet={isTablet}> <SettingsCard title="GENERAL" isTablet={isTablet}>
<LanguageSettingItem isTablet={isTablet} isLast /> <LanguageSettingItem
title={t('settings.language')}
value={i18n.language}
onChange={(code) => i18n.changeLanguage(code)}
isTablet={isTablet}
isLast
/>
</SettingsCard> </SettingsCard>
<AppearanceSettingsContent isTablet={isTablet} /> <AppearanceSettingsContent isTablet={isTablet} />
</> </>
@ -703,7 +709,11 @@ const SettingsScreen: React.FC = () => {
(settingsConfig?.categories?.['playback']?.visible !== false) (settingsConfig?.categories?.['playback']?.visible !== false)
) && ( ) && (
<SettingsCard title="GENERAL"> <SettingsCard title="GENERAL">
<LanguageSettingItem /> <LanguageSettingItem
title={t('settings.language')}
value={i18n.language}
onChange={(code) => i18n.changeLanguage(code)}
/>
{(settingsConfig?.categories?.['content']?.visible !== false) && ( {(settingsConfig?.categories?.['content']?.visible !== false) && (
<SettingItem <SettingItem
title={t('settings.content_discovery')} title={t('settings.content_discovery')}

View file

@ -1,31 +1,53 @@
import React, { useCallback, useRef } from 'react'; import React, { useCallback, useMemo, useRef } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet'; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet';
import { Feather } from '@expo/vector-icons'; import { Feather } from '@expo/vector-icons';
import { useTranslation } from 'react-i18next';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useBottomSheetBackHandler } from '../../hooks/useBottomSheetBackHandler'; import { useBottomSheetBackHandler } from '../../hooks/useBottomSheetBackHandler';
import { ChevronRight, SettingItem } from './SettingsComponents'; import { ChevronRight, SettingItem } from './SettingsComponents';
import { LOCALES } from '../../constants/locales'; import { LOCALES } from '../../constants/locales';
const SORTED_LOCALES = [...LOCALES].sort((a, b) => a.name.localeCompare(b.name)); export interface LanguageOption {
code: string;
name: string;
}
interface LanguageSettingItemProps { interface LanguageSettingItemProps {
title: string;
value: string;
onChange: (code: string) => void;
languages?: LanguageOption[];
isTablet?: boolean; isTablet?: boolean;
isLast?: boolean; isLast?: boolean;
} }
export const LanguageSettingItem: React.FC<LanguageSettingItemProps> = ({ isTablet = false, isLast = false }) => { export const LanguageSettingItem: React.FC<LanguageSettingItemProps> = ({
const { t, i18n } = useTranslation(); title,
value,
onChange,
languages,
isTablet = false,
isLast = false,
}) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const sheetRef = useRef<BottomSheetModal>(null); const sheetRef = useRef<BottomSheetModal>(null);
const { onChange, onDismiss } = useBottomSheetBackHandler(); const { onChange: onSheetChange, onDismiss } = useBottomSheetBackHandler();
const sortedLanguages = useMemo(() => {
const sorted = [...(languages ?? LOCALES)].sort((a, b) => a.name.localeCompare(b.name));
const selectedIndex = sorted.findIndex(l => l.code === value || l.code === value?.split('-')[0]);
if (selectedIndex > 0) {
const [selected] = sorted.splice(selectedIndex, 1);
sorted.unshift(selected);
}
return sorted;
}, [languages, value]);
const currentLocale = const currentLocale =
LOCALES.find(l => l.code === i18n.language) ?? sortedLanguages.find(l => l.code === value) ??
LOCALES.find(l => l.code === i18n.language?.split('-')[0]); sortedLanguages.find(l => l.code === value?.split('-')[0]);
const renderBackdrop = useCallback( const renderBackdrop = useCallback(
(props: any) => ( (props: any) => (
@ -37,7 +59,7 @@ export const LanguageSettingItem: React.FC<LanguageSettingItemProps> = ({ isTabl
return ( return (
<> <>
<SettingItem <SettingItem
title={t('settings.language')} title={title}
description={currentLocale?.name} description={currentLocale?.name}
icon="globe" icon="globe"
renderControl={() => <ChevronRight />} renderControl={() => <ChevronRight />}
@ -61,12 +83,12 @@ export const LanguageSettingItem: React.FC<LanguageSettingItemProps> = ({ isTabl
backgroundColor: currentTheme.colors.mediumGray, backgroundColor: currentTheme.colors.mediumGray,
width: 40, width: 40,
}} }}
onChange={onChange(sheetRef)} onChange={onSheetChange(sheetRef)}
onDismiss={onDismiss(sheetRef)} onDismiss={onDismiss(sheetRef)}
> >
<View style={[styles.sheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}> <View style={[styles.sheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
<Text style={[styles.sheetTitle, { color: currentTheme.colors.white }]}> <Text style={[styles.sheetTitle, { color: currentTheme.colors.white }]}>
{t('settings.select_language')} {title}
</Text> </Text>
<TouchableOpacity onPress={() => sheetRef.current?.dismiss()}> <TouchableOpacity onPress={() => sheetRef.current?.dismiss()}>
<Feather name="x" size={24} color={currentTheme.colors.lightGray} /> <Feather name="x" size={24} color={currentTheme.colors.lightGray} />
@ -76,9 +98,8 @@ export const LanguageSettingItem: React.FC<LanguageSettingItemProps> = ({ isTabl
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }} style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
contentContainerStyle={[styles.sheetContent, { paddingBottom: insets.bottom + 16 }]} contentContainerStyle={[styles.sheetContent, { paddingBottom: insets.bottom + 16 }]}
> >
{SORTED_LOCALES.map(l => { {sortedLanguages.map(l => {
const isSelected = i18n.language === l.code || const isSelected = value === l.code || value?.split('-')[0] === l.code;
i18n.language?.split('-')[0] === l.code;
return ( return (
<TouchableOpacity <TouchableOpacity
key={l.code} key={l.code}
@ -87,7 +108,7 @@ export const LanguageSettingItem: React.FC<LanguageSettingItemProps> = ({ isTabl
isSelected && { backgroundColor: currentTheme.colors.primary + '20' }, isSelected && { backgroundColor: currentTheme.colors.primary + '20' },
]} ]}
onPress={() => { onPress={() => {
i18n.changeLanguage(l.code); onChange(l.code);
sheetRef.current?.dismiss(); sheetRef.current?.dismiss();
}} }}
> >

View file

@ -8,6 +8,7 @@ import { useSettings } from '../../hooks/useSettings';
import { RootStackParamList } from '../../navigation/AppNavigator'; 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 { LanguageSettingItem } from './LanguageSettingItem';
import { useRealtimeConfig } from '../../hooks/useRealtimeConfig'; import { useRealtimeConfig } from '../../hooks/useRealtimeConfig';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
@ -156,31 +157,10 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
<MaterialIcons name="skip-next" size={18} color={currentTheme.colors.primary} /> <MaterialIcons name="skip-next" size={18} color={currentTheme.colors.primary} />
); );
// Bottom sheet refs
const audioLanguageSheetRef = useRef<BottomSheetModal>(null);
const subtitleLanguageSheetRef = useRef<BottomSheetModal>(null);
const subtitleSourceSheetRef = useRef<BottomSheetModal>(null); const subtitleSourceSheetRef = useRef<BottomSheetModal>(null);
// Snap points
const languageSnapPoints = useMemo(() => ['70%'], []);
const sourceSnapPoints = useMemo(() => ['45%'], []); const sourceSnapPoints = useMemo(() => ['45%'], []);
// Handlers to present sheets - ensure only one is open at a time
const openAudioLanguageSheet = useCallback(() => {
subtitleLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss();
setTimeout(() => audioLanguageSheetRef.current?.present(), 100);
}, []);
const openSubtitleLanguageSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss();
setTimeout(() => subtitleLanguageSheetRef.current?.present(), 100);
}, []);
const openSubtitleSourceSheet = useCallback(() => { const openSubtitleSourceSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss();
subtitleLanguageSheetRef.current?.dismiss();
setTimeout(() => subtitleSourceSheetRef.current?.present(), 100); setTimeout(() => subtitleSourceSheetRef.current?.present(), 100);
}, []); }, []);
@ -195,11 +175,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
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 getSourceLabel = (value: string) => {
if (value === 'internal') return t('settings.options.internal_first'); if (value === 'internal') return t('settings.options.internal_first');
if (value === 'external') return t('settings.options.external_first'); if (value === 'external') return t('settings.options.external_first');
@ -220,16 +195,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
[] []
); );
const handleSelectAudioLanguage = (code: string) => {
updateSetting('preferredAudioLanguage', code);
audioLanguageSheetRef.current?.dismiss();
};
const handleSelectSubtitleLanguage = (code: string) => {
updateSetting('preferredSubtitleLanguage', code);
subtitleLanguageSheetRef.current?.dismiss();
};
const handleSelectSubtitleSource = (value: 'internal' | 'external' | 'any') => { const handleSelectSubtitleSource = (value: 'internal' | 'external' | 'any') => {
updateSetting('subtitleSourcePreference', value); updateSetting('subtitleSourcePreference', value);
subtitleSourceSheetRef.current?.dismiss(); subtitleSourceSheetRef.current?.dismiss();
@ -322,20 +287,18 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
{/* Audio & Subtitle Preferences */} {/* Audio & Subtitle Preferences */}
<SettingsCard title={t('settings.sections.audio_subtitles')} isTablet={isTablet}> <SettingsCard title={t('settings.sections.audio_subtitles')} isTablet={isTablet}>
<SettingItem <LanguageSettingItem
title={t('settings.items.preferred_audio')} title={t('settings.items.preferred_audio')}
description={getLanguageName(settings?.preferredAudioLanguage || 'en')} value={settings?.preferredAudioLanguage || 'en'}
icon="volume-2" onChange={(code) => updateSetting('preferredAudioLanguage', code)}
renderControl={() => <ChevronRight />} languages={AVAILABLE_LANGUAGES}
onPress={openAudioLanguageSheet}
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <LanguageSettingItem
title={t('settings.items.preferred_subtitle')} title={t('settings.items.preferred_subtitle')}
description={getLanguageName(settings?.preferredSubtitleLanguage || 'en')} value={settings?.preferredSubtitleLanguage || 'en'}
icon="type" onChange={(code) => updateSetting('preferredSubtitleLanguage', code)}
renderControl={() => <ChevronRight />} languages={AVAILABLE_LANGUAGES}
onPress={openSubtitleLanguageSheet}
isTablet={isTablet} isTablet={isTablet}
/> />
<SettingItem <SettingItem
@ -411,88 +374,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
</SettingsCard> </SettingsCard>
)} )}
{/* Audio Language Bottom Sheet */}
<BottomSheetModal
ref={audioLanguageSheetRef}
index={0}
snapPoints={languageSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('settings.items.preferred_audio')}</Text>
</View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{AVAILABLE_LANGUAGES.map((lang) => {
const isSelected = lang.code === (settings?.preferredAudioLanguage || 'en');
return (
<TouchableOpacity
key={lang.code}
style={[
styles.languageItem,
isSelected && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => handleSelectAudioLanguage(lang.code)}
>
<Text style={[styles.languageName, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{lang.name}
</Text>
<Text style={styles.languageCode}>
{lang.code.toUpperCase()}
</Text>
{isSelected && (
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
);
})}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Subtitle Language Bottom Sheet */}
<BottomSheetModal
ref={subtitleLanguageSheetRef}
index={0}
snapPoints={languageSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('settings.items.preferred_subtitle')}</Text>
</View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{AVAILABLE_LANGUAGES.map((lang) => {
const isSelected = lang.code === (settings?.preferredSubtitleLanguage || 'en');
return (
<TouchableOpacity
key={lang.code}
style={[
styles.languageItem,
isSelected && { backgroundColor: currentTheme.colors.primary + '20' }
]}
onPress={() => handleSelectSubtitleLanguage(lang.code)}
>
<Text style={[styles.languageName, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{lang.name}
</Text>
<Text style={styles.languageCode}>
{lang.code.toUpperCase()}
</Text>
{isSelected && (
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
);
})}
</BottomSheetScrollView>
</BottomSheetModal>
{/* Subtitle Source Priority Bottom Sheet */} {/* Subtitle Source Priority Bottom Sheet */}
<BottomSheetModal <BottomSheetModal
ref={subtitleSourceSheetRef} ref={subtitleSourceSheetRef}