mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
added arabic
This commit is contained in:
parent
437645d5fd
commit
334d0b1863
12 changed files with 1534 additions and 109 deletions
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from 'react-native';
|
||||
import { InteractionManager } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -16,7 +17,6 @@ import { useTheme } from '../../contexts/ThemeContext';
|
|||
const { width } = Dimensions.get('window');
|
||||
const COLUMN_COUNT = 7; // 7 days in a week
|
||||
const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
interface CalendarEpisode {
|
||||
id: string;
|
||||
|
|
@ -76,8 +76,19 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
|
|||
episodes = [],
|
||||
onSelectDate
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const weekDays = [
|
||||
t('common.days_short.sun'),
|
||||
t('common.days_short.mon'),
|
||||
t('common.days_short.tue'),
|
||||
t('common.days_short.wed'),
|
||||
t('common.days_short.thu'),
|
||||
t('common.days_short.fri'),
|
||||
t('common.days_short.sat')
|
||||
];
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const [uiReady, setUiReady] = useState(false);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { useTheme } from '../../contexts/ThemeContext';
|
|||
import ContentItem from './ContentItem';
|
||||
import Animated, { FadeIn, Layout } from 'react-native-reanimated';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { getFormattedCatalogName, getCatalogDisplayName } from '../../utils/catalogNameUtils';
|
||||
|
||||
interface CatalogSectionProps {
|
||||
catalog: CatalogContent;
|
||||
|
|
@ -74,10 +75,44 @@ const posterLayout = calculatePosterLayout(width);
|
|||
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||
|
||||
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Use state for the display name to handle async custom name resolution
|
||||
const [displayName, setDisplayName] = React.useState(catalog.name);
|
||||
|
||||
// Re-resolve and format the name when language or catalog data changes
|
||||
React.useEffect(() => {
|
||||
const resolveName = async () => {
|
||||
// 1. Check for user-defined custom name
|
||||
const customName = await getCatalogDisplayName(
|
||||
catalog.addon,
|
||||
catalog.type,
|
||||
catalog.id,
|
||||
catalog.originalName || catalog.name
|
||||
);
|
||||
|
||||
// 2. If it's a user setting, use it as is
|
||||
if (customName !== (catalog.originalName || catalog.name)) {
|
||||
setDisplayName(customName);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Otherwise, use localized formatting
|
||||
const formatted = getFormattedCatalogName(
|
||||
customName,
|
||||
catalog.type,
|
||||
t('home.movies'),
|
||||
t('home.tv_shows'),
|
||||
t('home.channels')
|
||||
);
|
||||
setDisplayName(formatted);
|
||||
};
|
||||
|
||||
resolveName();
|
||||
}, [catalog.addon, catalog.id, catalog.type, catalog.name, catalog.originalName, i18n.language, t]);
|
||||
|
||||
const handleContentPress = useCallback((id: string, type: string) => {
|
||||
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
|
||||
}, [navigation, catalog.addon]);
|
||||
|
|
@ -119,7 +154,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{catalog.name}
|
||||
{displayName}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
|
|
|
|||
1172
src/i18n/locales/ar.json
Normal file
1172
src/i18n/locales/ar.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -24,6 +24,15 @@
|
|||
"minutes_ago": "{{count}}m ago",
|
||||
"hours_ago": "{{count}}h ago",
|
||||
"days_ago": "{{count}}d ago"
|
||||
},
|
||||
"days_short": {
|
||||
"sun": "Sun",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
|
|
@ -41,6 +50,10 @@
|
|||
"sign_in_desc": "You can sign in anytime from Settings → Account",
|
||||
"view_all": "View All",
|
||||
"this_week": "This Week",
|
||||
"upcoming": "Upcoming",
|
||||
"recently_released": "Recently Released",
|
||||
"no_scheduled_episodes": "Series with No Scheduled Episodes",
|
||||
"check_back_later": "Check back later",
|
||||
"continue_watching": "Continue Watching",
|
||||
"up_next": "Up Next",
|
||||
"up_next_caps": "UP NEXT",
|
||||
|
|
@ -306,7 +319,8 @@
|
|||
"all": "All",
|
||||
"failed_tmdb": "Failed to load content from TMDB",
|
||||
"movies": "Movies",
|
||||
"tv_shows": "TV Shows"
|
||||
"tv_shows": "TV Shows",
|
||||
"channels": "Channels"
|
||||
},
|
||||
"streams": {
|
||||
"back_to_episodes": "Back to Episodes",
|
||||
|
|
@ -563,6 +577,7 @@
|
|||
"select_language": "Select Language",
|
||||
"english": "English",
|
||||
"portuguese": "Portuguese",
|
||||
"arabic": "Arabic",
|
||||
"account": "Account",
|
||||
"content_discovery": "Content & Discovery",
|
||||
"appearance": "Appearance",
|
||||
|
|
@ -871,6 +886,17 @@
|
|||
"tv_shows": "TV Shows"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendar",
|
||||
"loading": "Loading calendar...",
|
||||
"no_scheduled_episodes": "No scheduled episodes",
|
||||
"check_back_later": "Check back later",
|
||||
"showing_episodes_for": "Showing episodes for {{date}}",
|
||||
"show_all_episodes": "Show All Episodes",
|
||||
"no_episodes_for": "No episodes for {{date}}",
|
||||
"no_upcoming_found": "No upcoming episodes found",
|
||||
"add_series_desc": "Add series to your library to see their upcoming episodes here"
|
||||
},
|
||||
"mdblist": {
|
||||
"title": "Rating Sources",
|
||||
"status_disabled": "MDBList Disabled",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,15 @@
|
|||
"minutes_ago": "{{count}}m atrás",
|
||||
"hours_ago": "{{count}}h atrás",
|
||||
"days_ago": "{{count}}d atrás"
|
||||
},
|
||||
"days_short": {
|
||||
"sun": "Dom",
|
||||
"mon": "Seg",
|
||||
"tue": "Ter",
|
||||
"wed": "Qua",
|
||||
"thu": "Qui",
|
||||
"fri": "Sex",
|
||||
"sat": "Sáb"
|
||||
}
|
||||
},
|
||||
"home": {
|
||||
|
|
@ -41,6 +50,10 @@
|
|||
"sign_in_desc": "Você pode entrar a qualquer momento em Configurações → Conta",
|
||||
"view_all": "Ver Tudo",
|
||||
"this_week": "Esta Semana",
|
||||
"upcoming": "Próximos",
|
||||
"recently_released": "Lançados Recentemente",
|
||||
"no_scheduled_episodes": "Séries sem episódios agendados",
|
||||
"check_back_later": "Volte mais tarde",
|
||||
"continue_watching": "Continue Assistindo",
|
||||
"up_next": "A Seguir",
|
||||
"up_next_caps": "A SEGUIR",
|
||||
|
|
@ -306,7 +319,8 @@
|
|||
"all": "Todos",
|
||||
"failed_tmdb": "Falha ao carregar conteúdo do TMDB",
|
||||
"movies": "Filmes",
|
||||
"tv_shows": "Séries"
|
||||
"tv_shows": "Séries",
|
||||
"channels": "Canais"
|
||||
},
|
||||
"streams": {
|
||||
"back_to_episodes": "Voltar aos Episódios",
|
||||
|
|
@ -541,6 +555,7 @@
|
|||
"select_language": "Selecionar Idioma",
|
||||
"english": "Inglês",
|
||||
"portuguese": "Português",
|
||||
"arabic": "Árabe",
|
||||
"account": "Conta",
|
||||
"content_discovery": "Conteúdo e Descoberta",
|
||||
"appearance": "Aparência",
|
||||
|
|
@ -839,6 +854,17 @@
|
|||
"tv_shows": "Séries e TV"
|
||||
}
|
||||
},
|
||||
"calendar": {
|
||||
"title": "Calendário",
|
||||
"loading": "Carregando calendário...",
|
||||
"no_scheduled_episodes": "Sem episódios agendados",
|
||||
"check_back_later": "Volte mais tarde",
|
||||
"showing_episodes_for": "Mostrando episódios para {{date}}",
|
||||
"show_all_episodes": "Mostrar Todos os Episódios",
|
||||
"no_episodes_for": "Nenhum episódio para {{date}}",
|
||||
"no_upcoming_found": "Nenhum episódio futuro encontrado",
|
||||
"add_series_desc": "Adicione séries à sua biblioteca para ver os próximos episódios aqui"
|
||||
},
|
||||
"mdblist": {
|
||||
"title": "Fontes de Avaliação",
|
||||
"status_disabled": "MDBList Desativado",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import en from './locales/en.json';
|
||||
import pt from './locales/pt.json';
|
||||
import ar from './locales/ar.json';
|
||||
|
||||
export const resources = {
|
||||
en: { translation: en },
|
||||
pt: { translation: pt },
|
||||
ar: { translation: ar },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
import { InteractionManager } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -55,6 +56,7 @@ interface CalendarSection {
|
|||
}
|
||||
|
||||
const CalendarScreen = () => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { libraryItems, loading: libraryLoading } = useLibrary();
|
||||
const { currentTheme } = useTheme();
|
||||
|
|
@ -189,7 +191,7 @@ const CalendarScreen = () => {
|
|||
) : (
|
||||
<>
|
||||
<Text style={[styles.noEpisodesText, { color: currentTheme.colors.text }]}>
|
||||
No scheduled episodes
|
||||
{t('calendar.no_scheduled_episodes')}
|
||||
</Text>
|
||||
<View style={styles.dateContainer}>
|
||||
<MaterialIcons
|
||||
|
|
@ -197,7 +199,7 @@ const CalendarScreen = () => {
|
|||
size={16}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>Check back later</Text>
|
||||
<Text style={[styles.date, { color: currentTheme.colors.lightGray }]}>{t('calendar.check_back_later')}</Text>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -207,16 +209,28 @@ const CalendarScreen = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const renderSectionHeader = ({ section }: { section: CalendarSection }) => (
|
||||
<View style={[styles.sectionHeader, {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
borderBottomColor: currentTheme.colors.border
|
||||
}]}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
|
||||
{section.title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
const renderSectionHeader = ({ section }: { section: CalendarSection }) => {
|
||||
// Map section titles to translation keys
|
||||
const titleKeyMap: Record<string, string> = {
|
||||
'This Week': 'home.this_week',
|
||||
'Upcoming': 'home.upcoming',
|
||||
'Recently Released': 'home.recently_released',
|
||||
'Series with No Scheduled Episodes': 'home.no_scheduled_episodes'
|
||||
};
|
||||
|
||||
const displayTitle = titleKeyMap[section.title] ? t(titleKeyMap[section.title]) : section.title;
|
||||
|
||||
return (
|
||||
<View style={[styles.sectionHeader, {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
borderBottomColor: currentTheme.colors.border
|
||||
}]}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>
|
||||
{displayTitle}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Process all episodes once data is loaded - using memory-efficient approach
|
||||
const allEpisodes = React.useMemo(() => {
|
||||
|
|
@ -276,7 +290,7 @@ const CalendarScreen = () => {
|
|||
<StatusBar barStyle="light-content" />
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={styles.loadingText}>Loading calendar...</Text>
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>{t('calendar.loading')}</Text>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -293,14 +307,14 @@ const CalendarScreen = () => {
|
|||
>
|
||||
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Calendar</Text>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>{t('calendar.title')}</Text>
|
||||
<View style={{ width: 40 }} />
|
||||
</View>
|
||||
|
||||
{selectedDate && filteredEpisodes.length > 0 && (
|
||||
<View style={[styles.filterInfoContainer, { borderBottomColor: currentTheme.colors.border }]}>
|
||||
<Text style={[styles.filterInfoText, { color: currentTheme.colors.text }]}>
|
||||
Showing episodes for {format(selectedDate, 'MMMM d, yyyy')}
|
||||
{t('calendar.showing_episodes_for', { date: format(selectedDate, 'MMMM d, yyyy') })}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={clearDateFilter} style={styles.clearFilterButton}>
|
||||
<MaterialIcons name="close" size={18} color={currentTheme.colors.text} />
|
||||
|
|
@ -337,14 +351,14 @@ const CalendarScreen = () => {
|
|||
<View style={styles.emptyFilterContainer}>
|
||||
<MaterialIcons name="event-busy" size={48} color={currentTheme.colors.lightGray} />
|
||||
<Text style={[styles.emptyFilterText, { color: currentTheme.colors.text }]}>
|
||||
No episodes for {format(selectedDate, 'MMMM d, yyyy')}
|
||||
{t('calendar.no_episodes_for', { date: format(selectedDate, 'MMMM d, yyyy') })}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.clearFilterButtonLarge, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={clearDateFilter}
|
||||
>
|
||||
<Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.text }]}>
|
||||
Show All Episodes
|
||||
<Text style={[styles.clearFilterButtonText, { color: currentTheme.colors.white }]}>
|
||||
{t('calendar.show_all_episodes')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -373,10 +387,10 @@ const CalendarScreen = () => {
|
|||
<View style={styles.emptyContainer}>
|
||||
<MaterialIcons name="calendar-today" size={64} color={currentTheme.colors.lightGray} />
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.text }]}>
|
||||
No upcoming episodes found
|
||||
{t('calendar.no_upcoming_found')}
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Add series to your library to see their upcoming episodes here
|
||||
{t('calendar.add_series_desc')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ if (Platform.OS === 'ios') {
|
|||
}
|
||||
}
|
||||
import { logger } from '../utils/logger';
|
||||
import { getFormattedCatalogName } from '../utils/catalogNameUtils';
|
||||
import { useCustomCatalogNames } from '../hooks/useCustomCatalogNames';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import { catalogService, DataSource, StreamingContent } from '../services/catalogService';
|
||||
|
|
@ -60,6 +61,28 @@ const SPACING = {
|
|||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// Enhanced responsive breakpoints (matching CatalogSection)
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
};
|
||||
|
||||
const getDeviceType = (deviceWidth: number) => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
||||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
};
|
||||
|
||||
const deviceType = getDeviceType(width);
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
|
||||
// Dynamic column and spacing calculation based on screen width
|
||||
const calculateCatalogLayout = (screenWidth: number) => {
|
||||
const MIN_ITEM_WIDTH = 120;
|
||||
|
|
@ -130,14 +153,28 @@ const createStyles = (colors: any) => StyleSheet.create({
|
|||
color: colors.primary,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 34,
|
||||
fontWeight: '700',
|
||||
color: colors.white,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
paddingBottom: 4,
|
||||
paddingTop: 8,
|
||||
width: '100%',
|
||||
},
|
||||
titleContainer: {
|
||||
position: 'relative',
|
||||
marginBottom: SPACING.md,
|
||||
},
|
||||
catalogTitle: {
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 4,
|
||||
},
|
||||
titleUnderline: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
left: 16,
|
||||
borderRadius: 2,
|
||||
opacity: 0.8,
|
||||
},
|
||||
list: {
|
||||
padding: SPACING.lg,
|
||||
paddingTop: SPACING.sm,
|
||||
|
|
@ -330,19 +367,13 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
|
||||
// Create display name with proper type suffix
|
||||
const createDisplayName = (catalogName: string) => {
|
||||
if (!catalogName) return '';
|
||||
|
||||
// Check if the name already includes content type indicators
|
||||
const lowerName = catalogName.toLowerCase();
|
||||
const contentType = type === 'movie' ? t('catalog.movies') : type === 'series' ? t('catalog.tv_shows') : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
|
||||
|
||||
// If the name already contains type information, return as is
|
||||
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
|
||||
return catalogName;
|
||||
}
|
||||
|
||||
// Otherwise append the content type
|
||||
return `${catalogName} ${contentType}`;
|
||||
return getFormattedCatalogName(
|
||||
catalogName,
|
||||
type,
|
||||
t('catalog.movies'),
|
||||
t('catalog.tv_shows'),
|
||||
t('catalog.channels')
|
||||
);
|
||||
};
|
||||
|
||||
// Use actual catalog name if available, otherwise fallback to custom name or original name
|
||||
|
|
@ -350,7 +381,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
|
||||
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
|
||||
(genreFilter ? `${genreFilter} ${type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows')}` :
|
||||
(type === 'movie' ? t('catalog.movies') : type === 'series' ? t('catalog.tv_shows') : `${type.charAt(0).toUpperCase() + type.slice(1)}s`));
|
||||
(originalName ? createDisplayName(originalName) : (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))));
|
||||
|
||||
// Add effect to get the actual catalog name and filter extras from addon manifest
|
||||
useEffect(() => {
|
||||
|
|
@ -895,7 +926,25 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
<Text style={styles.backText}>{t('catalog.back')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.headerTitle}>{displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[
|
||||
styles.headerTitle,
|
||||
{
|
||||
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
|
||||
}
|
||||
]}>
|
||||
{displayName || originalName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.titleUnderline,
|
||||
{
|
||||
backgroundColor: colors.primary,
|
||||
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{renderLoadingState()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -914,7 +963,25 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
<Text style={styles.backText}>{t('catalog.back')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[
|
||||
styles.headerTitle,
|
||||
{
|
||||
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
|
||||
}
|
||||
]}>
|
||||
{displayName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.titleUnderline,
|
||||
{
|
||||
backgroundColor: colors.primary,
|
||||
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
{renderErrorState()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
|
@ -932,7 +999,25 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
|||
<Text style={styles.backText}>{t('catalog.back')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={styles.headerTitle}>{displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`}</Text>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[
|
||||
styles.headerTitle,
|
||||
{
|
||||
fontSize: isTV ? 38 : isLargeTablet ? 36 : isTablet ? 34 : 34,
|
||||
}
|
||||
]}>
|
||||
{displayName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
styles.titleUnderline,
|
||||
{
|
||||
backgroundColor: colors.primary,
|
||||
width: isTV ? 80 : isLargeTablet ? 72 : isTablet ? 64 : 56,
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Filter chip bar - shows when catalog has filterable extras */}
|
||||
{catalogExtras.length > 0 && (
|
||||
|
|
|
|||
|
|
@ -45,7 +45,11 @@ import * as Haptics from 'expo-haptics';
|
|||
import { tmdbService } from '../services/tmdbService';
|
||||
import { logger } from '../utils/logger';
|
||||
import { storageService } from '../services/storageService';
|
||||
import { getCatalogDisplayName, clearCustomNameCache } from '../utils/catalogNameUtils';
|
||||
import {
|
||||
getCatalogDisplayName,
|
||||
getFormattedCatalogName,
|
||||
clearCustomNameCache
|
||||
} from '../utils/catalogNameUtils';
|
||||
import { useHomeCatalogs } from '../hooks/useHomeCatalogs';
|
||||
import { useFeaturedContent } from '../hooks/useFeaturedContent';
|
||||
import { useSettings, settingsEmitter } from '../hooks/useSettings';
|
||||
|
|
@ -95,13 +99,6 @@ type HomeScreenListItem =
|
|||
| { type: 'welcome'; key: string }
|
||||
| { type: 'loadMore'; key: string };
|
||||
|
||||
// Sample categories (real app would get these from API)
|
||||
const SAMPLE_CATEGORIES: Category[] = [
|
||||
{ id: 'movie', name: 'Movies' },
|
||||
{ id: 'series', name: 'Series' },
|
||||
{ id: 'channel', name: 'Channels' },
|
||||
];
|
||||
|
||||
const SkeletonCatalog = React.memo(() => {
|
||||
const { currentTheme } = useTheme();
|
||||
return (
|
||||
|
|
@ -279,21 +276,13 @@ const HomeScreen = () => {
|
|||
const isCustom = displayName !== originalName;
|
||||
|
||||
if (!isCustom) {
|
||||
// De-duplicate repeated words (case-insensitive)
|
||||
const words = displayName.split(' ').filter(Boolean);
|
||||
const uniqueWords: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const w of words) {
|
||||
const lw = w.toLowerCase();
|
||||
if (!seen.has(lw)) { uniqueWords.push(w); seen.add(lw); }
|
||||
}
|
||||
displayName = uniqueWords.join(' ');
|
||||
|
||||
// Append content type if not present
|
||||
const contentType = catalog.type === 'movie' ? t('home.movies') : t('home.tv_shows');
|
||||
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
|
||||
displayName = `${displayName} ${contentType}`;
|
||||
}
|
||||
displayName = getFormattedCatalogName(
|
||||
displayName,
|
||||
catalog.type,
|
||||
t('home.movies'),
|
||||
t('home.tv_shows'),
|
||||
t('home.channels')
|
||||
);
|
||||
}
|
||||
|
||||
const catalogContent = {
|
||||
|
|
@ -301,6 +290,7 @@ const HomeScreen = () => {
|
|||
type: catalog.type,
|
||||
id: catalog.id,
|
||||
name: displayName,
|
||||
originalName: originalName,
|
||||
items
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -586,7 +586,11 @@ const SettingsScreen: React.FC = () => {
|
|||
<SettingsCard title="GENERAL">
|
||||
<SettingItem
|
||||
title={t('settings.language')}
|
||||
description={i18n.language === 'pt' ? t('settings.portuguese') : t('settings.english')}
|
||||
description={
|
||||
i18n.language === 'pt' ? t('settings.portuguese') :
|
||||
i18n.language === 'ar' ? t('settings.arabic') :
|
||||
t('settings.english')
|
||||
}
|
||||
icon="globe"
|
||||
renderControl={() => <ChevronRight />}
|
||||
onPress={() => setLanguageModalVisible(true)}
|
||||
|
|
@ -865,6 +869,28 @@ const SettingsScreen: React.FC = () => {
|
|||
<Feather name="check" size={20} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.languageOption,
|
||||
i18n.language === 'ar' && { backgroundColor: currentTheme.colors.primary + '20' }
|
||||
]}
|
||||
onPress={() => {
|
||||
i18n.changeLanguage('ar');
|
||||
setLanguageModalVisible(false);
|
||||
}}
|
||||
>
|
||||
<Text style={[
|
||||
styles.languageText,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
i18n.language === 'ar' && { color: currentTheme.colors.primary, fontWeight: 'bold' }
|
||||
]}>
|
||||
{t('settings.arabic')}
|
||||
</Text>
|
||||
{i18n.language === 'ar' && (
|
||||
<Feather name="check" size={20} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@ export interface StreamingContent {
|
|||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
addonId?: string;
|
||||
tmdbId?: number;
|
||||
poster: string;
|
||||
posterShape?: 'poster' | 'square' | 'landscape';
|
||||
|
|
@ -140,6 +139,7 @@ export interface CatalogContent {
|
|||
type: string;
|
||||
id: string;
|
||||
name: string;
|
||||
originalName?: string;
|
||||
genre?: string;
|
||||
items: StreamingContent[];
|
||||
}
|
||||
|
|
@ -375,7 +375,7 @@ class CatalogService {
|
|||
if (metas && metas.length > 0) {
|
||||
// Cap items per catalog to reduce memory and rendering load
|
||||
const limited = metas.slice(0, 12);
|
||||
const items = limited.map(meta => this.convertMetaToStreamingContent(meta, addon.id));
|
||||
const items = limited.map(meta => this.convertMetaToStreamingContent(meta));
|
||||
|
||||
// Get potentially custom display name; if customized, respect it as-is
|
||||
const originalName = catalog.name || catalog.id;
|
||||
|
|
@ -467,7 +467,7 @@ class CatalogService {
|
|||
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
|
||||
|
||||
if (metas && metas.length > 0) {
|
||||
const items = metas.map(meta => this.convertMetaToStreamingContent(meta, addon.id));
|
||||
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
|
||||
|
||||
// Get potentially custom display name
|
||||
const displayName = await getCatalogDisplayName(addon.id, catalog.type, catalog.id, catalog.name);
|
||||
|
|
@ -704,7 +704,7 @@ class CatalogService {
|
|||
});
|
||||
|
||||
// Add to recent content using enhanced conversion for full metadata
|
||||
const content = this.convertMetaToStreamingContentEnhanced(meta, preferredAddonId);
|
||||
const content = this.convertMetaToStreamingContentEnhanced(meta);
|
||||
this.addToRecentContent(content);
|
||||
|
||||
// Check if it's in the library
|
||||
|
|
@ -798,7 +798,7 @@ class CatalogService {
|
|||
|
||||
if (meta) {
|
||||
// Use basic conversion without enhanced metadata processing
|
||||
const content = this.convertMetaToStreamingContent(meta, preferredAddonId);
|
||||
const content = this.convertMetaToStreamingContent(meta);
|
||||
|
||||
// Check if it's in the library
|
||||
content.inLibrary = this.library[`${type}:${id}`] !== undefined;
|
||||
|
|
@ -817,7 +817,7 @@ class CatalogService {
|
|||
}
|
||||
}
|
||||
|
||||
private convertMetaToStreamingContent(meta: Meta, addonId?: string): StreamingContent {
|
||||
private convertMetaToStreamingContent(meta: Meta): StreamingContent {
|
||||
// Basic conversion for catalog display - no enhanced metadata processing
|
||||
// Use addon's poster if available, otherwise use placeholder
|
||||
let posterUrl = meta.poster;
|
||||
|
|
@ -835,7 +835,6 @@ class CatalogService {
|
|||
id: meta.id,
|
||||
type: meta.type,
|
||||
name: meta.name,
|
||||
addonId,
|
||||
poster: posterUrl,
|
||||
posterShape: meta.posterShape || 'poster', // Use addon's shape or default to poster type
|
||||
banner: meta.background,
|
||||
|
|
@ -852,13 +851,12 @@ class CatalogService {
|
|||
}
|
||||
|
||||
// Enhanced conversion for detailed metadata (used only when fetching individual content details)
|
||||
private convertMetaToStreamingContentEnhanced(meta: Meta, addonId?: string): StreamingContent {
|
||||
private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent {
|
||||
// Enhanced conversion to utilize all available metadata from addons
|
||||
const converted: StreamingContent = {
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
name: meta.name,
|
||||
addonId,
|
||||
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
|
||||
posterShape: meta.posterShape || 'poster',
|
||||
banner: meta.background,
|
||||
|
|
@ -1145,23 +1143,22 @@ class CatalogService {
|
|||
const supportsGenre = catalog.extra?.some(e => e.name === 'genre') ||
|
||||
catalog.extraSupported?.includes('genre');
|
||||
|
||||
// If genre is specified but not supported, we still fetch but without the filter
|
||||
// This ensures we don't skip addons that don't support the filter
|
||||
// If genre is specified, only use catalogs that support genre OR have no filter restrictions
|
||||
// If genre is specified but catalog doesn't support genre filter, skip it
|
||||
if (genre && !supportsGenre) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const manifest = manifests.find(m => m.id === addon.id);
|
||||
if (!manifest) continue;
|
||||
|
||||
const fetchPromise = (async () => {
|
||||
try {
|
||||
// Only apply genre filter if supported
|
||||
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
|
||||
const filters = genre ? [{ title: 'genre', value: genre }] : [];
|
||||
const metas = await stremioService.getCatalog(manifest, type, catalog.id, 1, filters);
|
||||
|
||||
if (metas && metas.length > 0) {
|
||||
const items = metas.slice(0, limit).map(meta => ({
|
||||
...this.convertMetaToStreamingContent(meta),
|
||||
addonId: addon.id // Attach addon ID to each result
|
||||
}));
|
||||
const items = metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta));
|
||||
return {
|
||||
addonName: addon.name,
|
||||
items
|
||||
|
|
@ -1213,35 +1210,22 @@ class CatalogService {
|
|||
catalogId: string,
|
||||
type: string,
|
||||
genre?: string,
|
||||
page: number = 1
|
||||
limit: number = 20
|
||||
): Promise<StreamingContent[]> {
|
||||
try {
|
||||
const manifests = await stremioService.getInstalledAddonsAsync();
|
||||
const manifest = manifests.find(m => m.id === addonId);
|
||||
|
||||
|
||||
if (!manifest) {
|
||||
logger.error(`Addon ${addonId} not found`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Find the catalog to check if it supports genre filter
|
||||
const addon = (await this.getAllAddons()).find(a => a.id === addonId);
|
||||
const catalog = addon?.catalogs?.find(c => c.id === catalogId);
|
||||
|
||||
// Check if catalog supports genre filter
|
||||
const supportsGenre = catalog?.extra?.some((e: any) => e.name === 'genre') ||
|
||||
catalog?.extraSupported?.includes('genre');
|
||||
|
||||
// Only apply genre filter if the catalog supports it
|
||||
const filters = (genre && supportsGenre) ? [{ title: 'genre', value: genre }] : [];
|
||||
|
||||
const metas = await stremioService.getCatalog(manifest, type, catalogId, page, filters);
|
||||
const filters = genre ? [{ title: 'genre', value: genre }] : [];
|
||||
const metas = await stremioService.getCatalog(manifest, type, catalogId, 1, filters);
|
||||
|
||||
if (metas && metas.length > 0) {
|
||||
return metas.map(meta => ({
|
||||
...this.convertMetaToStreamingContent(meta),
|
||||
addonId: addonId
|
||||
}));
|
||||
return metas.slice(0, limit).map(meta => this.convertMetaToStreamingContent(meta));
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
|
|
@ -1534,10 +1518,7 @@ class CatalogService {
|
|||
const metas = response.data?.metas || [];
|
||||
|
||||
if (metas.length > 0) {
|
||||
const items = metas.map(meta => ({
|
||||
...this.convertMetaToStreamingContent(meta),
|
||||
addonId: addon.id
|
||||
}));
|
||||
const items = metas.map(meta => this.convertMetaToStreamingContent(meta));
|
||||
logger.log(`Found ${items.length} results from ${addon.name}`);
|
||||
return items;
|
||||
}
|
||||
|
|
@ -1625,4 +1606,4 @@ class CatalogService {
|
|||
}
|
||||
|
||||
export const catalogService = CatalogService.getInstance();
|
||||
export default catalogService;
|
||||
export default catalogService;
|
||||
|
|
@ -38,9 +38,66 @@ export async function getCatalogDisplayName(addonId: string, type: string, catal
|
|||
return customNames[key] || originalName;
|
||||
}
|
||||
|
||||
// Function to clear the cache if settings are updated elsewhere
|
||||
// Function to clear the cache if settings are updated elsewhere
|
||||
export function clearCustomNameCache() {
|
||||
customNamesCache = {}; // Reset to empty object
|
||||
cacheTimestamp = 0; // Invalidate timestamp
|
||||
logger.info('Custom catalog name cache cleared.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a catalog name by de-duplicating words, removing redundant English suffixes,
|
||||
* and appending localized content type
|
||||
*/
|
||||
export function getFormattedCatalogName(
|
||||
originalName: string,
|
||||
type: string,
|
||||
localizedMovie: string,
|
||||
localizedSeries: string,
|
||||
localizedChannels?: string
|
||||
): string {
|
||||
if (!originalName) return '';
|
||||
|
||||
// 1. De-duplicate repeated words (case-insensitive)
|
||||
const words = originalName.split(' ').filter(Boolean);
|
||||
const uniqueWords: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const w of words) {
|
||||
const lw = w.toLowerCase();
|
||||
if (!seen.has(lw)) {
|
||||
uniqueWords.push(w);
|
||||
seen.add(lw);
|
||||
}
|
||||
}
|
||||
let processedName = uniqueWords.join(' ');
|
||||
|
||||
// 2. Remove redundant English suffixes if they exist
|
||||
const redundantSuffixes = [' movies', ' movie', ' series', ' tv shows', ' tv show', ' shows', ' show', ' channels', ' channel'];
|
||||
const lowerName = processedName.toLowerCase();
|
||||
for (const suffix of redundantSuffixes) {
|
||||
if (lowerName.endsWith(suffix)) {
|
||||
processedName = processedName.substring(0, processedName.length - suffix.length).trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Determine the localized content type suffix
|
||||
let contentType = '';
|
||||
if (type === 'movie') {
|
||||
contentType = localizedMovie;
|
||||
} else if (type === 'series' || type === 'tv') {
|
||||
contentType = localizedSeries;
|
||||
} else if (type === 'channel' && localizedChannels) {
|
||||
contentType = localizedChannels;
|
||||
}
|
||||
|
||||
if (!contentType) return processedName;
|
||||
|
||||
// 4. If the processed name already contains the localized content type, return it
|
||||
if (processedName.toLowerCase().includes(contentType.toLowerCase())) {
|
||||
return processedName;
|
||||
}
|
||||
|
||||
return `${processedName} ${contentType}`;
|
||||
}
|
||||
Loading…
Reference in a new issue