From 334d0b18632a999f5181036c920b3cd6a94a8aa6 Mon Sep 17 00:00:00 2001 From: tapframe Date: Tue, 6 Jan 2026 15:56:27 +0530 Subject: [PATCH] added arabic --- src/components/calendar/CalendarSection.tsx | 13 +- src/components/home/CatalogSection.tsx | 39 +- src/i18n/locales/ar.json | 1172 +++++++++++++++++++ src/i18n/locales/en.json | 28 +- src/i18n/locales/pt.json | 28 +- src/i18n/resources.ts | 2 + src/screens/CalendarScreen.tsx | 54 +- src/screens/CatalogScreen.tsx | 125 +- src/screens/HomeScreen.tsx | 36 +- src/screens/SettingsScreen.tsx | 28 +- src/services/catalogService.ts | 61 +- src/utils/catalogNameUtils.ts | 57 + 12 files changed, 1534 insertions(+), 109 deletions(-) create mode 100644 src/i18n/locales/ar.json diff --git a/src/components/calendar/CalendarSection.tsx b/src/components/calendar/CalendarSection.tsx index 9f1dc04..671a963 100644 --- a/src/components/calendar/CalendarSection.tsx +++ b/src/components/calendar/CalendarSection.tsx @@ -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 = ({ 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(null); const scrollViewRef = useRef(null); const [uiReady, setUiReady] = useState(false); diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index c83c09d..4b0eed6 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -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>(); 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} { + const { t } = useTranslation(); const navigation = useNavigation>(); const { libraryItems, loading: libraryLoading } = useLibrary(); const { currentTheme } = useTheme(); @@ -189,7 +191,7 @@ const CalendarScreen = () => { ) : ( <> - No scheduled episodes + {t('calendar.no_scheduled_episodes')} { size={16} color={currentTheme.colors.lightGray} /> - Check back later + {t('calendar.check_back_later')} )} @@ -207,16 +209,28 @@ const CalendarScreen = () => { ); }; - const renderSectionHeader = ({ section }: { section: CalendarSection }) => ( - - - {section.title} - - - ); + const renderSectionHeader = ({ section }: { section: CalendarSection }) => { + // Map section titles to translation keys + const titleKeyMap: Record = { + '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 ( + + + {displayTitle} + + + ); + }; // Process all episodes once data is loaded - using memory-efficient approach const allEpisodes = React.useMemo(() => { @@ -276,7 +290,7 @@ const CalendarScreen = () => { - Loading calendar... + {t('calendar.loading')} ); @@ -293,14 +307,14 @@ const CalendarScreen = () => { > - Calendar + {t('calendar.title')} {selectedDate && filteredEpisodes.length > 0 && ( - Showing episodes for {format(selectedDate, 'MMMM d, yyyy')} + {t('calendar.showing_episodes_for', { date: format(selectedDate, 'MMMM d, yyyy') })} @@ -337,14 +351,14 @@ const CalendarScreen = () => { - No episodes for {format(selectedDate, 'MMMM d, yyyy')} + {t('calendar.no_episodes_for', { date: format(selectedDate, 'MMMM d, yyyy') })} - - Show All Episodes + + {t('calendar.show_all_episodes')} @@ -373,10 +387,10 @@ const CalendarScreen = () => { - No upcoming episodes found + {t('calendar.no_upcoming_found')} - Add series to your library to see their upcoming episodes here + {t('calendar.add_series_desc')} )} diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 05ac853..a97a4e2 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -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 = ({ 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 = ({ 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 = ({ route, navigation }) => { {t('catalog.back')} - {displayName || originalName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} + + + {displayName || originalName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))} + + + {renderLoadingState()} ); @@ -914,7 +963,25 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { {t('catalog.back')} - {displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} + + + {displayName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))} + + + {renderErrorState()} ); @@ -932,7 +999,25 @@ const CatalogScreen: React.FC = ({ route, navigation }) => { {t('catalog.back')} - {displayName || `${type.charAt(0).toUpperCase() + type.slice(1)}s`} + + + {displayName || (type === 'movie' ? t('catalog.movies') : t('catalog.tv_shows'))} + + + {/* Filter chip bar - shows when catalog has filterable extras */} {catalogExtras.length > 0 && ( diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 81b985f..e5dd2eb 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -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(); - 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 }; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 1d5185a..9faa9c5 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -586,7 +586,11 @@ const SettingsScreen: React.FC = () => { } onPress={() => setLanguageModalVisible(true)} @@ -865,6 +869,28 @@ const SettingsScreen: React.FC = () => { )} + + { + i18n.changeLanguage('ar'); + setLanguageModalVisible(false); + }} + > + + {t('settings.arabic')} + + {i18n.language === 'ar' && ( + + )} + diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 3123cbe..0c8e906 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -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 { 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; \ No newline at end of file diff --git a/src/utils/catalogNameUtils.ts b/src/utils/catalogNameUtils.ts index 21ce894..f114a86 100644 --- a/src/utils/catalogNameUtils.ts +++ b/src/utils/catalogNameUtils.ts @@ -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(); + 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}`; } \ No newline at end of file