diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index b809812..e1a99a5 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -42,6 +42,7 @@ export interface AppSettings { scraperTimeout: number; // Timeout for scraper execution in seconds enableScraperUrlValidation: boolean; // Enable/disable URL validation for scrapers streamDisplayMode: 'separate' | 'grouped'; // How to display streaming links - separately by provider or grouped under one name + streamSortMode: 'scraper-then-quality' | 'quality-then-scraper'; // How to sort streams - by scraper first or quality first // Quality filtering settings excludedQualities: string[]; // Array of quality strings to exclude (e.g., ['2160p', '4K', '1080p', '720p']) } @@ -68,6 +69,7 @@ export const DEFAULT_SETTINGS: AppSettings = { scraperTimeout: 60, // 60 seconds timeout enableScraperUrlValidation: true, // Enable URL validation by default streamDisplayMode: 'separate', // Default to separate display by provider + streamSortMode: 'scraper-then-quality', // Default to current behavior (scraper first, then quality) // Quality filtering defaults excludedQualities: [], // No qualities excluded by default }; diff --git a/src/screens/PluginsScreen.tsx b/src/screens/PluginsScreen.tsx index 247d94d..7562490 100644 --- a/src/screens/PluginsScreen.tsx +++ b/src/screens/PluginsScreen.tsx @@ -796,12 +796,34 @@ const PluginsScreen: React.FC = () => { updateSetting('streamDisplayMode', value ? 'grouped' : 'separate')} + onValueChange={(value) => { + updateSetting('streamDisplayMode', value ? 'grouped' : 'separate'); + // Auto-disable quality sorting when grouping is disabled + if (!value && settings.streamSortMode === 'quality-then-scraper') { + updateSetting('streamSortMode', 'scraper-then-quality'); + } + }} trackColor={{ false: colors.elevation3, true: colors.primary }} thumbColor={settings.streamDisplayMode === 'grouped' ? colors.white : '#f4f3f4'} disabled={!settings.enableLocalScrapers} /> + + + + Sort by Quality First + + When enabled, streams are sorted by quality first, then by scraper. When disabled, streams are sorted by scraper first, then by quality. Only available when grouping is enabled. + + + updateSetting('streamSortMode', value ? 'quality-then-scraper' : 'scraper-then-quality')} + trackColor={{ false: colors.elevation3, true: colors.primary }} + thumbColor={settings.streamSortMode === 'quality-then-scraper' ? colors.white : '#f4f3f4'} + disabled={!settings.enableLocalScrapers || settings.streamDisplayMode !== 'grouped'} + /> + {/* Quality Filtering */} diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 5828b8f..c7a7244 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -537,6 +537,84 @@ export const StreamsScreen = () => { }); }, [settings.excludedQualities]); + // Helper function to sort streams based on user preference + const sortStreams = useCallback((streams: Stream[]) => { + const installedAddons = stremioService.getInstalledAddons(); + + // Helper function to extract quality as number + const getQualityNumeric = (title: string | undefined): number => { + if (!title) return 0; + + // Check for 4K first (treat as 2160p) + if (/\b4k\b/i.test(title)) { + return 2160; + } + + const matchWithP = title.match(/(\d+)p/i); + if (matchWithP) return parseInt(matchWithP[1], 10); + + const qualityPatterns = [ + /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i + ]; + + for (const pattern of qualityPatterns) { + const match = title.match(pattern); + if (match) { + const quality = parseInt(match[1], 10); + if (quality >= 240 && quality <= 8000) return quality; + } + } + return 0; + }; + + // Provider priority (higher number = higher priority) + const getProviderPriority = (stream: Stream): number => { + const addonId = stream.addonId || stream.addonName || ''; + const addonIndex = installedAddons.findIndex(addon => addon.id === addonId); + + if (addonIndex !== -1) { + // Higher priority for addons installed earlier (reverse index) + return 50 - addonIndex; + } + + return 0; // Unknown providers get lowest priority + }; + + return [...streams].sort((a, b) => { + const qualityA = getQualityNumeric(a.name || a.title); + const qualityB = getQualityNumeric(b.name || b.title); + const providerPriorityA = getProviderPriority(a); + const providerPriorityB = getProviderPriority(b); + const isCachedA = a.behaviorHints?.cached || false; + const isCachedB = b.behaviorHints?.cached || false; + + // Always prioritize cached/debrid streams first + if (isCachedA !== isCachedB) { + return isCachedA ? -1 : 1; + } + + if (settings.streamSortMode === 'quality-then-scraper') { + // Sort by quality first, then by provider + if (qualityA !== qualityB) { + return qualityB - qualityA; // Higher quality first + } + if (providerPriorityA !== providerPriorityB) { + return providerPriorityB - providerPriorityA; // Better provider first + } + } else { + // Default: Sort by provider first, then by quality + if (providerPriorityA !== providerPriorityB) { + return providerPriorityB - providerPriorityA; // Better provider first + } + if (qualityA !== qualityB) { + return qualityB - qualityA; // Higher quality first + } + } + + return 0; + }); + }, [settings.excludedQualities, settings.streamSortMode]); + // Function to determine the best stream based on quality, provider priority, and other factors const getBestStream = useCallback((streamsData: typeof groupedStreams): Stream | null => { if (!streamsData || Object.keys(streamsData).length === 0) { @@ -546,6 +624,12 @@ export const StreamsScreen = () => { // Helper function to extract quality as number const getQualityNumeric = (title: string | undefined): number => { if (!title) return 0; + + // Check for 4K first (treat as 2160p) + if (/\b4k\b/i.test(title)) { + return 2160; + } + const matchWithP = title.match(/(\d+)p/i); if (matchWithP) return parseInt(matchWithP[1], 10); @@ -997,16 +1081,17 @@ export const StreamsScreen = () => { filteredEntries.forEach(([addonId, { addonName, streams: providerStreams }]) => { const isInstalledAddon = installedAddons.some(addon => addon.id === addonId); - // Apply quality filtering to streams + // Apply quality filtering and sorting to streams const filteredStreams = filterStreamsByQuality(providerStreams); + const sortedStreams = sortStreams(filteredStreams); if (isInstalledAddon) { - addonStreams.push(...filteredStreams); + addonStreams.push(...sortedStreams); if (!addonNames.includes(addonName)) { addonNames.push(addonName); } } else { - pluginStreams.push(...filteredStreams); + pluginStreams.push(...sortedStreams); if (!pluginNames.includes(addonName)) { pluginNames.push(addonName); } @@ -1015,17 +1100,25 @@ export const StreamsScreen = () => { const sections = []; if (addonStreams.length > 0) { + // Apply final sorting to the combined addon streams for quality-first mode + const finalSortedAddonStreams = settings.streamSortMode === 'quality-then-scraper' ? + sortStreams(addonStreams) : addonStreams; + sections.push({ title: addonNames.join(', '), addonId: 'grouped-addons', - data: addonStreams + data: finalSortedAddonStreams }); } if (pluginStreams.length > 0) { + // Apply final sorting to the combined plugin streams for quality-first mode + const finalSortedPluginStreams = settings.streamSortMode === 'quality-then-scraper' ? + sortStreams(pluginStreams) : pluginStreams; + sections.push({ title: localScraperService.getRepositoryName(), addonId: 'grouped-plugins', - data: pluginStreams + data: finalSortedPluginStreams }); } @@ -1033,17 +1126,18 @@ export const StreamsScreen = () => { } else { // Use separate sections for each provider (current behavior) return filteredEntries.map(([addonId, { addonName, streams: providerStreams }]) => { - // Apply quality filtering to streams + // Apply quality filtering and sorting to streams const filteredStreams = filterStreamsByQuality(providerStreams); + const sortedStreams = sortStreams(filteredStreams); return { title: addonName, addonId, - data: filteredStreams + data: sortedStreams }; }); } - }, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality]); + }, [selectedProvider, type, episodeStreams, groupedStreams, settings.streamDisplayMode, filterStreamsByQuality, sortStreams]); const episodeImage = useMemo(() => { if (episodeThumbnail) {