From c6b45340ba0823d4a179066539bc2f31b7508291 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 27 Jan 2026 15:57:00 +0530 Subject: [PATCH 1/7] Buffer Indicator behaviour fix while controls are hidden --- src/components/player/AndroidVideoPlayer.tsx | 2 +- src/components/player/KSPlayerCore.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index f260021c..8312fb74 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -883,7 +883,7 @@ const AndroidVideoPlayer: React.FC = () => { {/* Buffering Indicator (Visible when controls are hidden) */} {playerState.isBuffering && !playerState.showControls && ( - + )} diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index 60a4658e..a607a1e9 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -825,7 +825,7 @@ const KSPlayerCore: React.FC = () => { {/* Buffering Indicator (Visible when controls are hidden) */} {isBuffering && !showControls && ( - + )} From 13c431370392fd768da3efec8ea4d0e9cc85eee9 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 27 Jan 2026 18:48:26 +0530 Subject: [PATCH 2/7] fix for addon renaming and disabling due to addon.id key conflict --- src/hooks/useMetadata.ts | 29 ++-- src/screens/AddonsScreen.tsx | 68 +++++++-- src/screens/CatalogSettingsScreen.tsx | 21 +-- src/screens/DebridIntegrationScreen.tsx | 6 +- src/screens/streams/useStreamsScreen.ts | 114 +++++++-------- src/services/stremioService.ts | 185 ++++++++++++++---------- 6 files changed, 250 insertions(+), 173 deletions(-) diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 3b1bc7b8..b0d1b5c6 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -211,12 +211,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat try { await stremioService.getStreams(type, id, - (streams, addonId, addonName, error) => { + (streams, addonId, addonName, error, installationId) => { const processTime = Date.now() - sourceStartTime; console.log('🔍 [processStremioSource] Callback received:', { addonId, addonName, + installationId, streamCount: streams?.length || 0, error: error?.message || null, processTime @@ -275,20 +276,23 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Use debounced update to prevent rapid state changes debouncedStreamUpdate(() => { const updateState = (prevState: GroupedStreams): GroupedStreams => { - if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Updating state for addon ${addonName} (${addonId})`); + // Use installationId as key to keep multiple installations separate + const key = installationId || addonId || 'unknown'; + if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Updating state for addon ${addonName} (${addonId}) [${installationId}]`); return { ...prevState, - [addonId]: { + [key]: { addonName: addonName, streams: optimizedStreams // Use optimized streams } }; }; - // Track response order for addons + // Track response order for addons (use installationId to track each installation separately) setAddonResponseOrder(prevOrder => { - if (!prevOrder.includes(addonId)) { - return [...prevOrder, addonId]; + const key = installationId || addonId || 'unknown'; + if (!prevOrder.includes(key)) { + return [...prevOrder, key]; } return prevOrder; }); @@ -308,20 +312,23 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat debouncedStreamUpdate(() => { const updateState = (prevState: GroupedStreams): GroupedStreams => { - if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Adding empty provider ${addonName} (${addonId}) to state`); + // Use installationId as key to keep multiple installations separate + const key = installationId || addonId || 'unknown'; + if (__DEV__) logger.log(`🔄 [${logPrefix}:${sourceName}] Adding empty provider ${addonName} (${addonId}) [${installationId}] to state`); return { ...prevState, - [addonId]: { + [key]: { addonName: addonName, streams: [] // Empty array for providers with no streams } }; }; - // Track response order for addons + // Track response order for addons (use installationId to track each installation separately) setAddonResponseOrder(prevOrder => { - if (!prevOrder.includes(addonId)) { - return [...prevOrder, addonId]; + const key = installationId || addonId || 'unknown'; + if (!prevOrder.includes(key)) { + return [...prevOrder, key]; } return prevOrder; }); diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index 405b05a0..3c85423a 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -624,13 +624,40 @@ const AddonsScreen = () => { try { setInstalling(true); const manifest = await stremioService.getManifest(urlToInstall); + + // Check if this addon is already installed + const installedAddons = await stremioService.getInstalledAddonsAsync(); + const existingInstallations = installedAddons.filter(a => a.id === manifest.id); + const isAlreadyInstalled = existingInstallations.length > 0; + + // Check if addon provides streams + const providesStreams = manifest.resources?.some(resource => { + if (typeof resource === 'string') { + return resource === 'stream'; + } else if (typeof resource === 'object' && resource !== null && 'name' in resource) { + return (resource as any).name === 'stream'; + } + return false; + }) || false; + + + if (isAlreadyInstalled && !providesStreams) { + setAlertTitle(t('common.error')); + setAlertMessage('This addon is already installed. Multiple installations are only allowed for stream providers.'); + setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); + setAlertVisible(true); + return; + } + setAddonDetails(manifest); setAddonUrl(urlToInstall); setShowConfirmModal(true); - } catch (error) { + } catch (error: any) { logger.error('Failed to fetch addon details:', error); setAlertTitle(t('common.error')); - setAlertMessage(`${t('addons.fetch_error')} ${urlToInstall}`); + + const errorMessage = error?.message || `${t('addons.fetch_error')} ${urlToInstall}`; + setAlertMessage(errorMessage); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); setAlertVisible(true); } finally { @@ -652,10 +679,12 @@ const AddonsScreen = () => { setAlertMessage(t('addons.install_success')); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); setAlertVisible(true); - } catch (error) { + } catch (error: any) { logger.error('Failed to install addon:', error); setAlertTitle(t('common.error')); - setAlertMessage(t('addons.install_error')); + // Show specific error message if available, otherwise use generic message + const errorMessage = error?.message || t('addons.install_error'); + setAlertMessage(errorMessage); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); setAlertVisible(true); } finally { @@ -669,14 +698,14 @@ const AddonsScreen = () => { }; const moveAddonUp = (addon: ExtendedManifest) => { - if (stremioService.moveAddonUp(addon.installationId || addon.id)) { + if (addon.installationId && stremioService.moveAddonUp(addon.installationId)) { // Refresh the list to reflect the new order loadAddons(); } }; const moveAddonDown = (addon: ExtendedManifest) => { - if (stremioService.moveAddonDown(addon.installationId || addon.id)) { + if (addon.installationId && stremioService.moveAddonDown(addon.installationId)) { // Refresh the list to reflect the new order loadAddons(); } @@ -690,10 +719,12 @@ const AddonsScreen = () => { { label: t('addons.uninstall_button'), onPress: async () => { - await stremioService.removeAddon(addon.installationId || addon.id); - setAddons(prev => prev.filter(a => (a.installationId || a.id) !== (addon.installationId || addon.id))); - // Ensure we re-read from storage/order to avoid reappearing on next load - await loadAddons(); + if (addon.installationId) { + await stremioService.removeAddon(addon.installationId); + setAddons(prev => prev.filter(a => a.installationId !== addon.installationId)); + // Ensure we re-read from storage/order to avoid reappearing on next load + await loadAddons(); + } }, style: { color: colors.error } }, @@ -840,6 +871,11 @@ const AddonsScreen = () => { // Check if addon is pre-installed const isPreInstalled = stremioService.isPreInstalledAddon(item.id); + // Check if there are multiple installations of this addon + const sameAddonInstallations = addons.filter(a => a.id === item.id); + const hasMultipleInstallations = sameAddonInstallations.length > 1; + const installationNumber = sameAddonInstallations.findIndex(a => a.installationId === item.installationId) + 1; + // Format the types into a simple category text const categoryText = types.length > 0 ? types.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(' • ') @@ -890,13 +926,18 @@ const AddonsScreen = () => { )} - + {item.name} {isPreInstalled && ( {t('addons.pre_installed')} )} + {hasMultipleInstallations && ( + + #{installationNumber} + + )} {t('addons.version', { version: item.version || '1.0.0' })} @@ -935,6 +976,11 @@ const AddonsScreen = () => { {description.length > 100 ? description.substring(0, 100) + '...' : description} + {hasMultipleInstallations && item.originalUrl && ( + + {item.originalUrl} + + )} ); }; diff --git a/src/screens/CatalogSettingsScreen.tsx b/src/screens/CatalogSettingsScreen.tsx index d3f084f3..320d7252 100644 --- a/src/screens/CatalogSettingsScreen.tsx +++ b/src/screens/CatalogSettingsScreen.tsx @@ -308,12 +308,9 @@ const CatalogSettingsScreen = () => { addons.forEach(addon => { if (addon.catalogs && addon.catalogs.length > 0) { const uniqueCatalogs = new Map(); - // Use installationId if available, otherwise fallback to id - const uniqueAddonId = addon.installationId || addon.id; addon.catalogs.forEach(catalog => { - // Generate a truly unique key using installationId - const settingKey = `${uniqueAddonId}:${catalog.type}:${catalog.id}`; + const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`; let displayName = catalog.name || catalog.id; const catalogType = catalog.type === 'movie' ? 'Movies' : catalog.type === 'series' ? 'TV Shows' : catalog.type.charAt(0).toUpperCase() + catalog.type.slice(1); @@ -338,7 +335,7 @@ const CatalogSettingsScreen = () => { } uniqueCatalogs.set(settingKey, { - addonId: uniqueAddonId, // Store unique ID here + addonId: addon.id, catalogId: catalog.id, type: catalog.type, name: displayName, @@ -354,15 +351,11 @@ const CatalogSettingsScreen = () => { // Group settings by addon name const grouped: GroupedCatalogs = {}; availableCatalogs.forEach(setting => { - // Find addon by matching either installationId or id - const addon = addons.find(a => (a.installationId || a.id) === setting.addonId); + const addon = addons.find(a => a.id === setting.addonId); if (!addon) return; - // Use the unique addon ID (installationId) as the group key - const groupKey = setting.addonId; - - if (!grouped[groupKey]) { - grouped[groupKey] = { + if (!grouped[setting.addonId]) { + grouped[setting.addonId] = { name: addon.name, catalogs: [], expanded: true, @@ -370,9 +363,9 @@ const CatalogSettingsScreen = () => { }; } - grouped[groupKey].catalogs.push(setting); + grouped[setting.addonId].catalogs.push(setting); if (setting.enabled) { - grouped[groupKey].enabledCount++; + grouped[setting.addonId].enabledCount++; } }); diff --git a/src/screens/DebridIntegrationScreen.tsx b/src/screens/DebridIntegrationScreen.tsx index 042c843d..ebbb167f 100644 --- a/src/screens/DebridIntegrationScreen.tsx +++ b/src/screens/DebridIntegrationScreen.tsx @@ -716,10 +716,12 @@ const DebridIntegrationScreen = () => { setAlertMessage(t('debrid.connected_title')); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); setAlertVisible(true); - } catch (error) { + } catch (error: any) { logger.error('Failed to install Torbox addon:', error); setAlertTitle(t('common.error')); - setAlertMessage(t('addons.install_error')); + + const errorMessage = error?.message || t('addons.install_error'); + setAlertMessage(errorMessage); setAlertActions([{ label: t('common.ok'), onPress: () => setAlertVisible(false) }]); setAlertVisible(true); } finally { diff --git a/src/screens/streams/useStreamsScreen.ts b/src/screens/streams/useStreamsScreen.ts index ca9c043e..62de8cb0 100644 --- a/src/screens/streams/useStreamsScreen.ts +++ b/src/screens/streams/useStreamsScreen.ts @@ -224,10 +224,7 @@ export const useStreamsScreen = () => { const getProviderPriority = (addonId: string): number => { const installedAddons = stremioService.getInstalledAddons(); - // Check installationId first, then id for backward compatibility - const addonIndex = installedAddons.findIndex(addon => - (addon.installationId && addon.installationId === addonId) || addon.id === addonId - ); + const addonIndex = installedAddons.findIndex(addon => addon.id === addonId); if (addonIndex !== -1) { return 50 - addonIndex; } @@ -762,9 +759,7 @@ export const useStreamsScreen = () => { const pluginProviders: string[] = []; Array.from(allProviders).forEach(provider => { - const isInstalledAddon = installedAddons.some(addon => - (addon.installationId && addon.installationId === provider) || addon.id === provider - ); + const isInstalledAddon = installedAddons.some(addon => addon.installationId === provider || addon.id === provider); if (isInstalledAddon) { addonProviders.push(provider); } else { @@ -776,19 +771,20 @@ export const useStreamsScreen = () => { addonProviders .sort((a, b) => { - const indexA = installedAddons.findIndex(addon => - (addon.installationId && addon.installationId === a) || addon.id === a - ); - const indexB = installedAddons.findIndex(addon => - (addon.installationId && addon.installationId === b) || addon.id === b - ); + const indexA = installedAddons.findIndex(addon => addon.installationId === a || addon.id === a); + const indexB = installedAddons.findIndex(addon => addon.installationId === b || addon.id === b); return indexA - indexB; }) .forEach(provider => { - const installedAddon = installedAddons.find(addon => - (addon.installationId && addon.installationId === provider) || addon.id === provider - ); - filterChips.push({ id: provider, name: installedAddon?.name || provider }); + const installedAddon = installedAddons.find(addon => addon.installationId === provider || addon.id === provider); + // For multiple installations of same addon, show URL to differentiate + const sameAddonInstallations = installedAddons.filter(a => installedAddon && a.id === installedAddon.id); + const hasMultiple = sameAddonInstallations.length > 1; + const installationNumber = hasMultiple ? sameAddonInstallations.findIndex(a => a.installationId === installedAddon?.installationId) + 1 : 0; + const displayName = hasMultiple && installationNumber > 0 + ? `${installedAddon?.name} #${installationNumber}` + : (installedAddon?.name || provider); + filterChips.push({ id: provider, name: displayName }); }); // Group plugins by repository @@ -817,12 +813,8 @@ export const useStreamsScreen = () => { { id: 'all', name: 'All Providers' }, ...Array.from(allProviders) .sort((a, b) => { - const indexA = installedAddons.findIndex(addon => - (addon.installationId && addon.installationId === a) || addon.id === a - ); - const indexB = installedAddons.findIndex(addon => - (addon.installationId && addon.installationId === b) || addon.id === b - ); + const indexA = installedAddons.findIndex(addon => addon.installationId === a || addon.id === a); + const indexB = installedAddons.findIndex(addon => addon.installationId === b || addon.id === b); if (indexA !== -1 && indexB !== -1) return indexA - indexB; if (indexA !== -1) return -1; if (indexB !== -1) return 1; @@ -830,11 +822,17 @@ export const useStreamsScreen = () => { }) .map(provider => { const addonInfo = streams[provider]; - const installedAddon = installedAddons.find(addon => - (addon.installationId && addon.installationId === provider) || addon.id === provider - ); + const installedAddon = installedAddons.find(addon => addon.installationId === provider || addon.id === provider); let displayName = provider; - if (installedAddon) displayName = installedAddon.name; + if (installedAddon) { + // For multiple installations of same addon, show # to differentiate + const sameAddonInstallations = installedAddons.filter(a => a.id === installedAddon.id); + const hasMultiple = sameAddonInstallations.length > 1; + const installationNumber = hasMultiple ? sameAddonInstallations.findIndex(a => a.installationId === installedAddon.installationId) + 1 : 0; + displayName = hasMultiple && installationNumber > 0 + ? `${installedAddon.name} #${installationNumber}` + : installedAddon.name; + } else if (addonInfo?.addonName) displayName = addonInfo.addonName; return { id: provider, name: displayName }; }), @@ -846,7 +844,7 @@ export const useStreamsScreen = () => { const streams = selectedEpisode ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); - const filteredEntries = Object.entries(streams).filter(([addonId]) => { + const filteredEntries = Object.entries(streams).filter(([key]) => { if (selectedProvider === 'all') return true; // Handle repository-based filtering (repo-{repoId}) @@ -854,33 +852,27 @@ export const useStreamsScreen = () => { const repoId = selectedProvider.replace('repo-', ''); if (!repoId) return false; - const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); + const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key); if (isInstalledAddon) return false; // Not a plugin // Check if this plugin belongs to the selected repository - const repoInfo = localScraperService.getScraperRepository(addonId); + const repoInfo = localScraperService.getScraperRepository(key); return !!(repoInfo && (repoInfo.id === repoId || repoInfo.id?.toLowerCase() === repoId?.toLowerCase())); } // Legacy: handle old grouped-plugins filter (fallback) if (settings.streamDisplayMode === 'grouped' && selectedProvider === 'grouped-plugins') { - const isInstalledAddon = installedAddons.some(addon => - (addon.installationId && addon.installationId === addonId) || addon.id === addonId - ); + const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key); return !isInstalledAddon; } - return addonId === selectedProvider; + return key === selectedProvider; }); // Sort entries: installed addons first (in their installation order), then plugins - const sortedEntries = filteredEntries.sort(([addonIdA], [addonIdB]) => { - const isAddonA = installedAddons.some(addon => - (addon.installationId && addon.installationId === addonIdA) || addon.id === addonIdA - ); - const isAddonB = installedAddons.some(addon => - (addon.installationId && addon.installationId === addonIdB) || addon.id === addonIdB - ); + const sortedEntries = filteredEntries.sort(([keyA], [keyB]) => { + const isAddonA = installedAddons.some(addon => addon.installationId === keyA || addon.id === keyA); + const isAddonB = installedAddons.some(addon => addon.installationId === keyB || addon.id === keyB); // Addons always come before plugins if (isAddonA && !isAddonB) return -1; @@ -888,18 +880,14 @@ export const useStreamsScreen = () => { // Both are addons - sort by installation order if (isAddonA && isAddonB) { - const indexA = installedAddons.findIndex(addon => - (addon.installationId && addon.installationId === addonIdA) || addon.id === addonIdA - ); - const indexB = installedAddons.findIndex(addon => - (addon.installationId && addon.installationId === addonIdB) || addon.id === addonIdB - ); + const indexA = installedAddons.findIndex(addon => addon.installationId === keyA || addon.id === keyA); + const indexB = installedAddons.findIndex(addon => addon.installationId === keyB || addon.id === keyB); return indexA - indexB; } // Both are plugins - sort by response order - const responseIndexA = addonResponseOrder.indexOf(addonIdA); - const responseIndexB = addonResponseOrder.indexOf(addonIdB); + const responseIndexA = addonResponseOrder.indexOf(keyA); + const responseIndexB = addonResponseOrder.indexOf(keyB); if (responseIndexA !== -1 && responseIndexB !== -1) return responseIndexA - responseIndexB; if (responseIndexA !== -1) return -1; if (responseIndexB !== -1) return 1; @@ -910,10 +898,8 @@ export const useStreamsScreen = () => { const addonStreams: Stream[] = []; const pluginStreams: Stream[] = []; - sortedEntries.forEach(([addonId, { streams: providerStreams }]) => { - const isInstalledAddon = installedAddons.some(addon => - (addon.installationId && addon.installationId === addonId) || addon.id === addonId - ); + sortedEntries.forEach(([key, { streams: providerStreams }]) => { + const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key); if (isInstalledAddon) { addonStreams.push(...providerStreams); @@ -959,10 +945,9 @@ export const useStreamsScreen = () => { } return sortedEntries - .map(([addonId, { addonName, streams: providerStreams }]) => { - const isInstalledAddon = installedAddons.some(addon => - (addon.installationId && addon.installationId === addonId) || addon.id === addonId - ); + .map(([key, { addonName, streams: providerStreams }]) => { + const isInstalledAddon = installedAddons.some(addon => addon.installationId === key || addon.id === key); + const installedAddon = installedAddons.find(addon => addon.installationId === key || addon.id === key); let filteredStreams = providerStreams; if (!isInstalledAddon) { @@ -977,9 +962,20 @@ export const useStreamsScreen = () => { processedStreams = sortStreamsByQuality(filteredStreams); } + // For multiple installations of same addon, add # to section title + let sectionTitle = addonName; + if (installedAddon) { + const sameAddonInstallations = installedAddons.filter(a => a.id === installedAddon.id); + const hasMultiple = sameAddonInstallations.length > 1; + const installationNumber = hasMultiple ? sameAddonInstallations.findIndex(a => a.installationId === installedAddon.installationId) + 1 : 0; + sectionTitle = hasMultiple && installationNumber > 0 + ? `${addonName} #${installationNumber}` + : addonName; + } + return { - title: addonName, - addonId, + title: sectionTitle, + addonId: key, data: processedStreams, isEmptyDueToQualityFilter: false, }; diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index a8a4d802..e155ad26 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -148,9 +148,9 @@ export interface SubtitleResponse { addonName: string; } -// Modify the callback signature to include addon ID +// Modify the callback signature to include addon ID and installation ID interface StreamCallback { - (streams: Stream[] | null, addonId: string | null, addonName: string | null, error: Error | null): void; + (streams: Stream[] | null, addonId: string | null, addonName: string | null, error: Error | null, installationId?: string | null): void; } interface CatalogFilter { @@ -186,6 +186,7 @@ interface ResourceObject { export interface Manifest { id: string; + installationId?: string; // Unique ID for this installation (allows multiple installs of same addon) name: string; version: string; description: string; @@ -208,7 +209,6 @@ export interface Manifest { background?: string; // Background image URL logo?: string; // Logo URL contactEmail?: string; // Contact email - installationId?: string; // Unique ID for this specific installation } // Config object for addon configuration per protocol @@ -263,8 +263,8 @@ export interface AddonCapabilities { class StremioService { private static instance: StremioService; - private installedAddons: Map = new Map(); - private addonOrder: string[] = []; + private installedAddons: Map = new Map(); // Key is installationId + private addonOrder: string[] = []; // Array of installationIds private readonly STORAGE_KEY = 'stremio-addons'; private readonly ADDON_ORDER_KEY = 'stremio-addon-order'; private readonly MAX_CONCURRENT_REQUESTS = 3; @@ -278,6 +278,29 @@ class StremioService { this.initializationPromise = this.initialize(); } + // Generate a unique installation ID for an addon + private generateInstallationId(addonId: string): string { + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 9); + return `${addonId}-${timestamp}-${random}`; + } + + + private addonProvidesStreams(manifest: Manifest): boolean { + if (!manifest.resources || !Array.isArray(manifest.resources)) { + return false; + } + + return manifest.resources.some(resource => { + if (typeof resource === 'string') { + return resource === 'stream'; + } else if (typeof resource === 'object' && resource !== null && 'name' in resource) { + return (resource as ResourceObject).name === 'stream'; + } + return false; + }); + } + // Dynamic validator for content IDs based on installed addon capabilities public async isValidContentId(type: string, id: string | null | undefined): Promise { // Ensure addons are initialized before checking types @@ -419,13 +442,13 @@ class StremioService { if (storedAddons) { const parsed = JSON.parse(storedAddons); - // Convert to Map + // Convert to Map using installationId as key this.installedAddons = new Map(); for (const addon of parsed) { if (addon && addon.id) { - // Migration: Ensure installationId exists + // Generate installationId for existing addons that don't have one (migration) if (!addon.installationId) { - addon.installationId = addon.id; + addon.installationId = this.generateInstallationId(addon.id); } this.installedAddons.set(addon.installationId, addon); } @@ -436,21 +459,19 @@ class StremioService { const cinemetaId = 'com.linvo.cinemeta'; const hasUserRemovedCinemeta = await this.hasUserRemovedAddon(cinemetaId); - // Check if any installed addon has this ID (using valid values iteration) - const isCinemetaInstalled = Array.from(this.installedAddons.values()).some(a => a.id === cinemetaId); + // Check if Cinemeta is already installed (by checking addon.id, not installationId) + const hasCinemeta = Array.from(this.installedAddons.values()).some(addon => addon.id === cinemetaId); - if (!isCinemetaInstalled && !hasUserRemovedCinemeta) { + if (!hasCinemeta && !hasUserRemovedCinemeta) { try { const cinemetaManifest = await this.getManifest('https://v3-cinemeta.strem.io/manifest.json'); - // For default addons, we can use the ID as installationId to keep it clean (or generate one) - // Using ID ensures only one instance of default addon is auto-installed - cinemetaManifest.installationId = cinemetaId; - this.installedAddons.set(cinemetaId, cinemetaManifest); + cinemetaManifest.installationId = this.generateInstallationId(cinemetaId); + this.installedAddons.set(cinemetaManifest.installationId, cinemetaManifest); } catch (error) { // Fallback to minimal manifest if fetch fails const fallbackManifest: Manifest = { id: cinemetaId, - installationId: cinemetaId, + installationId: this.generateInstallationId(cinemetaId), name: 'Cinemeta', version: '3.0.13', description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.', @@ -487,7 +508,7 @@ class StremioService { configurable: false } }; - this.installedAddons.set(cinemetaId, fallbackManifest); + this.installedAddons.set(fallbackManifest.installationId!, fallbackManifest); } } @@ -495,17 +516,18 @@ class StremioService { const opensubsId = 'org.stremio.opensubtitlesv3'; const hasUserRemovedOpenSubtitles = await this.hasUserRemovedAddon(opensubsId); - const isOpenSubsInstalled = Array.from(this.installedAddons.values()).some(a => a.id === opensubsId); + // Check if OpenSubtitles is already installed (by checking addon.id, not installationId) + const hasOpenSubs = Array.from(this.installedAddons.values()).some(addon => addon.id === opensubsId); - if (!isOpenSubsInstalled && !hasUserRemovedOpenSubtitles) { + if (!hasOpenSubs && !hasUserRemovedOpenSubtitles) { try { const opensubsManifest = await this.getManifest('https://opensubtitles-v3.strem.io/manifest.json'); - opensubsManifest.installationId = opensubsId; - this.installedAddons.set(opensubsId, opensubsManifest); + opensubsManifest.installationId = this.generateInstallationId(opensubsId); + this.installedAddons.set(opensubsManifest.installationId, opensubsManifest); } catch (error) { const fallbackManifest: Manifest = { id: opensubsId, - installationId: opensubsId, + installationId: this.generateInstallationId(opensubsId), name: 'OpenSubtitles v3', version: '1.0.0', description: 'OpenSubtitles v3 Addon for Stremio', @@ -524,7 +546,7 @@ class StremioService { configurable: false } }; - this.installedAddons.set(opensubsId, fallbackManifest); + this.installedAddons.set(fallbackManifest.installationId!, fallbackManifest); } } @@ -534,27 +556,30 @@ class StremioService { if (!storedOrder) storedOrder = await mmkvStorage.getItem(`@user:local:${this.ADDON_ORDER_KEY}`); if (storedOrder) { this.addonOrder = JSON.parse(storedOrder); - // Filter out any ids that aren't in installedAddons - this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id)); + // Filter out any installationIds that aren't in installedAddons + this.addonOrder = this.addonOrder.filter(installationId => this.installedAddons.has(installationId)); } - // Add Cinemeta to order only if user hasn't removed it and it's installed + // Add Cinemeta to order only if user hasn't removed it const hasUserRemovedCinemetaOrder = await this.hasUserRemovedAddon(cinemetaId); - // We check if the installationId (which is cinemetaId for default) is in the map - if (!this.addonOrder.includes(cinemetaId) && this.installedAddons.has(cinemetaId) && !hasUserRemovedCinemetaOrder) { - this.addonOrder.push(cinemetaId); + const cinemetaInstallation = Array.from(this.installedAddons.values()).find(addon => addon.id === cinemetaId); + if (cinemetaInstallation && cinemetaInstallation.installationId && + !this.addonOrder.includes(cinemetaInstallation.installationId) && !hasUserRemovedCinemetaOrder) { + this.addonOrder.push(cinemetaInstallation.installationId); } // Only add OpenSubtitles to order if user hasn't removed it const hasUserRemovedOpenSubtitlesOrder = await this.hasUserRemovedAddon(opensubsId); - if (!this.addonOrder.includes(opensubsId) && this.installedAddons.has(opensubsId) && !hasUserRemovedOpenSubtitlesOrder) { - this.addonOrder.push(opensubsId); + const opensubsInstallation = Array.from(this.installedAddons.values()).find(addon => addon.id === opensubsId); + if (opensubsInstallation && opensubsInstallation.installationId && + !this.addonOrder.includes(opensubsInstallation.installationId) && !hasUserRemovedOpenSubtitlesOrder) { + this.addonOrder.push(opensubsInstallation.installationId); } - // Add any missing addons to the order - const installedIds = Array.from(this.installedAddons.keys()); - const missingIds = installedIds.filter(id => !this.addonOrder.includes(id)); - this.addonOrder = [...this.addonOrder, ...missingIds]; + // Add any missing addons to the order (use installationIds) + const installedInstallationIds = Array.from(this.installedAddons.keys()); + const missingInstallationIds = installedInstallationIds.filter(installationId => !this.addonOrder.includes(installationId)); + this.addonOrder = [...this.addonOrder, ...missingInstallationIds]; // Ensure order and addons are saved await this.saveAddonOrder(); @@ -668,49 +693,58 @@ class StremioService { async installAddon(url: string): Promise { const manifest = await this.getManifest(url); if (manifest && manifest.id) { - // Generate a unique installation ID - const installationId = `${manifest.id}-${Date.now()}-${Math.floor(Math.random() * 10000)}`; - manifest.installationId = installationId; + // Check if this addon is already installed + const existingInstallations = Array.from(this.installedAddons.values()).filter(a => a.id === manifest.id); + const isAlreadyInstalled = existingInstallations.length > 0; - this.installedAddons.set(installationId, manifest); + // Only allow multiple installations for stream-providing addons + if (isAlreadyInstalled && !this.addonProvidesStreams(manifest)) { + throw new Error('This addon is already installed. Multiple installations are only allowed for stream providers.'); + } + + // Generate a unique installation ID for this installation + manifest.installationId = this.generateInstallationId(manifest.id); + + // Store using installationId as key (allows multiple installations of same addon) + this.installedAddons.set(manifest.installationId, manifest); // If addon was previously removed by user, unmark it on reinstall and clean up await this.unmarkAddonAsRemovedByUser(manifest.id); + await this.cleanupRemovedAddonFromStorage(manifest.id); - // Note: cleanupRemovedAddonFromStorage takes an installationId (from addonOrder context), - // but here we are dealing with a new installationId, strictly speaking. - // However, we might want to cleanup any stray legacy entries for this addon ID if we wanted strict uniqueness, - // but we clearly support duplicates now, so we don't need to clean up other instances. - // We ONLY keep the unmarkAddonAsRemovedByUser to allow re-auto-install of defaults if user manually installs them. - - // Add to order (new addons go to the end) - this.addonOrder.push(installationId); + // Add installationId to order (new addons go to the end) + if (!this.addonOrder.includes(manifest.installationId)) { + this.addonOrder.push(manifest.installationId); + } await this.saveInstalledAddons(); await this.saveAddonOrder(); - // Emit an event that an addon was added - addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, installationId); + // Emit an event that an addon was added (include both ids for compatibility) + addonEmitter.emit(ADDON_EVENTS.ADDON_ADDED, { installationId: manifest.installationId, addonId: manifest.id }); } else { throw new Error('Invalid addon manifest'); } } async removeAddon(installationId: string): Promise { - // Allow removal of any addon + // Allow removal of any addon installation, including pre-installed ones like Cinemeta if (this.installedAddons.has(installationId)) { const addon = this.installedAddons.get(installationId); this.installedAddons.delete(installationId); - // Remove from order + // Remove from order using installationId this.addonOrder = this.addonOrder.filter(id => id !== installationId); - // Track user explicit removal for the addon ID (tombstone to prevent auto-reinstall of defaults) - if (addon && addon.id) { - await this.markAddonAsRemovedByUser(addon.id); + // Track user explicit removal only if this is the last installation of this addon + if (addon) { + const remainingInstallations = Array.from(this.installedAddons.values()).filter(a => a.id === addon.id); + if (remainingInstallations.length === 0) { + // This was the last installation, mark addon as removed by user + await this.markAddonAsRemovedByUser(addon.id); + // Proactively clean up any persisted orders/legacy keys for this addon + await this.cleanupRemovedAddonFromStorage(addon.id); + } } - // Clean up this specific installation from storage keys - await this.cleanupRemovedAddonFromStorage(installationId); - // Persist removals before app possibly exits await this.saveInstalledAddons(); await this.saveAddonOrder(); @@ -720,10 +754,10 @@ class StremioService { } getInstalledAddons(): Manifest[] { - // Return addons in the specified order + // Return addons in the specified order (using installationIds) const result = this.addonOrder - .filter(id => this.installedAddons.has(id)) - .map(id => this.installedAddons.get(id)!); + .filter(installationId => this.installedAddons.has(installationId)) + .map(installationId => this.installedAddons.get(installationId)!); return result; } @@ -1430,7 +1464,7 @@ class StremioService { try { if (!addon.url) { logger.warn(`⚠️ [getStreams] Addon ${addon.id} has no URL`); - if (callback) callback(null, addon.installationId || addon.id, addon.name, new Error('Addon has no URL')); + if (callback) callback(null, addon.id, addon.name, new Error('Addon has no URL'), addon.installationId); return; } @@ -1438,7 +1472,7 @@ class StremioService { const encodedId = encodeURIComponent(id); const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`; - logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`); + logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}) [${addon.installationId}]: ${url}`); const response = await this.retryRequest(async () => { return await axios.get(url, safeAxiosConfig); @@ -1446,22 +1480,21 @@ class StremioService { let processedStreams: Stream[] = []; if (response.data && response.data.streams) { - logger.log(`✅ [getStreams] Got ${response.data.streams.length} streams from ${addon.name} (${addon.id})`); + logger.log(`✅ [getStreams] Got ${response.data.streams.length} streams from ${addon.name} (${addon.id}) [${addon.installationId}]`); processedStreams = this.processStreams(response.data.streams, addon); - logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id})`); + logger.log(`✅ [getStreams] Processed ${processedStreams.length} valid streams from ${addon.name} (${addon.id}) [${addon.installationId}]`); } else { - logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id})`); + logger.log(`⚠️ [getStreams] No streams found in response from ${addon.name} (${addon.id}) [${addon.installationId}]`); } if (callback) { - // Call callback with processed streams (can be empty array) - // Use installationId if available to distinct between multiple installations of same addon - callback(processedStreams, addon.installationId || addon.id, addon.name, null); + // Call callback with processed streams (can be empty array), include installationId + callback(processedStreams, addon.id, addon.name, null, addon.installationId); } } catch (error) { if (callback) { - // Call callback with error - callback(null, addon.installationId || addon.id, addon.name, error as Error); + // Call callback with error, include installationId + callback(null, addon.id, addon.name, error as Error, addon.installationId); } } })(); // Immediately invoke the async function @@ -1642,7 +1675,7 @@ class StremioService { name: name, title: displayTitle, addonName: addon.name, - addonId: addon.installationId || addon.id, + addonId: addon.id, // Include description as-is to preserve full details description: stream.description, @@ -1833,9 +1866,9 @@ class StremioService { return deduped; } - // Add methods to move addons in the order - moveAddonUp(id: string): boolean { - const index = this.addonOrder.indexOf(id); + // Add methods to move addons in the order (using installationIds) + moveAddonUp(installationId: string): boolean { + const index = this.addonOrder.indexOf(installationId); if (index > 0) { // Swap with the previous item [this.addonOrder[index - 1], this.addonOrder[index]] = @@ -1848,8 +1881,8 @@ class StremioService { return false; } - moveAddonDown(id: string): boolean { - const index = this.addonOrder.indexOf(id); + moveAddonDown(installationId: string): boolean { + const index = this.addonOrder.indexOf(installationId); if (index >= 0 && index < this.addonOrder.length - 1) { // Swap with the next item [this.addonOrder[index], this.addonOrder[index + 1]] = From 983f33556f8f789d766abf44638292785d5600e9 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Wed, 28 Jan 2026 19:41:51 +0530 Subject: [PATCH 3/7] fix imdb logo not appearing while using mdblist --- src/components/metadata/RatingsSection.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx index 92b5e3b3..53f1ccd6 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -22,7 +22,7 @@ const BREAKPOINTS = { tv: 1440, }; -const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png'; +const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/6/69/IMDB_Logo_2016.svg'; export const RATING_PROVIDERS = { imdb: { @@ -163,7 +163,7 @@ export const RatingsSection: React.FC = ({ imdbId, type }) imdb: { name: 'IMDb', icon: { uri: IMDb_LOGO }, - isImage: true, + isImage: false, color: '#F5C518', transform: (value: number) => value.toFixed(1) }, @@ -311,4 +311,4 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: '600', }, -}); \ No newline at end of file +}); From 6f0db7303b916b99654123d076eb513432714fe8 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Wed, 28 Jan 2026 21:57:59 +0530 Subject: [PATCH 4/7] better approach for imdb logo using remote svg lib --- src/components/metadata/RatingsSection.tsx | 37 +++++++++++++++------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx index 53f1ccd6..c87c6a4e 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { SvgUri } from 'react-native-svg'; import { View, Text, StyleSheet, ActivityIndicator, Image, Animated, Dimensions } from 'react-native'; import { MaterialIcons as MaterialIconsWrapper } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; @@ -162,8 +163,8 @@ export const RatingsSection: React.FC = ({ imdbId, type }) const ratingConfig = { imdb: { name: 'IMDb', - icon: { uri: IMDb_LOGO }, - isImage: false, + icon: IMDb_LOGO, + isRemoteSvg: true, color: '#F5C518', transform: (value: number) => value.toFixed(1) }, @@ -244,10 +245,23 @@ export const RatingsSection: React.FC = ({ imdbId, type }) return ( - {config.isImage ? ( + {config.isRemoteSvg ? ( + + ) : config.isImage ? ( ) : config.icon ? ( @@ -258,13 +272,14 @@ export const RatingsSection: React.FC = ({ imdbId, type }) })} ) : ( - // Text fallback - + {config.name} )} From 504a34df24730505b33edab94ff515a4b4149f72 Mon Sep 17 00:00:00 2001 From: chrisk325 Date: Wed, 28 Jan 2026 22:30:52 +0530 Subject: [PATCH 5/7] small fix --- src/components/metadata/RatingsSection.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx index c87c6a4e..3bfefba9 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -250,6 +250,7 @@ export const RatingsSection: React.FC = ({ imdbId, type }) uri={config.icon as string} width={source === 'imdb' ? iconSize * 2 : iconSize} height={iconSize} + style={{ marginRight: iconTextGap }} /> ) : config.isImage ? ( Date: Thu, 29 Jan 2026 04:19:52 +0530 Subject: [PATCH 6/7] Revert "better approach for imdb logo using remote svg lib" --- src/components/metadata/RatingsSection.tsx | 38 +++++++--------------- 1 file changed, 11 insertions(+), 27 deletions(-) diff --git a/src/components/metadata/RatingsSection.tsx b/src/components/metadata/RatingsSection.tsx index 3bfefba9..53f1ccd6 100644 --- a/src/components/metadata/RatingsSection.tsx +++ b/src/components/metadata/RatingsSection.tsx @@ -1,5 +1,4 @@ import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react'; -import { SvgUri } from 'react-native-svg'; import { View, Text, StyleSheet, ActivityIndicator, Image, Animated, Dimensions } from 'react-native'; import { MaterialIcons as MaterialIconsWrapper } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; @@ -163,8 +162,8 @@ export const RatingsSection: React.FC = ({ imdbId, type }) const ratingConfig = { imdb: { name: 'IMDb', - icon: IMDb_LOGO, - isRemoteSvg: true, + icon: { uri: IMDb_LOGO }, + isImage: false, color: '#F5C518', transform: (value: number) => value.toFixed(1) }, @@ -245,24 +244,10 @@ export const RatingsSection: React.FC = ({ imdbId, type }) return ( - {config.isRemoteSvg ? ( - - ) : config.isImage ? ( + {config.isImage ? ( ) : config.icon ? ( @@ -273,14 +258,13 @@ export const RatingsSection: React.FC = ({ imdbId, type }) })} ) : ( - + // Text fallback + {config.name} )} From f73e418b36e3ebce3be2b0421f7b8d4c799d7004 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:47:46 +0530 Subject: [PATCH 7/7] revert: drop theintrodb.org integration Revert the theintrodb.org skip-intro provider and return to the existing introdb.app behavior. --- src/components/player/AndroidVideoPlayer.tsx | 9 +- src/components/player/KSPlayerCore.tsx | 9 +- src/components/player/common/UpNextButton.tsx | 16 +- .../player/overlays/SkipIntroButton.tsx | 35 ++-- src/hooks/useSettings.ts | 2 - .../settings/PlaybackSettingsScreen.tsx | 144 +---------------- src/services/introService.ts | 149 ++---------------- 7 files changed, 33 insertions(+), 331 deletions(-) diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 8312fb74..e22c2847 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -44,7 +44,6 @@ import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; import SkipIntroButton from './overlays/SkipIntroButton'; import UpNextButton from './common/UpNextButton'; import { CustomAlert } from '../CustomAlert'; -import { CreditsInfo } from '../../services/introService'; // Android-specific components @@ -145,9 +144,6 @@ const AndroidVideoPlayer: React.FC = () => { // Subtitle sync modal state const [showSyncModal, setShowSyncModal] = useState(false); - // Credits timing state from API - const [creditsInfo, setCreditsInfo] = useState(null); - // Track auto-selection ref to prevent duplicate selections const hasAutoSelectedTracks = useRef(false); @@ -171,7 +167,7 @@ const AndroidVideoPlayer: React.FC = () => { }, [uri, episodeId]); const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) }); - const { metadata, cast, tmdbId } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [], tmdbId: null }; + const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] }; const hasLogo = metadata && metadata.logo; const openingAnimation = useOpeningAnimation(backdrop, metadata); @@ -976,10 +972,8 @@ const AndroidVideoPlayer: React.FC = () => { episode={episode} malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id} kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined} - tmdbId={tmdbId || undefined} currentTime={playerState.currentTime} onSkip={(endTime) => controlsHook.seekToTime(endTime)} - onCreditsInfo={setCreditsInfo} controlsVisible={playerState.showControls} controlsFixedOffset={100} /> @@ -1005,7 +999,6 @@ const AndroidVideoPlayer: React.FC = () => { metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined} controlsVisible={playerState.showControls} controlsFixedOffset={100} - creditsInfo={creditsInfo} /> diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index a607a1e9..79574b9e 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -21,7 +21,6 @@ import ResumeOverlay from './modals/ResumeOverlay'; import ParentalGuideOverlay from './overlays/ParentalGuideOverlay'; import SkipIntroButton from './overlays/SkipIntroButton'; import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components'; -import { CreditsInfo } from '../../services/introService'; // Platform-specific components import { KSPlayerSurface } from './ios/components/KSPlayerSurface'; @@ -155,7 +154,7 @@ const KSPlayerCore: React.FC = () => { const speedControl = useSpeedControl(1.0); // Metadata Hook - const { metadata, groupedEpisodes, cast, tmdbId } = useMetadata({ id, type: type as 'movie' | 'series' }); + const { metadata, groupedEpisodes, cast } = useMetadata({ id, type: type as 'movie' | 'series' }); // Trakt Autosync const traktAutosync = useTraktAutosync({ @@ -178,9 +177,6 @@ const KSPlayerCore: React.FC = () => { // Subtitle sync modal state const [showSyncModal, setShowSyncModal] = useState(false); - // Credits timing state from API - const [creditsInfo, setCreditsInfo] = useState(null); - // Track auto-selection refs to prevent duplicate selections const hasAutoSelectedTracks = useRef(false); @@ -946,10 +942,8 @@ const KSPlayerCore: React.FC = () => { episode={episode} malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id} kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined} - tmdbId={tmdbId || undefined} currentTime={currentTime} onSkip={(endTime) => controls.seekToTime(endTime)} - onCreditsInfo={setCreditsInfo} controlsVisible={showControls} controlsFixedOffset={126} /> @@ -975,7 +969,6 @@ const KSPlayerCore: React.FC = () => { metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined} controlsVisible={showControls} controlsFixedOffset={126} - creditsInfo={creditsInfo} /> {/* Modals */} diff --git a/src/components/player/common/UpNextButton.tsx b/src/components/player/common/UpNextButton.tsx index d8c5de0b..9958e0c6 100644 --- a/src/components/player/common/UpNextButton.tsx +++ b/src/components/player/common/UpNextButton.tsx @@ -4,7 +4,6 @@ import { Animated } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { logger } from '../../../utils/logger'; import { LinearGradient } from 'expo-linear-gradient'; -import { CreditsInfo } from '../../../services/introService'; export interface Insets { top: number; @@ -34,7 +33,6 @@ interface UpNextButtonProps { metadata?: { poster?: string; id?: string }; // Added metadata prop controlsVisible?: boolean; controlsFixedOffset?: number; - creditsInfo?: CreditsInfo | null; // Add credits info from API } const UpNextButton: React.FC = ({ @@ -51,7 +49,6 @@ const UpNextButton: React.FC = ({ metadata, controlsVisible = false, controlsFixedOffset = 100, - creditsInfo, }) => { const [visible, setVisible] = useState(false); const opacity = useRef(new Animated.Value(0)).current; @@ -79,19 +76,10 @@ const UpNextButton: React.FC = ({ const shouldShow = useMemo(() => { if (!nextEpisode || duration <= 0) return false; - - // If we have credits timing from API, use that as primary source - if (creditsInfo?.startTime !== null && creditsInfo?.startTime !== undefined) { - // Show button when we reach credits start time and stay visible until 10s before end - const timeRemaining = duration - currentTime; - const isInCredits = currentTime >= creditsInfo.startTime; - return isInCredits && timeRemaining > 10; - } - - // Fallback: Use fixed timing (show when under ~1 minute and above 10s) const timeRemaining = duration - currentTime; + // Be tolerant to timer jitter: show when under ~1 minute and above 10s return timeRemaining < 61 && timeRemaining > 10; - }, [nextEpisode, duration, currentTime, creditsInfo]); + }, [nextEpisode, duration, currentTime]); // Debug logging removed to reduce console noise // The state is computed in shouldShow useMemo above diff --git a/src/components/player/overlays/SkipIntroButton.tsx b/src/components/player/overlays/SkipIntroButton.tsx index 2061aab7..b5329c6a 100644 --- a/src/components/player/overlays/SkipIntroButton.tsx +++ b/src/components/player/overlays/SkipIntroButton.tsx @@ -10,7 +10,7 @@ import Animated, { import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialIcons } from '@expo/vector-icons'; import { BlurView } from 'expo-blur'; -import { introService, SkipInterval, SkipType, CreditsInfo } from '../../../services/introService'; +import { introService, SkipInterval, SkipType } from '../../../services/introService'; import { useTheme } from '../../../contexts/ThemeContext'; import { logger } from '../../../utils/logger'; import { useSettings } from '../../../hooks/useSettings'; @@ -22,10 +22,8 @@ interface SkipIntroButtonProps { episode?: number; malId?: string; kitsuId?: string; - tmdbId?: number; currentTime: number; onSkip: (endTime: number) => void; - onCreditsInfo?: (credits: CreditsInfo | null) => void; controlsVisible?: boolean; controlsFixedOffset?: number; } @@ -37,10 +35,8 @@ export const SkipIntroButton: React.FC = ({ episode, malId, kitsuId, - tmdbId, currentTime, onSkip, - onCreditsInfo, controlsVisible = false, controlsFixedOffset = 100, }) => { @@ -69,22 +65,20 @@ export const SkipIntroButton: React.FC = ({ // Fetch skip data when episode changes useEffect(() => { - const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${tmdbId}`; + const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`; if (!skipIntroEnabled) { setSkipIntervals([]); setCurrentInterval(null); setIsVisible(false); fetchedRef.current = false; - if (onCreditsInfo) onCreditsInfo(null); return; } // Skip if not a series or missing required data (though MAL/Kitsu ID might be enough for some cases, usually need season/ep) - if (type !== 'series' || (!imdbId && !malId && !kitsuId && !tmdbId) || !season || !episode) { + if (type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { setSkipIntervals([]); fetchedRef.current = false; - if (onCreditsInfo) onCreditsInfo(null); return; } @@ -100,35 +94,24 @@ export const SkipIntroButton: React.FC = ({ setSkipIntervals([]); const fetchSkipData = async () => { - logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (TMDB: ${tmdbId}, IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`); + logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`); try { - const mediaType = type === 'series' ? 'tv' : type === 'movie' ? 'movie' : 'tv'; - const result = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, tmdbId, mediaType); - setSkipIntervals(result.intervals); + const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId); + setSkipIntervals(intervals); - // Pass credits info to parent via callback - if (onCreditsInfo) { - onCreditsInfo(result.credits); - } - - if (result.intervals.length > 0) { - logger.log(`[SkipIntroButton] ✓ Found ${result.intervals.length} skip intervals:`, result.intervals); + if (intervals.length > 0) { + logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals); } else { logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`); } - - if (result.credits) { - logger.log(`[SkipIntroButton] ✓ Found credits timing:`, result.credits); - } } catch (error) { logger.error('[SkipIntroButton] Error fetching skip data:', error); setSkipIntervals([]); - if (onCreditsInfo) onCreditsInfo(null); } }; fetchSkipData(); - }, [imdbId, type, season, episode, malId, kitsuId, tmdbId, skipIntroEnabled, onCreditsInfo]); + }, [imdbId, type, season, episode, malId, kitsuId, skipIntroEnabled]); // Determine active interval based on current playback position useEffect(() => { diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index 838afb94..c285a1de 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -59,7 +59,6 @@ export interface AppSettings { // Playback behavior alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85% skipIntroEnabled: boolean; // Enable/disable Skip Intro overlay (IntroDB) - introDbSource: 'theintrodb' | 'introdb'; // Preferred IntroDB source: TheIntroDB (new) or IntroDB (legacy) // Downloads enableDownloads: boolean; // Show Downloads tab and enable saving streams // Theme settings @@ -148,7 +147,6 @@ export const DEFAULT_SETTINGS: AppSettings = { // Playback behavior defaults alwaysResume: true, skipIntroEnabled: true, - introDbSource: 'theintrodb', // Default to TheIntroDB (new API) // Downloads enableDownloads: false, useExternalPlayerForDownloads: false, diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx index 00b3f998..8cd69710 100644 --- a/src/screens/settings/PlaybackSettingsScreen.tsx +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback, useMemo, useRef, useEffect } from 'react'; -import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Dimensions, Image } from 'react-native'; +import { View, StyleSheet, ScrollView, StatusBar, Platform, Text, TouchableOpacity, Dimensions } from 'react-native'; import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -17,7 +17,6 @@ import { SvgXml } from 'react-native-svg'; const { width } = Dimensions.get('window'); const INTRODB_LOGO_URI = 'https://introdb.app/images/logo-vector.svg'; -const THEINTRODB_FAVICON_URI = 'https://theintrodb.org/favicon.ico'; // Available languages for audio/subtitle selection const AVAILABLE_LANGUAGES = [ @@ -78,7 +77,6 @@ export const PlaybackSettingsContent: React.FC = ( const config = useRealtimeConfig(); const [introDbLogoXml, setIntroDbLogoXml] = useState(null); - const [theIntroDbLoaded, setTheIntroDbLoaded] = useState(false); useEffect(() => { let cancelled = false; @@ -105,57 +103,20 @@ export const PlaybackSettingsContent: React.FC = ( }; }, []); - // Preload TheIntroDB favicon - useEffect(() => { - let cancelled = false; - const load = async () => { - try { - await fetch(THEINTRODB_FAVICON_URI); - if (!cancelled) setTheIntroDbLoaded(true); - } catch { - if (!cancelled) setTheIntroDbLoaded(false); - } - }; - load(); - return () => { - cancelled = true; - }; - }, []); - - const introDbLogoIcon = useMemo(() => { - const selectedSource = settings?.introDbSource || 'theintrodb'; - - if (selectedSource === 'theintrodb') { - // Show TheIntroDB favicon - return theIntroDbLoaded ? ( - - ) : ( - - ); - } else { - // Show IntroDB logo (legacy) - return introDbLogoXml ? ( - - ) : ( - - ); - } - }, [settings?.introDbSource, introDbLogoXml, theIntroDbLoaded, currentTheme.colors.primary]); + const introDbLogoIcon = introDbLogoXml ? ( + + ) : ( + + ); // Bottom sheet refs const audioLanguageSheetRef = useRef(null); const subtitleLanguageSheetRef = useRef(null); const subtitleSourceSheetRef = useRef(null); - const introSourceSheetRef = useRef(null); // Snap points const languageSnapPoints = useMemo(() => ['70%'], []); const sourceSnapPoints = useMemo(() => ['45%'], []); - const introSourceSnapPoints = useMemo(() => ['35%'], []); // Handlers to present sheets - ensure only one is open at a time const openAudioLanguageSheet = useCallback(() => { @@ -176,13 +137,6 @@ export const PlaybackSettingsContent: React.FC = ( setTimeout(() => subtitleSourceSheetRef.current?.present(), 100); }, []); - const openIntroSourceSheet = useCallback(() => { - audioLanguageSheetRef.current?.dismiss(); - subtitleLanguageSheetRef.current?.dismiss(); - subtitleSourceSheetRef.current?.dismiss(); - setTimeout(() => introSourceSheetRef.current?.present(), 100); - }, []); - const isItemVisible = (itemId: string) => { if (!config?.items) return true; const item = config.items[itemId]; @@ -234,17 +188,6 @@ export const PlaybackSettingsContent: React.FC = ( subtitleSourceSheetRef.current?.dismiss(); }; - const handleSelectIntroSource = (value: 'theintrodb' | 'introdb') => { - updateSetting('introDbSource', value); - introSourceSheetRef.current?.dismiss(); - }; - - const getIntroSourceLabel = (value: string) => { - if (value === 'theintrodb') return 'TheIntroDB'; - if (value === 'introdb') return 'IntroDB'; - return 'TheIntroDB'; - }; - return ( <> {hasVisibleItems(['video_player']) && ( @@ -269,7 +212,7 @@ export const PlaybackSettingsContent: React.FC = ( ( = ( onValueChange={(value) => updateSetting('skipIntroEnabled', value)} /> )} + isLast isTablet={isTablet} /> - {settings?.skipIntroEnabled && ( - } - onPress={openIntroSourceSheet} - isLast - isTablet={isTablet} - /> - )} {/* Audio & Subtitle Preferences */} @@ -509,67 +442,6 @@ export const PlaybackSettingsContent: React.FC = ( })} - - {/* Intro Source Bottom Sheet */} - - - Skip Intro Source - - - {[ - { value: 'theintrodb', label: 'TheIntroDB', description: 'theintrodb.org - Supports skip recap and end credits if available', logo: THEINTRODB_FAVICON_URI }, - { value: 'introdb', label: 'IntroDB', description: 'Skip Intro database by introdb.app', logo: INTRODB_LOGO_URI } - ].map((option) => { - const isSelected = option.value === (settings?.introDbSource || 'theintrodb'); - return ( - handleSelectIntroSource(option.value as 'theintrodb' | 'introdb')} - > - - - {option.value === 'theintrodb' ? ( - - ) : ( - introDbLogoXml ? ( - - ) : ( - - ) - )} - - {option.label} - - - - {option.description} - - - {isSelected && ( - - )} - - ); - })} - - ); }; diff --git a/src/services/introService.ts b/src/services/introService.ts index 1cce4ee1..9d225903 100644 --- a/src/services/introService.ts +++ b/src/services/introService.ts @@ -1,7 +1,6 @@ import axios from 'axios'; import { logger } from '../utils/logger'; import { tmdbService } from './tmdbService'; -import { mmkvStorage } from './mmkvStorage'; /** * IntroDB API service for fetching TV show intro timestamps @@ -9,7 +8,6 @@ import { mmkvStorage } from './mmkvStorage'; */ const INTRODB_API_URL = process.env.EXPO_PUBLIC_INTRODB_API_URL; -const THEINTRODB_API_URL = 'https://api.theintrodb.org/v1'; const ANISKIP_API_URL = 'https://api.aniskip.com/v2'; const KITSU_API_URL = 'https://kitsu.io/api/edge'; const ARM_IMDB_URL = 'https://arm.haglund.dev/api/v2/imdb'; @@ -20,31 +18,10 @@ export interface SkipInterval { startTime: number; endTime: number; type: SkipType; - provider: 'introdb' | 'aniskip' | 'theintrodb'; + provider: 'introdb' | 'aniskip'; skipId?: string; } -export interface CreditsInfo { - startTime: number | null; - endTime: number | null; - confidence: number; -} - -export interface TheIntroDBTimestamp { - start_ms: number | null; - end_ms: number | null; - confidence: number; - submission_count: number; -} - -export interface TheIntroDBResponse { - tmdb_id: number; - type: 'movie' | 'tv'; - intro?: TheIntroDBTimestamp; - recap?: TheIntroDBTimestamp; - credits?: TheIntroDBTimestamp; -} - export interface IntroTimestamps { imdb_id: string; season: number; @@ -175,75 +152,6 @@ async function fetchFromAniSkip(malId: string, episode: number): Promise { - try { - const params: any = { tmdb_id: tmdbId }; - if (type === 'tv' && season !== undefined && episode !== undefined) { - params.season = season; - params.episode = episode; - } - - const response = await axios.get(`${THEINTRODB_API_URL}/media`, { - params, - timeout: 5000, - }); - - const intervals: SkipInterval[] = []; - let credits: CreditsInfo | null = null; - - // Add intro skip interval if available - if (response.data.intro && response.data.intro.end_ms !== null) { - intervals.push({ - startTime: response.data.intro.start_ms !== null ? response.data.intro.start_ms / 1000 : 0, - endTime: response.data.intro.end_ms / 1000, - type: 'intro', - provider: 'theintrodb' - }); - } - - // Add recap skip interval if available - if (response.data.recap && response.data.recap.start_ms !== null && response.data.recap.end_ms !== null) { - intervals.push({ - startTime: response.data.recap.start_ms / 1000, - endTime: response.data.recap.end_ms / 1000, - type: 'recap', - provider: 'theintrodb' - }); - } - - // Store credits info for next episode button timing - if (response.data.credits && response.data.credits.start_ms !== null) { - credits = { - startTime: response.data.credits.start_ms / 1000, - endTime: response.data.credits.end_ms !== null ? response.data.credits.end_ms / 1000 : null, - confidence: response.data.credits.confidence - }; - } - - if (intervals.length > 0 || credits) { - logger.log(`[IntroService] TheIntroDB found data for TMDB ${tmdbId}:`, { - intervals: intervals.length, - hasCredits: !!credits - }); - } - - return { intervals, credits }; - } catch (error: any) { - if (axios.isAxiosError(error) && error.response?.status === 404) { - logger.log(`[IntroService] No TheIntroDB data for TMDB ${tmdbId}`); - return { intervals: [], credits: null }; - } - - logger.error('[IntroService] Error fetching from TheIntroDB:', error?.message || error); - return { intervals: [], credits: null }; - } -} - async function fetchFromIntroDb(imdbId: string, season: number, episode: number): Promise { try { const response = await axios.get(`${INTRODB_API_URL}/intro`, { @@ -287,52 +195,19 @@ export async function getSkipTimes( season: number, episode: number, malId?: string, - kitsuId?: string, - tmdbId?: number, - type?: 'movie' | 'tv' -): Promise<{ intervals: SkipInterval[], credits: CreditsInfo | null }> { - // Get user preference for intro source - const introDbSource = mmkvStorage.getString('introDbSource') || 'theintrodb'; - - if (introDbSource === 'theintrodb') { - // User prefers TheIntroDB (new API) - // 1. Try TheIntroDB (Primary) - Supports both movies and TV shows - if (tmdbId && type) { - const theIntroDbResult = await fetchFromTheIntroDb(tmdbId, type, season, episode); - if (theIntroDbResult.intervals.length > 0 || theIntroDbResult.credits) { - return theIntroDbResult; - } - } - - // 2. Try old IntroDB (Fallback for TV Shows) - if (imdbId) { - const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode); - if (introDbIntervals.length > 0) { - return { intervals: introDbIntervals, credits: null }; - } - } - } else { - // User prefers IntroDB (legacy) - // 1. Try old IntroDB first - if (imdbId) { - const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode); - if (introDbIntervals.length > 0) { - return { intervals: introDbIntervals, credits: null }; - } - } - - // 2. Try TheIntroDB as fallback - if (tmdbId && type) { - const theIntroDbResult = await fetchFromTheIntroDb(tmdbId, type, season, episode); - if (theIntroDbResult.intervals.length > 0 || theIntroDbResult.credits) { - return theIntroDbResult; - } + kitsuId?: string +): Promise { + // 1. Try IntroDB (TV Shows) first + if (imdbId) { + const introDbIntervals = await fetchFromIntroDb(imdbId, season, episode); + if (introDbIntervals.length > 0) { + return introDbIntervals; } } - // 3. Try AniSkip (Anime) if we have MAL ID or Kitsu ID + // 2. Try AniSkip (Anime) if we have MAL ID or Kitsu ID let finalMalId = malId; - + // If we have Kitsu ID but no MAL ID, try to resolve it if (!finalMalId && kitsuId) { logger.log(`[IntroService] Resolving MAL ID from Kitsu ID: ${kitsuId}`); @@ -357,11 +232,11 @@ export async function getSkipTimes( const aniSkipIntervals = await fetchFromAniSkip(finalMalId, episode); if (aniSkipIntervals.length > 0) { logger.log(`[IntroService] Found ${aniSkipIntervals.length} skip intervals from AniSkip`); - return { intervals: aniSkipIntervals, credits: null }; + return aniSkipIntervals; } } - return { intervals: [], credits: null }; + return []; } /**