diff --git a/.env.sentry-build-plugin b/.env.sentry-build-plugin new file mode 100644 index 0000000..9ef4f71 --- /dev/null +++ b/.env.sentry-build-plugin @@ -0,0 +1 @@ +SENTRY_ALLOW_FAILURE=true \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..48e6889 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,37 @@ +name: Release Build + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/checkout@v3 + + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build app + run: | + npm run build + + - name: Create Release + uses: softprops/action-gh-release@v1 + with: + files: | + android/app/build/outputs/apk/release/*.apk + body_path: ALPHA_BUILD_2_ANNOUNCEMENT.md + draft: true + prerelease: true + generate_release_notes: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index bf6e7ea..be2b4be 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,6 @@ expo-env.d.ts *.p12 *.key *.mobileprovision -android/ -ios/ # Metro .metro-health-check* @@ -37,4 +35,11 @@ yarn-error.* # typescript *.tsbuildinfo plan.md -release_announcement.md \ No newline at end of file +release_announcement.md +ALPHA_BUILD_2_ANNOUNCEMENT.md +CHANGELOG.md +.env.local +android/ +HEATING_OPTIMIZATIONS.md +ios +android \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/App.tsx b/App.tsx index bf19654..82d898f 100644 --- a/App.tsx +++ b/App.tsx @@ -5,7 +5,7 @@ * @format */ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { View, StyleSheet @@ -25,6 +25,24 @@ import { GenreProvider } from './src/contexts/GenreContext'; import { TraktProvider } from './src/contexts/TraktContext'; import { ThemeProvider, useTheme } from './src/contexts/ThemeContext'; import SplashScreen from './src/components/SplashScreen'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import * as Sentry from '@sentry/react-native'; + +Sentry.init({ + dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992', + + // Adds more context data to events (IP address, cookies, user, etc.) + // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/ + sendDefaultPii: true, + + // Configure Session Replay + replaysSessionSampleRate: 0.1, + replaysOnErrorSampleRate: 1, + integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()], + + // uncomment the line below to enable Spotlight (https://spotlightjs.com) + // spotlight: __DEV__, +}); // This fixes many navigation layout issues by using native screen containers enableScreens(true); @@ -33,6 +51,23 @@ enableScreens(true); const ThemedApp = () => { const { currentTheme } = useTheme(); const [isAppReady, setIsAppReady] = useState(false); + const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(null); + + // Check onboarding status + useEffect(() => { + const checkOnboardingStatus = async () => { + try { + const onboardingCompleted = await AsyncStorage.getItem('hasCompletedOnboarding'); + setHasCompletedOnboarding(onboardingCompleted === 'true'); + } catch (error) { + console.error('Error checking onboarding status:', error); + // Default to showing onboarding if we can't check + setHasCompletedOnboarding(false); + } + }; + + checkOnboardingStatus(); + }, []); // Create custom themes based on current theme const customDarkTheme = { @@ -58,6 +93,10 @@ const ThemedApp = () => { setIsAppReady(true); }; + // Don't render anything until we know the onboarding status + const shouldShowApp = isAppReady && hasCompletedOnboarding !== null; + const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding'; + return ( { style="light" /> {!isAppReady && } - {isAppReady && } + {shouldShowApp && } @@ -99,4 +138,4 @@ const styles = StyleSheet.create({ }, }); -export default App; +export default Sentry.wrap(App); \ No newline at end of file diff --git a/NOTIFICATION_INTEGRATION_SUMMARY.md b/NOTIFICATION_INTEGRATION_SUMMARY.md new file mode 100644 index 0000000..cb96d94 --- /dev/null +++ b/NOTIFICATION_INTEGRATION_SUMMARY.md @@ -0,0 +1,232 @@ +# 🔔 Comprehensive Notification Integration - Implementation Summary + +## ✅ **What Was Implemented** + +I've successfully integrated notifications with your library and Trakt system, adding automatic background notifications for all saved shows. Here's what's now working: + +--- + +## 🚀 **1. Library Auto-Integration** + +### **Automatic Notification Setup** +- **When adding series to library**: Notifications are automatically scheduled for upcoming episodes +- **When removing series from library**: All related notifications are automatically cancelled +- **Real-time sync**: Changes to library immediately trigger notification updates + +### **Implementation Details:** +```typescript +// In catalogService.ts - Auto-setup when adding to library +public async addToLibrary(content: StreamingContent): Promise { + // ... existing code ... + + // Auto-setup notifications for series when added to library + if (content.type === 'series') { + await notificationService.updateNotificationsForSeries(content.id); + } +} +``` + +--- + +## 🎬 **2. Trakt Integration** + +### **Comprehensive Trakt Support** +- **Trakt Watchlist**: Automatically syncs notifications for shows in your Trakt watchlist +- **Trakt Collection**: Syncs notifications for shows in your Trakt collection +- **Background Sync**: Periodically checks Trakt for new shows and updates notifications +- **Authentication Handling**: Automatically detects when Trakt is connected/disconnected + +### **What Gets Synced:** +- All series from your Trakt watchlist +- All series from your Trakt collection +- Automatic deduplication with local library +- IMDB ID mapping for accurate show identification + +--- + +## ⏰ **3. Background Notifications** + +### **Automatic Background Processing** +- **6-hour sync cycle**: Automatically syncs all notifications every 6 hours +- **App foreground sync**: Syncs when app comes to foreground +- **Library change sync**: Immediate sync when library changes +- **Trakt change detection**: Syncs when Trakt data changes + +### **Smart Episode Detection:** +- **4-week window**: Finds episodes airing in the next 4 weeks +- **Multiple data sources**: Uses Stremio first, falls back to TMDB +- **Duplicate prevention**: Won't schedule same episode twice +- **Automatic cleanup**: Removes old/expired notifications + +--- + +## 📱 **4. Enhanced Settings Screen** + +### **New Features Added:** +- **Notification Stats Display**: Shows upcoming, this week, and total notifications +- **Manual Sync Button**: "Sync Library & Trakt" button for immediate sync +- **Real-time Stats**: Stats update automatically after sync +- **Visual Feedback**: Loading states and success messages + +### **Stats Dashboard:** +``` +📅 Upcoming: 12 📆 This Week: 3 🔔 Total: 15 +``` + +--- + +## 🔧 **5. Technical Implementation** + +### **Enhanced NotificationService Features:** + +#### **Library Integration:** +```typescript +private setupLibraryIntegration(): void { + // Subscribe to library updates from catalog service + this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => { + await this.syncNotificationsForLibrary(libraryItems); + }); +} +``` + +#### **Trakt Integration:** +```typescript +private async syncTraktNotifications(): Promise { + // Get Trakt watchlist and collection shows + const [watchlistShows, collectionShows] = await Promise.all([ + traktService.getWatchlistShows(), + traktService.getCollectionShows() + ]); + // Sync notifications for each show +} +``` + +#### **Background Sync:** +```typescript +private setupBackgroundSync(): void { + // Sync notifications every 6 hours + this.backgroundSyncInterval = setInterval(async () => { + await this.performBackgroundSync(); + }, 6 * 60 * 60 * 1000); +} +``` + +--- + +## 📊 **6. Data Sources & Fallbacks** + +### **Multi-Source Episode Detection:** +1. **Primary**: Stremio addon metadata +2. **Fallback**: TMDB API for episode air dates +3. **Smart Mapping**: Handles both IMDB IDs and TMDB IDs +4. **Season Detection**: Checks current and upcoming seasons + +### **Notification Content:** +``` +Title: "New Episode: Breaking Bad" +Body: "S5:E14 - Ozymandias is airing soon!" +Data: { seriesId: "tt0903747", episodeId: "..." } +``` + +--- + +## 🎯 **7. User Experience Improvements** + +### **Seamless Integration:** +- **Zero manual setup**: Works automatically when you add shows +- **Cross-platform sync**: Trakt integration keeps notifications in sync across devices +- **Smart timing**: Respects user's preferred notification timing (1h, 6h, 12h, 24h) +- **Battery optimized**: Efficient background processing + +### **Visual Feedback:** +- **Stats dashboard**: See exactly how many notifications are scheduled +- **Sync status**: Clear feedback when syncing completes +- **Error handling**: Graceful handling of API failures + +--- + +## 🔄 **8. Automatic Workflows** + +### **When You Add a Show to Library:** +1. Show is added to local library +2. Notification service automatically triggered +3. Upcoming episodes detected (next 4 weeks) +4. Notifications scheduled based on your timing preference +5. Stats updated in settings screen + +### **When You Add a Show to Trakt:** +1. Background sync detects new Trakt show (within 6 hours or on app open) +2. Show metadata fetched +3. Notifications scheduled automatically +4. No manual intervention required + +### **When Episodes Air:** +1. Notification delivered at your preferred time +2. Old notifications automatically cleaned up +3. Stats updated to reflect current state + +--- + +## 📈 **9. Performance Optimizations** + +### **Efficient Processing:** +- **Batch operations**: Processes multiple shows efficiently +- **API rate limiting**: Includes delays to prevent overwhelming APIs +- **Memory management**: Cleans up old notifications automatically +- **Error resilience**: Continues processing even if individual shows fail + +### **Background Processing:** +- **Non-blocking**: Doesn't interfere with app performance +- **Intelligent scheduling**: Only syncs when necessary +- **Resource conscious**: Optimized for battery life + +--- + +## 🎉 **10. What This Means for Users** + +### **Before:** +- Manual notification setup required +- No integration with library or Trakt +- Limited to manually added shows +- No background updates + +### **After:** +- ✅ **Automatic**: Add any show to library → notifications work automatically +- ✅ **Trakt Sync**: Your Trakt watchlist/collection → automatic notifications +- ✅ **Background**: Always up-to-date without manual intervention +- ✅ **Smart**: Finds episodes from multiple sources +- ✅ **Visual**: Clear stats and sync controls + +--- + +## 🔧 **11. How to Use** + +### **For Library Shows:** +1. Add any series to your library (heart icon) +2. Notifications automatically scheduled +3. Check stats in Settings → Notification Settings + +### **For Trakt Shows:** +1. Connect your Trakt account +2. Add shows to Trakt watchlist or collection +3. Notifications sync automatically (within 6 hours or on app open) +4. Use "Sync Library & Trakt" button for immediate sync + +### **Manual Control:** +- Go to Settings → Notification Settings +- View notification stats +- Use "Sync Library & Trakt" for immediate sync +- Adjust timing preferences (1h, 6h, 12h, 24h before airing) + +--- + +## 🚀 **Result** + +Your notification system now provides a **Netflix-like experience** where: +- Adding shows automatically sets up notifications +- Trakt integration keeps everything in sync +- Background processing ensures you never miss episodes +- Smart episode detection works across multiple data sources +- Visual feedback shows exactly what's scheduled + +The system is now **fully automated** and **user-friendly**, requiring zero manual setup while providing comprehensive coverage of all your shows from both local library and Trakt integration. \ No newline at end of file diff --git a/README.md b/README.md index c3f0be0..7de56a0 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,134 @@ +# Nuvio Streaming App +

Nuvio Logo

-# Nuvio +

+ A modern streaming app built with React Native and Expo, featuring Stremio addon integration, Trakt synchronization, and a beautiful user interface. +

-An app I built with React Native/Expo for browsing and watching movies & shows. It uses Stremio-compatible addons to find streaming sources. +## ⚠️ Alpha Testing +This app is currently in alpha testing. Please report any bugs or issues you encounter. -Built for iOS and Android. +[Download Latest Release](https://github.com/tapframe/NuvioStreaming/releases/latest) -## Key Features +## ✨ Key Features -* **Home Screen:** Highlights new content, your watch history, and content categories. -* **Discover:** Browse trending and popular movies & TV shows. -* **Details:** Displays detailed info (descriptions, cast, ratings). -* **Video Player:** Integrated player(still broken on IOS,supports External PLayer for now). -* **Stream Finding:** Finds available streams using Stremio addons. -* **Search:** Quickly find specific movies or shows. -* **Trakt Sync:** Planned integration (coming soon). -* **Addon Management:** Add and manage your Stremio addons. -* **UI:** Focuses on a clean, interactive user experience. +### Content & Discovery +- **Smart Home Screen:** Personalized content recommendations and continue watching +- **Discover Section:** Browse trending and popular movies & TV shows +- **Rich Metadata:** Detailed information, cast, ratings, and similar content +- **Powerful Search:** Find content quickly with instant results + +### Streaming & Playback +- **Advanced Video Player:** + - Built-in player with gesture controls + - External player support + - Auto-quality selection + - Subtitle customization +- **Smart Stream Selection:** Automatically finds the best available streams +- **Auto-Play:** Seamless playback of next episodes +- **Continue Watching:** Resume from where you left off + +### Integration & Sync +- **Trakt Integration:** + - Account synchronization + - Watch history tracking + - Library management + - Progress syncing +- **Stremio Addons:** + - Compatible with Stremio addon system + - Easy addon management + - Multiple source support + +### User Experience +- **Modern UI/UX:** Clean, intuitive interface with smooth animations +- **Performance:** Optimized for smooth scrolling and quick loading +- **Customization:** Theme options and display preferences +- **Cross-Platform:** Works on both iOS and Android ## 📸 Screenshots -| Home | Discover | Search | -| :----------------------------------------- | :----------------------------------------- | :--------------------------------------- | -| ![Home](src/assets/home.jpg) | ![Discover](src/assets/discover.jpg) | ![Search](src/assets/search.jpg) | -| **Metadata** | **Seasons & Episodes** | **Rating** | -| ![Metadata](src/assets/metadascreen.jpg) | ![Seasons](src/assets/seasonandepisode.jpg)| ![Rating](src/assets/ratingscreen.jpg) | +| Home & Continue Watching | Discover & Browse | Search & Details | +|:-----------------------:|:-----------------:|:----------------:| +| ![Home](src/assets/home.jpg) | ![Discover](src/assets/discover.jpg) | ![Search](src/assets/search.jpg) | +| **Content Details** | **Episodes & Seasons** | **Ratings & Info** | +| ![Metadata](src/assets/metadascreen.jpg) | ![Seasons](src/assets/seasonandepisode.jpg) | ![Rating](src/assets/ratingscreen.jpg) | -## Development +## 🚀 Getting Started -1. You'll need Node.js, npm/yarn, and the Expo Go app (or native build tools like Android Studio/Xcode). -2. `git clone https://github.com/nayifleo1/NuvioExpo.git` -3. `cd NuvioExpo` -4. `npm install` or `yarn install` -5. `npx expo start` (Easiest way: Scan QR code with Expo Go app) - * Or `npx expo run:android` / `npx expo run:ios` for native builds. +### Prerequisites +- Node.js 18 or newer +- npm or yarn +- Expo Go app (for development) +- Android Studio (for Android builds) +- Xcode (for iOS builds) -## Found a bug or have an idea? +### Development Setup +1. Clone the repository: + ```bash + git clone https://github.com/tapframe/NuvioStreaming.git + cd NuvioStreaming + ``` -Great! Please open an [Issue on GitHub](https://github.com/nayifleo1/NuvioExpo/issues). Describe the problem or your suggestion. +2. Install dependencies: + ```bash + npm install + # or + yarn install + ``` -## Contribution +3. Start the development server: + ```bash + npx expo start + ``` -Contributions are welcome! Fork the repository, make your changes, and submit a Pull Request. +4. Run on device/simulator: + - Scan QR code with Expo Go app + - Or run native builds: + ```bash + npx expo run:android + # or + npx expo run:ios + ``` + +## 🤝 Contributing + +We welcome contributions! Here's how you can help: + +1. Fork the repository +2. Create your feature branch +3. Commit your changes +4. Push to the branch +5. Open a Pull Request + +## 🐛 Bug Reports & Feature Requests + +Found a bug or have an idea? Please open an [issue](https://github.com/tapframe/NuvioStreaming/issues) with: +- Clear description of the problem/suggestion +- Steps to reproduce (for bugs) +- Expected behavior +- Screenshots if applicable + +## 📝 Changelog + +See [CHANGELOG.md](CHANGELOG.md) for release history and changes. + +## 📄 License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## 🙏 Acknowledgments + +Built with help from the amazing communities behind: +- React Native & Expo +- TMDB API +- Trakt.tv +- Stremio --- -Built with help from the communities and tools behind React Native, Expo, TMDB, Trakt, and the Stremio addon system. - -*Happy Streaming!* \ No newline at end of file +

+ Happy Streaming! 🎬 +

\ No newline at end of file diff --git a/ScraperforTesting/uhdmovies.js b/ScraperforTesting/uhdmovies.js new file mode 100644 index 0000000..aa325b4 --- /dev/null +++ b/ScraperforTesting/uhdmovies.js @@ -0,0 +1,1234 @@ +const axios = require('axios'); +const cheerio = require('cheerio'); +const { URLSearchParams, URL } = require('url'); +const FormData = require('form-data'); +const { CookieJar } = require('tough-cookie'); +const fs = require('fs').promises; +const path = require('path'); +const RedisCache = require('../utils/redisCache'); + +// Dynamic import for axios-cookiejar-support +let axiosCookieJarSupport = null; +const getAxiosCookieJarSupport = async () => { + if (!axiosCookieJarSupport) { + axiosCookieJarSupport = await import('axios-cookiejar-support'); + } + return axiosCookieJarSupport; +}; + +// --- Domain Fetching --- +let uhdMoviesDomain = 'https://uhdmovies.email'; // Fallback domain +let domainCacheTimestamp = 0; +const DOMAIN_CACHE_TTL = 4 * 60 * 60 * 1000; // 4 hours + +async function getUHDMoviesDomain() { + const now = Date.now(); + if (now - domainCacheTimestamp < DOMAIN_CACHE_TTL) { + return uhdMoviesDomain; + } + + try { + console.log('[UHDMovies] Fetching latest domain...'); + const response = await axios.get('https://raw.githubusercontent.com/phisher98/TVVVV/refs/heads/main/domains.json'); + if (response.data && response.data.UHDMovies) { + uhdMoviesDomain = response.data.UHDMovies; + domainCacheTimestamp = now; + console.log(`[UHDMovies] Updated domain to: ${uhdMoviesDomain}`); + } else { + console.warn('[UHDMovies] Domain JSON fetched, but "UHDMovies" key was not found. Using fallback.'); + } + } catch (error) { + console.error(`[UHDMovies] Failed to fetch latest domain, using fallback. Error: ${error.message}`); + } + return uhdMoviesDomain; +} + +// Constants +const TMDB_API_KEY_UHDMOVIES = "439c478a771f35c05022f9feabcca01c"; // Public TMDB API key + +// --- Caching Configuration --- +const CACHE_ENABLED = process.env.DISABLE_CACHE !== 'true'; // Set to true to disable caching for this provider +console.log(`[UHDMovies] Internal cache is ${CACHE_ENABLED ? 'enabled' : 'disabled'}.`); +const CACHE_DIR = process.env.VERCEL ? path.join('/tmp', '.uhd_cache') : path.join(__dirname, '.cache', 'uhdmovies'); // Cache directory inside providers/uhdmovies + +// Initialize Redis cache +const redisCache = new RedisCache('UHDMovies'); + +// --- Caching Helper Functions --- +const ensureCacheDir = async () => { + if (!CACHE_ENABLED) return; + try { + await fs.mkdir(CACHE_DIR, { recursive: true }); + } catch (error) { + if (error.code !== 'EEXIST') { + console.error(`[UHDMovies Cache] Error creating cache directory: ${error.message}`); + } + } +}; + +const getFromCache = async (key) => { + if (!CACHE_ENABLED) return null; + + // Try Redis cache first, then fallback to file system + const cachedData = await redisCache.getFromCache(key, '', CACHE_DIR); + if (cachedData) { + return cachedData.data || cachedData; // Support both new format (data field) and legacy format + } + + return null; +}; + +const saveToCache = async (key, data) => { + if (!CACHE_ENABLED) return; + + const cacheData = { + data: data + }; + + // Save to both Redis and file system + await redisCache.saveToCache(key, cacheData, '', CACHE_DIR); +}; + +// Initialize cache directory on startup +ensureCacheDir(); + +// Configure axios with headers to mimic a browser +const axiosInstance = axios.create({ + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Cache-Control': 'max-age=0' + } +}); + +// Simple In-Memory Cache +const uhdMoviesCache = { + search: {}, + movie: {}, + show: {} +}; + +// Function to search for movies +async function searchMovies(query) { + try { + const baseUrl = await getUHDMoviesDomain(); + console.log(`[UHDMovies] Searching for: ${query}`); + const searchUrl = `${baseUrl}/search/${encodeURIComponent(query)}`; + + const response = await axiosInstance.get(searchUrl); + const $ = cheerio.load(response.data); + + const searchResults = []; + + // New logic for grid-based search results + $('article.gridlove-post').each((index, element) => { + const linkElement = $(element).find('a[href*="/download-"]'); + if (linkElement.length > 0) { + const link = linkElement.first().attr('href'); + // Prefer the 'title' attribute, fallback to h1 text + const title = linkElement.first().attr('title') || $(element).find('h1.sanket').text().trim(); + + if (link && title && !searchResults.some(item => item.link === link)) { + searchResults.push({ + title, + link: link.startsWith('http') ? link : `${baseUrl}${link}` + }); + } + } + }); + + // Fallback for original list-based search if new logic fails + if (searchResults.length === 0) { + console.log('[UHDMovies] Grid search logic found no results, trying original list-based logic...'); + $('a[href*="/download-"]').each((index, element) => { + const link = $(element).attr('href'); + // Avoid duplicates by checking if link already exists in results + if (link && !searchResults.some(item => item.link === link)) { + const title = $(element).text().trim(); + if (title) { + searchResults.push({ + title, + link: link.startsWith('http') ? link : `${baseUrl}${link}` + }); + } + } + }); + } + + console.log(`[UHDMovies] Found ${searchResults.length} results`); + return searchResults; + } catch (error) { + console.error(`[UHDMovies] Error searching movies: ${error.message}`); + return []; + } +} + +// Function to extract clean quality information from verbose text +function extractCleanQuality(fullQualityText) { + if (!fullQualityText || fullQualityText === 'Unknown Quality') { + return 'Unknown Quality'; + } + + const cleanedFullQualityText = fullQualityText.replace(/(\u00a9|\u00ae|[\u2000-\u3300]|\ud83c[\ud000-\udfff]|\ud83d[\ud000-\udfff]|\ud83e[\ud000-\udfff])/g, '').trim(); + const text = cleanedFullQualityText.toLowerCase(); + let quality = []; + + // Extract resolution + if (text.includes('2160p') || text.includes('4k')) { + quality.push('4K'); + } else if (text.includes('1080p')) { + quality.push('1080p'); + } else if (text.includes('720p')) { + quality.push('720p'); + } else if (text.includes('480p')) { + quality.push('480p'); + } + + // Extract special features + if (text.includes('hdr')) { + quality.push('HDR'); + } + if (text.includes('dolby vision') || text.includes('dovi') || /\bdv\b/.test(text)) { + quality.push('DV'); + } + if (text.includes('imax')) { + quality.push('IMAX'); + } + if (text.includes('bluray') || text.includes('blu-ray')) { + quality.push('BluRay'); + } + + // If we found any quality indicators, join them + if (quality.length > 0) { + return quality.join(' | '); + } + + // Fallback: try to extract a shorter version of the original text + // Look for patterns like "Movie Name (Year) Resolution ..." + const patterns = [ + /(\d{3,4}p.*?(?:x264|x265|hevc).*?)[\[\(]/i, + /(\d{3,4}p.*?)[\[\(]/i, + /((?:720p|1080p|2160p|4k).*?)$/i + ]; + + for (const pattern of patterns) { + const match = cleanedFullQualityText.match(pattern); + if (match && match[1].trim().length < 100) { + return match[1].trim().replace(/x265/ig, 'HEVC'); + } + } + + // Final fallback: truncate if too long + if (cleanedFullQualityText.length > 80) { + return cleanedFullQualityText.substring(0, 77).replace(/x265/ig, 'HEVC') + '...'; + } + + return cleanedFullQualityText.replace(/x265/ig, 'HEVC'); +} + +// Function to extract download links for TV shows from a page +async function extractTvShowDownloadLinks(showPageUrl, season, episode) { + try { + console.log(`[UHDMovies] Extracting TV show links from: ${showPageUrl} for S${season}E${episode}`); + const response = await axiosInstance.get(showPageUrl); + const $ = cheerio.load(response.data); + + const showTitle = $('h1').first().text().trim(); + const downloadLinks = []; + + // --- NEW LOGIC TO SCOPE SEARCH TO THE CORRECT SEASON --- + let inTargetSeason = false; + let qualityText = ''; + + $('.entry-content').find('*').each((index, element) => { + const $el = $(element); + const text = $el.text().trim(); + const seasonMatch = text.match(/^SEASON\s+(\d+)/i); + + // Check if we are entering a new season block + if (seasonMatch) { + const currentSeasonNum = parseInt(seasonMatch[1], 10); + if (currentSeasonNum == season) { + inTargetSeason = true; + console.log(`[UHDMovies] Entering Season ${season} block.`); + } else if (inTargetSeason) { + // We've hit the next season, so we stop. + console.log(`[UHDMovies] Exiting Season ${season} block, now in Season ${currentSeasonNum}.`); + inTargetSeason = false; + return false; // Exit .each() loop + } + } + + if (inTargetSeason) { + // This element is within the correct season's block. + + // Is this a quality header? (e.g., a
 or a 

with ) + // It often contains resolution, release group, etc. + const isQualityHeader = $el.is('pre, p:has(strong), p:has(b), h3, h4'); + if (isQualityHeader) { + const headerText = $el.text().trim(); + // Filter out irrelevant headers. We can be more aggressive here. + if (headerText.length > 5 && !/plot|download|screenshot|trailer|join|powered by|season/i.test(headerText) && !($el.find('a').length > 0)) { + qualityText = headerText; // Store the most recent quality header + } + } + + // Is this a paragraph with episode links? + if ($el.is('p') && $el.find('a[href*="tech.unblockedgames.world"], a[href*="tech.examzculture.in"]').length > 0) { + const linksParagraph = $el; + const episodeRegex = new RegExp(`^Episode\\s+0*${episode}(?!\\d)`, 'i'); + const targetEpisodeLink = linksParagraph.find('a').filter((i, el) => { + return episodeRegex.test($(el).text().trim()); + }).first(); + + if (targetEpisodeLink.length > 0) { + const link = targetEpisodeLink.attr('href'); + if (link && !downloadLinks.some(item => item.link === link)) { + const sizeMatch = qualityText.match(/\[\s*([0-9.,]+\s*[KMGT]B)/i); + const size = sizeMatch ? sizeMatch[1] : 'Unknown'; + + const cleanQuality = extractCleanQuality(qualityText); + const rawQuality = qualityText.replace(/(\r\n|\n|\r)/gm, " ").replace(/\s+/g, ' ').trim(); + + console.log(`[UHDMovies] Found match: Quality='${qualityText}', Link='${link}'`); + downloadLinks.push({ quality: cleanQuality, size: size, link: link, rawQuality: rawQuality }); + } + } + } + } + }); + + if (downloadLinks.length === 0) { + console.log('[UHDMovies] Main extraction logic failed. Trying fallback method without season scoping.'); + $('.entry-content').find('a[href*="tech.unblockedgames.world"], a[href*="tech.examzculture.in"]').each((i, el) => { + const linkElement = $(el); + const episodeRegex = new RegExp(`^Episode\\s+0*${episode}(?!\\d)`, 'i'); + + if (episodeRegex.test(linkElement.text().trim())) { + const link = linkElement.attr('href'); + if (link && !downloadLinks.some(item => item.link === link)) { + let qualityText = 'Unknown Quality'; + const parentP = linkElement.closest('p, div'); + const prevElement = parentP.prev(); + if (prevElement.length > 0) { + const prevText = prevElement.text().trim(); + if (prevText && prevText.length > 5 && !prevText.toLowerCase().includes('download')) { + qualityText = prevText; + } + } + + const sizeMatch = qualityText.match(/\[([0-9.,]+[KMGT]B[^\]]*)\]/i); + const size = sizeMatch ? sizeMatch[1] : 'Unknown'; + const cleanQuality = extractCleanQuality(qualityText); + const rawQuality = qualityText.replace(/(\r\n|\n|\r)/gm, " ").replace(/\s+/g, ' ').trim(); + + console.log(`[UHDMovies] Found match via fallback: Quality='${qualityText}', Link='${link}'`); + downloadLinks.push({ quality: cleanQuality, size: size, link: link, rawQuality: rawQuality }); + } + } + }); + } + + if (downloadLinks.length > 0) { + console.log(`[UHDMovies] Found ${downloadLinks.length} links for S${season}E${episode}.`); + } else { + console.log(`[UHDMovies] Could not find links for S${season}E${episode}. It's possible the logic needs adjustment or the links aren't on the page.`); + } + + return { title: showTitle, links: downloadLinks }; + + } catch (error) { + console.error(`[UHDMovies] Error extracting TV show download links: ${error.message}`); + return { title: 'Unknown', links: [] }; + } +} + +// Function to extract download links from a movie page +async function extractDownloadLinks(moviePageUrl, targetYear = null) { + try { + console.log(`[UHDMovies] Extracting links from: ${moviePageUrl}`); + const response = await axiosInstance.get(moviePageUrl); + const $ = cheerio.load(response.data); + + const movieTitle = $('h1').first().text().trim(); + const downloadLinks = []; + + // Find all download links (the new SID links) and their associated quality information + $('a[href*="tech.unblockedgames.world"], a[href*="tech.examzculture.in"]').each((index, element) => { + const link = $(element).attr('href'); + + if (link && !downloadLinks.some(item => item.link === link)) { + let quality = 'Unknown Quality'; + let size = 'Unknown'; + + // Method 1: Look for quality in the closest preceding paragraph or heading + const prevElement = $(element).closest('p').prev(); + if (prevElement.length > 0) { + const prevText = prevElement.text().trim(); + if (prevText && prevText.length > 20 && !prevText.includes('Download')) { + quality = prevText; + } + } + + // Method 2: Look for quality in parent's siblings + if (quality === 'Unknown Quality') { + const parentSiblings = $(element).parent().prevAll().first().text().trim(); + if (parentSiblings && parentSiblings.length > 20) { + quality = parentSiblings; + } + } + + // Method 3: Look for bold/strong text above the link + if (quality === 'Unknown Quality') { + const strongText = $(element).closest('p').prevAll().find('strong, b').last().text().trim(); + if (strongText && strongText.length > 20) { + quality = strongText; + } + } + + // Method 4: Look for the entire paragraph containing quality info + if (quality === 'Unknown Quality') { + let currentElement = $(element).parent(); + for (let i = 0; i < 5; i++) { + currentElement = currentElement.prev(); + if (currentElement.length === 0) break; + + const text = currentElement.text().trim(); + if (text && text.length > 30 && + (text.includes('1080p') || text.includes('720p') || text.includes('2160p') || + text.includes('4K') || text.includes('HEVC') || text.includes('x264') || text.includes('x265'))) { + quality = text; + break; + } + } + } + + // Year-based filtering for collections + if (targetYear && quality !== 'Unknown Quality') { + // Check for years in quality text + const yearMatches = quality.match(/\((\d{4})\)/g); + let hasMatchingYear = false; + + if (yearMatches && yearMatches.length > 0) { + for (const yearMatch of yearMatches) { + const year = parseInt(yearMatch.replace(/[()]/g, '')); + if (year === targetYear) { + hasMatchingYear = true; + break; + } + } + if (!hasMatchingYear) { + console.log(`[UHDMovies] Skipping link due to year mismatch. Target: ${targetYear}, Found: ${yearMatches.join(', ')} in "${quality}"`); + return; // Skip this link + } + } else { + // If no year in quality text, check filename and other indicators + const linkText = $(element).text().trim(); + const parentText = $(element).parent().text().trim(); + const combinedText = `${quality} ${linkText} ${parentText}`; + + // Look for years in combined text + const allYearMatches = combinedText.match(/\((\d{4})\)/g) || combinedText.match(/(\d{4})/g); + if (allYearMatches) { + let foundTargetYear = false; + for (const yearMatch of allYearMatches) { + const year = parseInt(yearMatch.replace(/[()]/g, '')); + if (year >= 1900 && year <= 2030) { // Valid movie year range + if (year === targetYear) { + foundTargetYear = true; + break; + } + } + } + if (!foundTargetYear && allYearMatches.length > 0) { + console.log(`[UHDMovies] Skipping link due to no matching year found. Target: ${targetYear}, Found years: ${allYearMatches.join(', ')} in combined text`); + return; // Skip this link + } + } + + // Additional check: if quality contains movie names that don't match target year + const lowerQuality = quality.toLowerCase(); + if (targetYear === 2015) { + if (lowerQuality.includes('wasp') || lowerQuality.includes('quantumania')) { + console.log(`[UHDMovies] Skipping link for 2015 target as it contains 'wasp' or 'quantumania': "${quality}"`); + return; // Skip this link + } + } + } + } + + // Extract size from quality text if present + const sizeMatch = quality.match(/\[([0-9.,]+\s*[KMGT]B[^\]]*)\]/); + if (sizeMatch) { + size = sizeMatch[1]; + } + + // Clean up the quality information + const cleanQuality = extractCleanQuality(quality); + + downloadLinks.push({ + quality: cleanQuality, + size: size, + link: link, + rawQuality: quality.replace(/(\r\n|\n|\r)/gm, " ").replace(/\s+/g, ' ').trim() + }); + } + }); + + return { + title: movieTitle, + links: downloadLinks + }; + + } catch (error) { + console.error(`[UHDMovies] Error extracting download links: ${error.message}`); + return { title: 'Unknown', links: [] }; + } +} + +function extractCodecs(rawQuality) { + const codecs = []; + const text = rawQuality.toLowerCase(); + + if (text.includes('hevc') || text.includes('x265')) { + codecs.push('H.265'); + } else if (text.includes('x264')) { + codecs.push('H.264'); + } + + if (text.includes('10bit') || text.includes('10-bit')) { + codecs.push('10-bit'); + } + + if (text.includes('atmos')) { + codecs.push('Atmos'); + } else if (text.includes('dts-hd')) { + codecs.push('DTS-HD'); + } else if (text.includes('dts')) { + codecs.push('DTS'); + } else if (text.includes('ddp5.1') || text.includes('dd+ 5.1') || text.includes('eac3')) { + codecs.push('EAC3'); + } else if (text.includes('ac3')) { + codecs.push('AC3'); + } + + if (text.includes('dovi') || text.includes('dolby vision') || /\bdv\b/.test(text)) { + codecs.push('DV'); + } else if (text.includes('hdr')) { + codecs.push('HDR'); + } + + return codecs; +} + +// Function to try Instant Download method +async function tryInstantDownload($) { + const instantDownloadLink = $('a:contains("Instant Download")').attr('href'); + if (!instantDownloadLink) { + return null; + } + + console.log('[UHDMovies] Found "Instant Download" link, attempting to extract final URL...'); + + try { + const urlParams = new URLSearchParams(new URL(instantDownloadLink).search); + const keys = urlParams.get('url'); + + if (keys) { + const apiUrl = `${new URL(instantDownloadLink).origin}/api`; + const formData = new FormData(); + formData.append('keys', keys); + + const apiResponse = await axiosInstance.post(apiUrl, formData, { + headers: { + ...formData.getHeaders(), + 'x-token': new URL(instantDownloadLink).hostname + } + }); + + if (apiResponse.data && apiResponse.data.url) { + let finalUrl = apiResponse.data.url; + // Fix spaces in workers.dev URLs by encoding them properly + if (finalUrl.includes('workers.dev')) { + const urlParts = finalUrl.split('/'); + const filename = urlParts[urlParts.length - 1]; + const encodedFilename = filename.replace(/ /g, '%20'); + urlParts[urlParts.length - 1] = encodedFilename; + finalUrl = urlParts.join('/'); + } + console.log('[UHDMovies] Extracted final link from API:', finalUrl); + return finalUrl; + } + } + + console.log('[UHDMovies] Could not find a valid final download link from Instant Download.'); + return null; + } catch (error) { + console.log(`[UHDMovies] Error processing "Instant Download": ${error.message}`); + return null; + } +} + +// Function to try Resume Cloud method +async function tryResumeCloud($) { + // Look for both "Resume Cloud" and "Cloud Resume Download" buttons + const resumeCloudButton = $('a:contains("Resume Cloud"), a:contains("Cloud Resume Download")'); + + if (resumeCloudButton.length === 0) { + return null; + } + + const resumeLink = resumeCloudButton.attr('href'); + if (!resumeLink) { + return null; + } + + // Check if it's already a direct download link (workers.dev) + if (resumeLink.includes('workers.dev') || resumeLink.startsWith('http')) { + let directLink = resumeLink; + // Fix spaces in workers.dev URLs by encoding them properly + if (directLink.includes('workers.dev')) { + const urlParts = directLink.split('/'); + const filename = urlParts[urlParts.length - 1]; + const encodedFilename = filename.replace(/ /g, '%20'); + urlParts[urlParts.length - 1] = encodedFilename; + directLink = urlParts.join('/'); + } + console.log(`[UHDMovies] Found direct "Cloud Resume Download" link: ${directLink}`); + return directLink; + } + + // Otherwise, follow the link to get the final download + try { + const resumeUrl = new URL(resumeLink, 'https://driveleech.net').href; + console.log(`[UHDMovies] Found 'Resume Cloud' page link. Following to: ${resumeUrl}`); + + // "Click" the link by making another request + const finalPageResponse = await axiosInstance.get(resumeUrl, { maxRedirects: 10 }); + const $$ = cheerio.load(finalPageResponse.data); + + // Look for direct download links + let finalDownloadLink = $$('a.btn-success[href*="workers.dev"], a[href*="driveleech.net/d/"]').attr('href'); + + if (finalDownloadLink) { + // Fix spaces in workers.dev URLs by encoding them properly + if (finalDownloadLink.includes('workers.dev')) { + // Split the URL at the last slash to separate the base URL from the filename + const urlParts = finalDownloadLink.split('/'); + const filename = urlParts[urlParts.length - 1]; + // Encode spaces in the filename part only + const encodedFilename = filename.replace(/ /g, '%20'); + urlParts[urlParts.length - 1] = encodedFilename; + finalDownloadLink = urlParts.join('/'); + } + console.log(`[UHDMovies] Extracted final Resume Cloud link: ${finalDownloadLink}`); + return finalDownloadLink; + } else { + console.log('[UHDMovies] Could not find the final download link on the "Resume Cloud" page.'); + return null; + } + } catch (error) { + console.log(`[UHDMovies] Error processing "Resume Cloud": ${error.message}`); + return null; + } +} + +// Environment variable to control URL validation +const URL_VALIDATION_ENABLED = process.env.DISABLE_URL_VALIDATION !== 'true'; +console.log(`[UHDMovies] URL validation is ${URL_VALIDATION_ENABLED ? 'enabled' : 'disabled'}.`); + +// Validate if a video URL is working (not 404 or broken) +async function validateVideoUrl(url, timeout = 10000) { + // Skip validation if disabled via environment variable + if (!URL_VALIDATION_ENABLED) { + console.log(`[UHDMovies] URL validation disabled, skipping validation for: ${url.substring(0, 100)}...`); + return true; + } + + try { + console.log(`[UHDMovies] Validating URL: ${url.substring(0, 100)}...`); + const response = await axiosInstance.head(url, { + timeout, + headers: { + 'Range': 'bytes=0-1' // Just request first byte to test + } + }); + + // Check if status is OK (200-299) or partial content (206) + if (response.status >= 200 && response.status < 400) { + console.log(`[UHDMovies] ✓ URL validation successful (${response.status})`); + return true; + } else { + console.log(`[UHDMovies] ✗ URL validation failed with status: ${response.status}`); + return false; + } + } catch (error) { + console.log(`[UHDMovies] ✗ URL validation failed: ${error.message}`); + return false; + } +} + +// Function to follow redirect links and get the final download URL with size info +async function getFinalLink(redirectUrl) { + try { + console.log(`[UHDMovies] Following redirect: ${redirectUrl}`); + + // Request the driveleech page + let response = await axiosInstance.get(redirectUrl, { maxRedirects: 10 }); + let $ = cheerio.load(response.data); + + // --- Check for JavaScript redirect --- + const scriptContent = $('script').html(); + const redirectMatch = scriptContent && scriptContent.match(/window\.location\.replace\("([^"]+)"\)/); + + if (redirectMatch && redirectMatch[1]) { + const newPath = redirectMatch[1]; + const newUrl = new URL(newPath, 'https://driveleech.net/').href; + console.log(`[UHDMovies] Found JavaScript redirect. Following to: ${newUrl}`); + response = await axiosInstance.get(newUrl, { maxRedirects: 10 }); + $ = cheerio.load(response.data); + } + + // Extract size and filename information from the page + let sizeInfo = 'Unknown'; + let fileName = null; + + const sizeElement = $('li.list-group-item:contains("Size :")').text(); + if (sizeElement) { + const sizeMatch = sizeElement.match(/Size\s*:\s*([0-9.,]+\s*[KMGT]B)/i); + if (sizeMatch) sizeInfo = sizeMatch[1]; + } + + const nameElement = $('li.list-group-item:contains("Name :")').text(); + if (nameElement) { + fileName = nameElement.replace('Name :', '').trim(); + } + + // Try each download method in order until we find a working one + const downloadMethods = [ + { name: 'Resume Cloud', func: tryResumeCloud }, + { name: 'Instant Download', func: tryInstantDownload } + ]; + + for (const method of downloadMethods) { + try { + console.log(`[UHDMovies] Trying ${method.name}...`); + const finalUrl = await method.func($); + + if (finalUrl) { + // Validate the URL before using it + const isValid = await validateVideoUrl(finalUrl); + if (isValid) { + console.log(`[UHDMovies] ✓ Successfully resolved using ${method.name}`); + return { url: finalUrl, size: sizeInfo, fileName: fileName }; + } else { + console.log(`[UHDMovies] ✗ ${method.name} returned invalid/broken URL, trying next method...`); + } + } else { + console.log(`[UHDMovies] ✗ ${method.name} failed to resolve URL, trying next method...`); + } + } catch (error) { + console.log(`[UHDMovies] ✗ ${method.name} threw error: ${error.message}, trying next method...`); + } + } + + console.log('[UHDMovies] ✗ All download methods failed.'); + return null; + + } catch (error) { + console.error(`[UHDMovies] Error in getFinalLink: ${error.message}`); + return null; + } +} + +// Compare media to find matching result +function compareMedia(mediaInfo, searchResult) { + const normalizeString = (str) => String(str || '').toLowerCase().replace(/[^a-zA-Z0-9]/g, ''); + + const titleWithAnd = mediaInfo.title.replace(/\s*&\s*/g, ' and '); + const normalizedMediaTitle = normalizeString(titleWithAnd); + const normalizedResultTitle = normalizeString(searchResult.title); + + console.log(`[UHDMovies] Comparing: "${mediaInfo.title}" (${mediaInfo.year}) vs "${searchResult.title}"`); + console.log(`[UHDMovies] Normalized: "${normalizedMediaTitle}" vs "${normalizedResultTitle}"`); + + // Check if titles match or result title contains media title + let titleMatches = normalizedResultTitle.includes(normalizedMediaTitle); + + // If direct match fails, try checking for franchise/collection matches + if (!titleMatches) { + const mainTitle = normalizedMediaTitle.split('and')[0]; + const isCollection = normalizedResultTitle.includes('duology') || + normalizedResultTitle.includes('trilogy') || + normalizedResultTitle.includes('quadrilogy') || + normalizedResultTitle.includes('collection') || + normalizedResultTitle.includes('saga'); + + if (isCollection && normalizedResultTitle.includes(mainTitle)) { + console.log(`[UHDMovies] Found collection match: "${mainTitle}" in collection "${searchResult.title}"`); + titleMatches = true; + } + } + + if (!titleMatches) { + console.log(`[UHDMovies] Title mismatch: "${normalizedResultTitle}" does not contain "${normalizedMediaTitle}"`); + return false; + } + + // NEW: Negative keyword check for spinoffs + const negativeKeywords = ['challenge', 'conversation', 'story', 'in conversation']; + const originalTitleLower = mediaInfo.title.toLowerCase(); + for (const keyword of negativeKeywords) { + if (normalizedResultTitle.includes(keyword.replace(/\s/g, '')) && !originalTitleLower.includes(keyword)) { + console.log(`[UHDMovies] Rejecting spinoff due to keyword: "${keyword}"`); + return false; // It's a spinoff, reject it. + } + } + + // Check year if both are available + if (mediaInfo.year && searchResult.title) { + const yearRegex = /\b(19[89]\d|20\d{2})\b/g; // Look for years 1980-2099 + const yearMatchesInResult = searchResult.title.match(yearRegex); + const yearRangeMatch = searchResult.title.match(/\((\d{4})\s*-\s*(\d{4})\)/); + + let hasMatchingYear = false; + + if (yearMatchesInResult) { + console.log(`[UHDMovies] Found years in result: ${yearMatchesInResult.join(', ')}`); + if (yearMatchesInResult.some(yearStr => Math.abs(parseInt(yearStr) - mediaInfo.year) <= 1)) { + hasMatchingYear = true; + } + } + + if (!hasMatchingYear && yearRangeMatch) { + console.log(`[UHDMovies] Found year range in result: ${yearRangeMatch[0]}`); + const startYear = parseInt(yearRangeMatch[1]); + const endYear = parseInt(yearRangeMatch[2]); + if (mediaInfo.year >= startYear - 1 && mediaInfo.year <= endYear + 1) { + hasMatchingYear = true; + } + } + + // If there are any years found in the title, one of them MUST match. + if ((yearMatchesInResult || yearRangeMatch) && !hasMatchingYear) { + console.log(`[UHDMovies] Year mismatch. Target: ${mediaInfo.year}, but no matching year found in result.`); + return false; + } + } + + console.log(`[UHDMovies] Match successful!`); + return true; +} + +// Function to score search results based on quality keywords +function scoreResult(title) { + let score = 0; + const lowerTitle = title.toLowerCase(); + + if (lowerTitle.includes('remux')) score += 10; + if (lowerTitle.includes('bluray') || lowerTitle.includes('blu-ray')) score += 8; + if (lowerTitle.includes('imax')) score += 6; + if (lowerTitle.includes('4k') || lowerTitle.includes('2160p')) score += 5; + if (lowerTitle.includes('dovi') || lowerTitle.includes('dolby vision') || /\\bdv\\b/.test(lowerTitle)) score += 4; + if (lowerTitle.includes('hdr')) score += 3; + if (lowerTitle.includes('1080p')) score += 2; + if (lowerTitle.includes('hevc') || lowerTitle.includes('x265')) score += 1; + + return score; +} + +// Function to parse size string into MB +function parseSize(sizeString) { + if (!sizeString || typeof sizeString !== 'string') { + return 0; + } + + const upperCaseSizeString = sizeString.toUpperCase(); + + // Regex to find a number (integer or float) followed by GB, MB, or KB + const match = upperCaseSizeString.match(/([0-9.,]+)\s*(GB|MB|KB)/); + + if (!match) { + return 0; + } + + const sizeValue = parseFloat(match[1].replace(/,/g, '')); + if (isNaN(sizeValue)) { + return 0; + } + + const unit = match[2]; + + if (unit === 'GB') { + return sizeValue * 1024; + } else if (unit === 'MB') { + return sizeValue; + } else if (unit === 'KB') { + return sizeValue / 1024; + } + + return 0; +} + +// New function to resolve the tech.unblockedgames.world links +async function resolveSidToDriveleech(sidUrl) { + console.log(`[UHDMovies] Resolving SID link: ${sidUrl}`); + const { origin } = new URL(sidUrl); + const jar = new CookieJar(); + + // Get the wrapper function from dynamic import + const { wrapper } = await getAxiosCookieJarSupport(); + const session = wrapper(axios.create({ + jar, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7', + 'Accept-Language': 'en-US,en;q=0.5', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1' + } + })); + + try { + // Step 0: Get the _wp_http value + console.log(" [SID] Step 0: Fetching initial page..."); + const responseStep0 = await session.get(sidUrl); + let $ = cheerio.load(responseStep0.data); + const initialForm = $('#landing'); + const wp_http_step1 = initialForm.find('input[name="_wp_http"]').val(); + const action_url_step1 = initialForm.attr('action'); + + if (!wp_http_step1 || !action_url_step1) { + console.error(" [SID] Error: Could not find _wp_http in initial form."); + return null; + } + + // Step 1: POST to the first form's action URL + console.log(" [SID] Step 1: Submitting initial form..."); + const step1Data = new URLSearchParams({ '_wp_http': wp_http_step1 }); + const responseStep1 = await session.post(action_url_step1, step1Data, { + headers: { 'Referer': sidUrl, 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + // Step 2: Parse verification page for second form + console.log(" [SID] Step 2: Parsing verification page..."); + $ = cheerio.load(responseStep1.data); + const verificationForm = $('#landing'); + const action_url_step2 = verificationForm.attr('action'); + const wp_http2 = verificationForm.find('input[name="_wp_http2"]').val(); + const token = verificationForm.find('input[name="token"]').val(); + + if (!action_url_step2) { + console.error(" [SID] Error: Could not find verification form."); + return null; + } + + // Step 3: POST to the verification URL + console.log(" [SID] Step 3: Submitting verification..."); + const step2Data = new URLSearchParams({ '_wp_http2': wp_http2, 'token': token }); + const responseStep2 = await session.post(action_url_step2, step2Data, { + headers: { 'Referer': responseStep1.request.res.responseUrl, 'Content-Type': 'application/x-www-form-urlencoded' } + }); + + // Step 4: Find dynamic cookie and link from JavaScript + console.log(" [SID] Step 4: Parsing final page for JS data..."); + let finalLinkPath = null; + let cookieName = null; + let cookieValue = null; + + const scriptContent = responseStep2.data; + const cookieMatch = scriptContent.match(/s_343\('([^']+)',\s*'([^']+)'/); + const linkMatch = scriptContent.match(/c\.setAttribute\("href",\s*"([^"]+)"\)/); + + if (cookieMatch) { + cookieName = cookieMatch[1].trim(); + cookieValue = cookieMatch[2].trim(); + } + if (linkMatch) { + finalLinkPath = linkMatch[1].trim(); + } + + if (!finalLinkPath || !cookieName || !cookieValue) { + console.error(" [SID] Error: Could not extract dynamic cookie/link from JS."); + return null; + } + + const finalUrl = new URL(finalLinkPath, origin).href; + console.log(` [SID] Dynamic link found: ${finalUrl}`); + console.log(` [SID] Dynamic cookie found: ${cookieName}`); + + // Step 5: Set cookie and make final request + console.log(" [SID] Step 5: Setting cookie and making final request..."); + await jar.setCookie(`${cookieName}=${cookieValue}`, origin); + + const finalResponse = await session.get(finalUrl, { + headers: { 'Referer': responseStep2.request.res.responseUrl } + }); + + // Step 6: Extract driveleech URL from meta refresh tag + $ = cheerio.load(finalResponse.data); + const metaRefresh = $('meta[http-equiv="refresh"]'); + if (metaRefresh.length > 0) { + const content = metaRefresh.attr('content'); + const urlMatch = content.match(/url=(.*)/i); + if (urlMatch && urlMatch[1]) { + const driveleechUrl = urlMatch[1].replace(/"/g, "").replace(/'/g, ""); + console.log(` [SID] SUCCESS! Resolved Driveleech URL: ${driveleechUrl}`); + return driveleechUrl; + } + } + + console.error(" [SID] Error: Could not find meta refresh tag with Driveleech URL."); + return null; + + } catch (error) { + console.error(` [SID] Error during SID resolution: ${error.message}`); + if (error.response) { + console.error(` [SID] Status: ${error.response.status}`); + } + return null; + } +} + +// Main function to get streams for TMDB content +async function getUHDMoviesStreams(tmdbId, mediaType = 'movie', season = null, episode = null) { + console.log(`[UHDMovies] Attempting to fetch streams for TMDB ID: ${tmdbId}, Type: ${mediaType}${mediaType === 'tv' ? `, S:${season}E:${episode}` : ''}`); + + const cacheKey = `uhd_final_v12_${tmdbId}_${mediaType}${season ? `_s${season}e${episode}` : ''}`; + + try { + // 1. Check cache first + let cachedLinks = await getFromCache(cacheKey); + if (cachedLinks && cachedLinks.length > 0) { + console.log(`[UHDMovies] Cache HIT for ${cacheKey}. Using ${cachedLinks.length} cached Driveleech links.`); + } else { + if (cachedLinks && cachedLinks.length === 0) { + console.log(`[UHDMovies] Cache contains empty data for ${cacheKey}. Refetching from source.`); + } else { + console.log(`[UHDMovies] Cache MISS for ${cacheKey}. Fetching from source.`); + } + console.log(`[UHDMovies] Cache MISS for ${cacheKey}. Fetching from source.`); + // 2. If cache miss, get TMDB info to perform search + const tmdbUrl = `https://api.themoviedb.org/3/${mediaType === 'tv' ? 'tv' : 'movie'}/${tmdbId}?api_key=${TMDB_API_KEY_UHDMOVIES}`; + const tmdbResponse = await axios.get(tmdbUrl); + const tmdbData = tmdbResponse.data; + const mediaInfo = { + title: mediaType === 'tv' ? tmdbData.name : tmdbData.title, + year: parseInt(((mediaType === 'tv' ? tmdbData.first_air_date : tmdbData.release_date) || '').split('-')[0], 10) + }; + + if (!mediaInfo.title) throw new Error('Could not extract title from TMDB response.'); + console.log(`[UHDMovies] TMDB Info: "${mediaInfo.title}" (${mediaInfo.year || 'N/A'})`); + + // 3. Search for the media on UHDMovies + let searchTitle = mediaInfo.title.replace(/:/g, '').replace(/\s*&\s*/g, ' and '); + let searchResults = await searchMovies(searchTitle); + + // If no results or only wrong year results, try fallback search with just main title + if (searchResults.length === 0 || !searchResults.some(result => compareMedia(mediaInfo, result))) { + console.log(`[UHDMovies] Primary search failed or no matches. Trying fallback search...`); + + // Extract main title (remove subtitles after colon, "and the", etc.) + let fallbackTitle = mediaInfo.title.split(':')[0].trim(); + if (fallbackTitle.includes('and the')) { + fallbackTitle = fallbackTitle.split('and the')[0].trim(); + } + if (fallbackTitle !== searchTitle) { + console.log(`[UHDMovies] Fallback search with: "${fallbackTitle}"`); + const fallbackResults = await searchMovies(fallbackTitle); + if (fallbackResults.length > 0) { + searchResults = fallbackResults; + } + } + } + + if (searchResults.length === 0) { + console.log(`[UHDMovies] No search results found for "${mediaInfo.title}".`); + // Don't cache empty results to allow retrying later + return []; + } + + // 4. Find the best matching result + const matchingResults = searchResults.filter(result => compareMedia(mediaInfo, result)); + + if (matchingResults.length === 0) { + console.log(`[UHDMovies] No matching content found for "${mediaInfo.title}" (${mediaInfo.year}).`); + // Don't cache empty results to allow retrying later + return []; + } + + let matchingResult; + + if (matchingResults.length === 1) { + matchingResult = matchingResults[0]; + } else { + console.log(`[UHDMovies] Found ${matchingResults.length} matching results. Scoring to find the best...`); + + const scoredResults = matchingResults.map(result => { + const score = scoreResult(result.title); + console.log(` - Score ${score}: ${result.title}`); + return { ...result, score }; + }).sort((a, b) => b.score - a.score); + + matchingResult = scoredResults[0]; + console.log(`[UHDMovies] Best match selected with score ${matchingResult.score}: "${matchingResult.title}"`); + } + + console.log(`[UHDMovies] Found matching content: "${matchingResult.title}"`); + + // 5. Extract SID links from the movie/show page + const downloadInfo = await (mediaType === 'tv' ? extractTvShowDownloadLinks(matchingResult.link, season, episode) : extractDownloadLinks(matchingResult.link, mediaInfo.year)); + if (downloadInfo.links.length === 0) { + console.log('[UHDMovies] No download links found on page.'); + // Don't cache empty results to allow retrying later + return []; + } + + // 6. Resolve all SID links to driveleech redirect URLs (intermediate step) + console.log(`[UHDMovies] Resolving ${downloadInfo.links.length} SID link(s) to driveleech redirect URLs...`); + const resolutionPromises = downloadInfo.links.map(async (linkInfo) => { + try { + let driveleechUrl = null; + + if (linkInfo.link && (linkInfo.link.includes('tech.unblockedgames.world') || linkInfo.link.includes('tech.creativeexpressionsblog.com') || linkInfo.link.includes('tech.examzculture.in'))) { + driveleechUrl = await resolveSidToDriveleech(linkInfo.link); + } else if (linkInfo.link && (linkInfo.link.includes('driveseed.org') || linkInfo.link.includes('driveleech.net'))) { + // If it's already a direct driveseed/driveleech link, use it + driveleechUrl = linkInfo.link; + } + + if (!driveleechUrl) return null; + + console.log(`[UHDMovies] Caching driveleech redirect URL for ${linkInfo.quality}: ${driveleechUrl}`); + return { ...linkInfo, driveleechRedirectUrl: driveleechUrl }; + } catch (error) { + console.error(`[UHDMovies] Error resolving ${linkInfo.quality}: ${error.message}`); + return null; + } + }); + + cachedLinks = (await Promise.all(resolutionPromises)).filter(Boolean); + + // 7. Save the successfully resolved driveleech redirect URLs to the cache + if (cachedLinks.length > 0) { + console.log(`[UHDMovies] Caching ${cachedLinks.length} resolved driveleech redirect URLs for key: ${cacheKey}`); + await saveToCache(cacheKey, cachedLinks); + } else { + console.log(`[UHDMovies] No driveleech redirect URLs could be resolved. Not caching to allow retrying later.`); + return []; + } + } + + if (!cachedLinks || cachedLinks.length === 0) { + console.log('[UHDMovies] No final file page URLs found after scraping/cache check.'); + return []; + } + + // 8. Process all cached driveleech redirect URLs to get streaming links + console.log(`[UHDMovies] Processing ${cachedLinks.length} cached driveleech redirect URL(s) to get streaming links.`); + const streamPromises = cachedLinks.map(async (linkInfo) => { + try { + // First, resolve the driveleech redirect URL to get the final file page URL + const response = await axiosInstance.get(linkInfo.driveleechRedirectUrl, { maxRedirects: 10 }); + let $ = cheerio.load(response.data); + + // Check for JavaScript redirect (window.location.replace) + const scriptContent = $('script').html(); + const redirectMatch = scriptContent && scriptContent.match(/window\.location\.replace\("([^"]+)"\)/); + + let finalFilePageUrl = linkInfo.driveleechRedirectUrl; + if (redirectMatch && redirectMatch[1]) { + finalFilePageUrl = new URL(redirectMatch[1], 'https://driveleech.net/').href; + console.log(`[UHDMovies] Resolved redirect to final file page: ${finalFilePageUrl}`); + + // Load the final file page + const finalResponse = await axiosInstance.get(finalFilePageUrl, { maxRedirects: 10 }); + $ = cheerio.load(finalResponse.data); + } + + // Extract file size and name information + let sizeInfo = 'Unknown'; + let fileName = null; + + const sizeElement = $('li.list-group-item:contains("Size :")').text(); + if (sizeElement) { + const sizeMatch = sizeElement.match(/Size\s*:\s*([0-9.,]+\s*[KMGT]B)/); + if (sizeMatch) { + sizeInfo = sizeMatch[1]; + } + } + + const nameElement = $('li.list-group-item:contains("Name :")'); + if (nameElement.length > 0) { + fileName = nameElement.text().replace('Name :', '').trim(); + } else { + const h5Title = $('div.card-header h5').clone().children().remove().end().text().trim(); + if (h5Title) { + fileName = h5Title.replace(/\[.*\]/, '').trim(); + } + } + + // Try download methods to get final streaming URL + const downloadMethods = [ + { name: 'Resume Cloud', func: tryResumeCloud }, + { name: 'Instant Download', func: tryInstantDownload } + ]; + + for (const method of downloadMethods) { + try { + const finalUrl = await method.func($); + + if (finalUrl) { + const isValid = await validateVideoUrl(finalUrl); + if (isValid) { + const rawQuality = linkInfo.rawQuality || ''; + const codecs = extractCodecs(rawQuality); + const cleanFileName = fileName ? fileName.replace(/\.[^/.]+$/, "").replace(/[._]/g, ' ') : (linkInfo.quality || 'Unknown'); + + return { + name: `UHDMovies`, + title: `${cleanFileName}\n${sizeInfo}`, + url: finalUrl, + quality: linkInfo.quality, + size: sizeInfo, + fileName: fileName, + fullTitle: rawQuality, + codecs: codecs, + behaviorHints: { bingeGroup: `uhdmovies-${linkInfo.quality}` } + }; + } + } + } catch (error) { + console.log(`[UHDMovies] ${method.name} failed: ${error.message}`); + } + } + + return null; + } catch (error) { + console.error(`[UHDMovies] Error processing cached driveleech redirect ${linkInfo.driveleechRedirectUrl}: ${error.message}`); + return null; + } + }); + + const streams = (await Promise.all(streamPromises)).filter(Boolean); + console.log(`[UHDMovies] Successfully processed ${streams.length} final stream links.`); + + // Sort final streams by size + streams.sort((a, b) => { + const sizeA = parseSize(a.size); + const sizeB = parseSize(b.size); + return sizeB - sizeA; + }); + + return streams; + } catch (error) { + console.error(`[UHDMovies] A critical error occurred in getUHDMoviesStreams for ${tmdbId}: ${error.message}`); + if (error.stack) console.error(error.stack); + return []; + } +} + +module.exports = { getUHDMoviesStreams }; \ No newline at end of file diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..8a6be07 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,16 @@ +# OSX +# +.DS_Store + +# Android/IntelliJ +# +build/ +.idea +.gradle +local.properties +*.iml +*.hprof +.cxx/ + +# Bundle artifacts +*.jsbundle diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..8904494 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,187 @@ +apply plugin: "com.android.application" +apply plugin: "org.jetbrains.kotlin.android" +apply plugin: "com.facebook.react" + +def projectRoot = rootDir.getAbsoluteFile().getParentFile().getAbsolutePath() + +/** + * This is the configuration block to customize your React Native Android app. + * By default you don't need to apply any configuration, just uncomment the lines you need. + */ +react { + entryFile = file(["node", "-e", "require('expo/scripts/resolveAppEntry')", projectRoot, "android", "absolute"].execute(null, rootDir).text.trim()) + reactNativeDir = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + hermesCommand = new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim()).getParentFile().getAbsolutePath() + "/sdks/hermesc/%OS-BIN%/hermesc" + codegenDir = new File(["node", "--print", "require.resolve('@react-native/codegen/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().getAbsoluteFile() + + // Use Expo CLI to bundle the app, this ensures the Metro config + // works correctly with Expo projects. + cliFile = new File(["node", "--print", "require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })"].execute(null, rootDir).text.trim()) + bundleCommand = "export:embed" + + /* Folders */ + // The root of your project, i.e. where "package.json" lives. Default is '../..' + // root = file("../../") + // The folder where the react-native NPM package is. Default is ../../node_modules/react-native + // reactNativeDir = file("../../node_modules/react-native") + // The folder where the react-native Codegen package is. Default is ../../node_modules/@react-native/codegen + // codegenDir = file("../../node_modules/@react-native/codegen") + + /* Variants */ + // The list of variants to that are debuggable. For those we're going to + // skip the bundling of the JS bundle and the assets. By default is just 'debug'. + // If you add flavors like lite, prod, etc. you'll have to list your debuggableVariants. + // debuggableVariants = ["liteDebug", "prodDebug"] + + /* Bundling */ + // A list containing the node command and its flags. Default is just 'node'. + // nodeExecutableAndArgs = ["node"] + + // + // The path to the CLI configuration file. Default is empty. + // bundleConfig = file(../rn-cli.config.js) + // + // The name of the generated asset file containing your JS bundle + // bundleAssetName = "MyApplication.android.bundle" + // + // The entry file for bundle generation. Default is 'index.android.js' or 'index.js' + // entryFile = file("../js/MyApplication.android.js") + // + // A list of extra flags to pass to the 'bundle' commands. + // See https://github.com/react-native-community/cli/blob/main/docs/commands.md#bundle + // extraPackagerArgs = [] + + /* Hermes Commands */ + // The hermes compiler command to run. By default it is 'hermesc' + // hermesCommand = "$rootDir/my-custom-hermesc/bin/hermesc" + // + // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" + // hermesFlags = ["-O", "-output-source-map"] + + /* Autolinking */ + autolinkLibrariesWithApp() +} + +/** + * Set this to true to Run Proguard on Release builds to minify the Java bytecode. + */ +def enableProguardInReleaseBuilds = (findProperty('android.enableProguardInReleaseBuilds') ?: false).toBoolean() + +/** + * The preferred build flavor of JavaScriptCore (JSC) + * + * For example, to use the international variant, you can use: + * `def jscFlavor = 'org.webkit:android-jsc-intl:+'` + * + * The international variant includes ICU i18n library and necessary data + * allowing to use e.g. `Date.toLocaleString` and `String.localeCompare` that + * give correct results when using with locales other than en-US. Note that + * this variant is about 6MiB larger per architecture than default. + */ +def jscFlavor = 'org.webkit:android-jsc:+' + +apply from: new File(["node", "--print", "require('path').dirname(require.resolve('@sentry/react-native/package.json'))"].execute().text.trim(), "sentry.gradle") + +android { + ndkVersion rootProject.ext.ndkVersion + + buildToolsVersion rootProject.ext.buildToolsVersion + compileSdk rootProject.ext.compileSdkVersion + + namespace 'com.nuvio.app' + defaultConfig { + applicationId 'com.nuvio.app' + minSdkVersion rootProject.ext.minSdkVersion + targetSdkVersion rootProject.ext.targetSdkVersion + versionCode 1 + versionName "1.0.0" + } + + splits { + abi { + reset() + enable true + universalApk false // If true, also generate a universal APK + include "arm64-v8a", "armeabi-v7a", "x86", "x86_64" + } + } + signingConfigs { + debug { + storeFile file('debug.keystore') + storePassword 'android' + keyAlias 'androiddebugkey' + keyPassword 'android' + } + } + buildTypes { + debug { + signingConfig signingConfigs.debug + } + release { + // Caution! In production, you need to generate your own keystore file. + // see https://reactnative.dev/docs/signed-apk-android. + signingConfig signingConfigs.debug + shrinkResources (findProperty('android.enableShrinkResourcesInReleaseBuilds')?.toBoolean() ?: false) + minifyEnabled enableProguardInReleaseBuilds + proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" + crunchPngs (findProperty('android.enablePngCrunchInReleaseBuilds')?.toBoolean() ?: true) + } + } + packagingOptions { + jniLibs { + useLegacyPackaging (findProperty('expo.useLegacyPackaging')?.toBoolean() ?: false) + } + } + androidResources { + ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' + } +} + +// Apply static values from `gradle.properties` to the `android.packagingOptions` +// Accepts values in comma delimited lists, example: +// android.packagingOptions.pickFirsts=/LICENSE,**/picasa.ini +["pickFirsts", "excludes", "merges", "doNotStrip"].each { prop -> + // Split option: 'foo,bar' -> ['foo', 'bar'] + def options = (findProperty("android.packagingOptions.$prop") ?: "").split(","); + // Trim all elements in place. + for (i in 0.. 0) { + println "android.packagingOptions.$prop += $options ($options.length)" + // Ex: android.packagingOptions.pickFirsts += '**/SCCS/**' + options.each { + android.packagingOptions[prop] += it + } + } +} + +dependencies { + // The version of react-native is set by the React Native Gradle Plugin + implementation("com.facebook.react:react-android") + + def isGifEnabled = (findProperty('expo.gif.enabled') ?: "") == "true"; + def isWebpEnabled = (findProperty('expo.webp.enabled') ?: "") == "true"; + def isWebpAnimatedEnabled = (findProperty('expo.webp.animated') ?: "") == "true"; + + if (isGifEnabled) { + // For animated gif support + implementation("com.facebook.fresco:animated-gif:${reactAndroidLibs.versions.fresco.get()}") + } + + if (isWebpEnabled) { + // For webp support + implementation("com.facebook.fresco:webpsupport:${reactAndroidLibs.versions.fresco.get()}") + if (isWebpAnimatedEnabled) { + // Animated webp support + implementation("com.facebook.fresco:animated-webp:${reactAndroidLibs.versions.fresco.get()}") + } + } + + if (hermesEnabled.toBoolean()) { + implementation("com.facebook.react:hermes-android") + } else { + implementation jscFlavor + } +} diff --git a/android/app/debug.keystore b/android/app/debug.keystore new file mode 100644 index 0000000..364e105 Binary files /dev/null and b/android/app/debug.keystore differ diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..551eb41 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,14 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /usr/local/Cellar/android-sdk/24.3.3/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# react-native-reanimated +-keep class com.swmansion.reanimated.** { *; } +-keep class com.facebook.react.turbomodule.** { *; } + +# Add any project specific keep options here: diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..3ec2507 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b9e6574 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/com/nuvio/app/MainActivity.kt b/android/app/src/main/java/com/nuvio/app/MainActivity.kt new file mode 100644 index 0000000..bdd6bfe --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/MainActivity.kt @@ -0,0 +1,61 @@ +package com.nuvio.app + +import android.os.Build +import android.os.Bundle + +import com.facebook.react.ReactActivity +import com.facebook.react.ReactActivityDelegate +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled +import com.facebook.react.defaults.DefaultReactActivityDelegate + +import expo.modules.ReactActivityDelegateWrapper + +class MainActivity : ReactActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + // Set the theme to AppTheme BEFORE onCreate to support + // coloring the background, status bar, and navigation bar. + // This is required for expo-splash-screen. + setTheme(R.style.AppTheme); + super.onCreate(null) + } + + /** + * Returns the name of the main component registered from JavaScript. This is used to schedule + * rendering of the component. + */ + override fun getMainComponentName(): String = "main" + + /** + * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate] + * which allows you to enable New Architecture with a single boolean flags [fabricEnabled] + */ + override fun createReactActivityDelegate(): ReactActivityDelegate { + return ReactActivityDelegateWrapper( + this, + BuildConfig.IS_NEW_ARCHITECTURE_ENABLED, + object : DefaultReactActivityDelegate( + this, + mainComponentName, + fabricEnabled + ){}) + } + + /** + * Align the back button behavior with Android S + * where moving root activities to background instead of finishing activities. + * @see onBackPressed + */ + override fun invokeDefaultOnBackPressed() { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) { + if (!moveTaskToBack(false)) { + // For non-root activities, use the default implementation to finish them. + super.invokeDefaultOnBackPressed() + } + return + } + + // Use the default back button implementation on Android S + // because it's doing more than [Activity.moveTaskToBack] in fact. + super.invokeDefaultOnBackPressed() + } +} diff --git a/android/app/src/main/java/com/nuvio/app/MainApplication.kt b/android/app/src/main/java/com/nuvio/app/MainApplication.kt new file mode 100644 index 0000000..31160e6 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/MainApplication.kt @@ -0,0 +1,57 @@ +package com.nuvio.app + +import android.app.Application +import android.content.res.Configuration + +import com.facebook.react.PackageList +import com.facebook.react.ReactApplication +import com.facebook.react.ReactNativeHost +import com.facebook.react.ReactPackage +import com.facebook.react.ReactHost +import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load +import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.soloader.OpenSourceMergedSoMapping +import com.facebook.soloader.SoLoader + +import expo.modules.ApplicationLifecycleDispatcher +import expo.modules.ReactNativeHostWrapper + +class MainApplication : Application(), ReactApplication { + + override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( + this, + object : DefaultReactNativeHost(this) { + override fun getPackages(): List { + val packages = PackageList(this).packages + // Packages that cannot be autolinked yet can be added manually here, for example: + // packages.add(new MyReactNativePackage()); + return packages + } + + override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" + + override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG + + override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED + override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED + } + ) + + override val reactHost: ReactHost + get() = ReactNativeHostWrapper.createReactHost(applicationContext, reactNativeHost) + + override fun onCreate() { + super.onCreate() + SoLoader.init(this, OpenSourceMergedSoMapping) + if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { + // If you opted-in for the New Architecture, we load the native entry point for this app. + load() + } + ApplicationLifecycleDispatcher.onApplicationCreate(this) + } + + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig) + } +} diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png new file mode 100644 index 0000000..29ed05b Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png new file mode 100644 index 0000000..1e3a378 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png new file mode 100644 index 0000000..6b7ddf9 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..b977b3f Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png new file mode 100644 index 0000000..a79fe8b Binary files /dev/null and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable/ic_launcher_background.xml b/android/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..883b2a0 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/rn_edit_text_material.xml b/android/app/src/main/res/drawable/rn_edit_text_material.xml new file mode 100644 index 0000000..5c25e72 --- /dev/null +++ b/android/app/src/main/res/drawable/rn_edit_text_material.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..3941bea --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000..4f55492 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..acff384 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp new file mode 100644 index 0000000..68b2b6b Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp new file mode 100644 index 0000000..7ce788c Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..695874a Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000..de65e82 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp new file mode 100644 index 0000000..f48cd8b Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..b9693cb Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..d48403b Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000..eebf7ad Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..5f71916 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..4f6de84 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp new file mode 100644 index 0000000..bee06ef Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 0000000..4208368 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000..34afa47 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/values-night/colors.xml b/android/app/src/main/res/values-night/colors.xml new file mode 100644 index 0000000..3c05de5 --- /dev/null +++ b/android/app/src/main/res/values-night/colors.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..d26d3c6 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + #020404 + #020404 + #023c69 + #020404 + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..a5cdeec --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + Nuvio + contain + false + dark + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..92774dd --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,17 @@ + + + + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..abbcb8e --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,41 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext { + buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0' + minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24') + compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35') + targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34') + kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25' + + ndkVersion = "26.1.10909125" + } + repositories { + google() + mavenCentral() + } + dependencies { + classpath('com.android.tools.build:gradle') + classpath('com.facebook.react:react-native-gradle-plugin') + classpath('org.jetbrains.kotlin:kotlin-gradle-plugin') + } +} + +apply plugin: "com.facebook.react.rootproject" + +allprojects { + repositories { + maven { + // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm + url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android')) + } + maven { + // Android JSC is installed from npm + url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist')) + } + + google() + mavenCentral() + maven { url 'https://www.jitpack.io' } + } +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..7531e9e --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,56 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx512m -XX:MaxMetaspaceSize=256m +org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +# Enable AAPT2 PNG crunching +android.enablePngCrunchInReleaseBuilds=true + +# Use this property to specify which architecture you want to build. +# You can also override it from the CLI using +# ./gradlew -PreactNativeArchitectures=x86_64 +reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 + +# Use this property to enable support to the new architecture. +# This will allow you to use TurboModules and the Fabric render in +# your application. You should enable this flag either if you want +# to write custom TurboModules/Fabric components OR use libraries that +# are providing them. +newArchEnabled=true + +# Use this property to enable or disable the Hermes JS engine. +# If set to false, you will be using JSC instead. +hermesEnabled=true + +# Enable GIF support in React Native images (~200 B increase) +expo.gif.enabled=true +# Enable webp support in React Native images (~85 KB increase) +expo.webp.enabled=true +# Enable animated webp support (~3.4 MB increase) +# Disabled by default because iOS doesn't support animated webp +expo.webp.animated=false + +# Enable network inspector +EX_DEV_CLIENT_NETWORK_INSPECTOR=true + +# Use legacy packaging to compress native libraries in the resulting APK. +expo.useLegacyPackaging=false diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..a4b76b9 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..79eb9d0 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100644 index 0000000..f5feea6 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,252 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..9b42019 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/sentry.properties b/android/sentry.properties new file mode 100644 index 0000000..5581e03 --- /dev/null +++ b/android/sentry.properties @@ -0,0 +1,4 @@ +defaults.url=https://sentry.io/ +defaults.org=tapframe +defaults.project=react-native +# Using SENTRY_AUTH_TOKEN environment variable \ No newline at end of file diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..a39f8ed --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,38 @@ +pluginManagement { + includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile().toString()) +} +plugins { id("com.facebook.react.settings") } + +extensions.configure(com.facebook.react.ReactSettingsExtension) { ex -> + if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') { + ex.autolinkLibrariesFromCommand() + } else { + def command = [ + 'node', + '--no-warnings', + '--eval', + 'require(require.resolve(\'expo-modules-autolinking\', { paths: [require.resolve(\'expo/package.json\')] }))(process.argv.slice(1))', + 'react-native-config', + '--json', + '--platform', + 'android' + ].toList() + ex.autolinkLibrariesFromCommand(command) + } +} + +rootProject.name = 'Nuvio' + +dependencyResolutionManagement { + versionCatalogs { + reactAndroidLibs { + from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml"))) + } + } +} + +apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle"); +useExpoModules() + +include ':app' +includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile()) diff --git a/app.json b/app.json index 5cb6c50..fb225ab 100644 --- a/app.json +++ b/app.json @@ -4,7 +4,7 @@ "slug": "nuvio", "version": "1.0.0", "orientation": "default", - "icon": "./assets/icon.png", + "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", "userInterfaceStyle": "dark", "scheme": "stremioexpo", "newArchEnabled": true, @@ -15,6 +15,7 @@ }, "ios": { "supportsTablet": true, + "icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png", "infoPlist": { "NSAppTransportSecurity": { "NSAllowsArbitraryLoads": true @@ -24,38 +25,28 @@ ], "NSLocalNetworkUsageDescription": "App uses the local network to discover and connect to devices.", "NSMicrophoneUsageDescription": "This app does not require microphone access.", - "UIBackgroundModes": ["audio"], + "UIBackgroundModes": [ + "audio" + ], "LSSupportsOpeningDocumentsInPlace": true, "UIFileSharingEnabled": true }, "bundleIdentifier": "com.nuvio.app", - "associatedDomains": [], - "documentTypes": [ - { - "name": "Matroska Video", - "role": "viewer", - "utis": ["org.matroska.mkv"], - "extensions": ["mkv"] - } - ] + "associatedDomains": [] }, "android": { "adaptiveIcon": { - "foregroundImage": "./assets/icon.png", - "backgroundColor": "#020404", - "monochromeImage": "./assets/icon.png" + "foregroundImage": "./assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png", + "backgroundColor": "#020404" }, + "icon": "./assets/android/mipmap-xxxhdpi/ic_launcher.png", "permissions": [ "INTERNET", "WAKE_LOCK" ], "package": "com.nuvio.app", - "enableSplitAPKs": true, "versionCode": 1, - "enableProguardInReleaseBuilds": true, - "enableHermes": true, - "enableSeparateBuildPerCPUArchitecture": true, - "enableVectorDrawables": true + "architectures": ["arm64-v8a", "armeabi-v7a", "x86", "x86_64"] }, "web": { "favicon": "./assets/favicon.png" @@ -65,6 +56,16 @@ "projectId": "909107b8-fe61-45ce-b02f-b02510d306a6" } }, - "owner": "nayifleo" + "owner": "nayifleo", + "plugins": [ + [ + "@sentry/react-native/expo", + { + "url": "https://sentry.io/", + "project": "react-native", + "organization": "tapframe" + } + ] + ] } } diff --git a/assets/WhatsApp Image 2025-04-17 at 03.45.56_f37ab22f.jpg b/assets/WhatsApp Image 2025-04-17 at 03.45.56_f37ab22f.jpg deleted file mode 100644 index 4ffb7f5..0000000 Binary files a/assets/WhatsApp Image 2025-04-17 at 03.45.56_f37ab22f.jpg and /dev/null differ diff --git a/assets/android/ic_launcher-web.png b/assets/android/ic_launcher-web.png new file mode 100644 index 0000000..2efccc3 Binary files /dev/null and b/assets/android/ic_launcher-web.png differ diff --git a/assets/android/mipmap-anydpi-v26/ic_launcher.xml b/assets/android/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/assets/android/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/android/mipmap-anydpi-v26/ic_launcher_round.xml b/assets/android/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..036d09b --- /dev/null +++ b/assets/android/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/assets/android/mipmap-hdpi/ic_launcher.png b/assets/android/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..af5bd9d Binary files /dev/null and b/assets/android/mipmap-hdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-hdpi/ic_launcher_foreground.png b/assets/android/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..4aaa708 Binary files /dev/null and b/assets/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-hdpi/ic_launcher_round.png b/assets/android/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..ce446f7 Binary files /dev/null and b/assets/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-ldpi/ic_launcher.png b/assets/android/mipmap-ldpi/ic_launcher.png new file mode 100644 index 0000000..426e2ae Binary files /dev/null and b/assets/android/mipmap-ldpi/ic_launcher.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher.png b/assets/android/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c450f86 Binary files /dev/null and b/assets/android/mipmap-mdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher_foreground.png b/assets/android/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..7ec84d6 Binary files /dev/null and b/assets/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher_round.png b/assets/android/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..1185a4d Binary files /dev/null and b/assets/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher.png b/assets/android/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..81d12cd Binary files /dev/null and b/assets/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..dbdd835 Binary files /dev/null and b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher_round.png b/assets/android/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..17ea551 Binary files /dev/null and b/assets/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher.png b/assets/android/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..dd251f4 Binary files /dev/null and b/assets/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1a315db Binary files /dev/null and b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..c16b8d9 Binary files /dev/null and b/assets/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher.png b/assets/android/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..b017bf5 Binary files /dev/null and b/assets/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..1c27650 Binary files /dev/null and b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..055fe00 Binary files /dev/null and b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/assets/android/playstore-icon.png b/assets/android/playstore-icon.png new file mode 100644 index 0000000..8e950cf Binary files /dev/null and b/assets/android/playstore-icon.png differ diff --git a/assets/android/values/ic_launcher_background.xml b/assets/android/values/ic_launcher_background.xml new file mode 100644 index 0000000..dcdf032 --- /dev/null +++ b/assets/android/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #151515 + \ No newline at end of file diff --git a/assets/audio/profile-selected.mp3 b/assets/audio/profile-selected.mp3 deleted file mode 100644 index 94bc727..0000000 Binary files a/assets/audio/profile-selected.mp3 and /dev/null differ diff --git a/assets/gifs/demo.gif b/assets/gifs/demo.gif deleted file mode 100644 index 395f879..0000000 Binary files a/assets/gifs/demo.gif and /dev/null differ diff --git a/assets/images/adaptive-icon.png b/assets/images/adaptive-icon.png deleted file mode 100644 index da1618e..0000000 Binary files a/assets/images/adaptive-icon.png and /dev/null differ diff --git a/assets/images/app-icon.png b/assets/images/app-icon.png deleted file mode 100644 index dac4426..0000000 Binary files a/assets/images/app-icon.png and /dev/null differ diff --git a/assets/images/favicon.png b/assets/images/favicon.png deleted file mode 100644 index e75f697..0000000 Binary files a/assets/images/favicon.png and /dev/null differ diff --git a/assets/images/icon.png b/assets/images/icon.png deleted file mode 100644 index dac4426..0000000 Binary files a/assets/images/icon.png and /dev/null differ diff --git a/assets/images/partial-react-logo.png b/assets/images/partial-react-logo.png deleted file mode 100644 index 66fd957..0000000 Binary files a/assets/images/partial-react-logo.png and /dev/null differ diff --git a/assets/images/react-logo.png b/assets/images/react-logo.png deleted file mode 100644 index 917bb5b..0000000 Binary files a/assets/images/react-logo.png and /dev/null differ diff --git a/assets/images/react-logo@2x.png b/assets/images/react-logo@2x.png deleted file mode 100644 index f7b9229..0000000 Binary files a/assets/images/react-logo@2x.png and /dev/null differ diff --git a/assets/images/react-logo@3x.png b/assets/images/react-logo@3x.png deleted file mode 100644 index 5f60bdc..0000000 Binary files a/assets/images/react-logo@3x.png and /dev/null differ diff --git a/assets/images/replace-these/coming-soon.png b/assets/images/replace-these/coming-soon.png deleted file mode 100644 index 8837ee4..0000000 Binary files a/assets/images/replace-these/coming-soon.png and /dev/null differ diff --git a/assets/images/replace-these/download-netflix-icon.png b/assets/images/replace-these/download-netflix-icon.png deleted file mode 100644 index de406d0..0000000 Binary files a/assets/images/replace-these/download-netflix-icon.png and /dev/null differ diff --git a/assets/images/replace-these/download-netflix-transparent.png b/assets/images/replace-these/download-netflix-transparent.png deleted file mode 100644 index 2d96168..0000000 Binary files a/assets/images/replace-these/download-netflix-transparent.png and /dev/null differ diff --git a/assets/images/replace-these/everyone-watching.webp b/assets/images/replace-these/everyone-watching.webp deleted file mode 100644 index 0e495e6..0000000 Binary files a/assets/images/replace-these/everyone-watching.webp and /dev/null differ diff --git a/assets/images/replace-these/new-netflix-outline.png b/assets/images/replace-these/new-netflix-outline.png deleted file mode 100644 index 62b0e3d..0000000 Binary files a/assets/images/replace-these/new-netflix-outline.png and /dev/null differ diff --git a/assets/images/replace-these/new-netflix.png b/assets/images/replace-these/new-netflix.png deleted file mode 100644 index d14c7cb..0000000 Binary files a/assets/images/replace-these/new-netflix.png and /dev/null differ diff --git a/assets/images/replace-these/top10.png b/assets/images/replace-these/top10.png deleted file mode 100644 index c723d75..0000000 Binary files a/assets/images/replace-these/top10.png and /dev/null differ diff --git a/assets/images/splash.png b/assets/images/splash.png deleted file mode 100644 index 32d01fe..0000000 Binary files a/assets/images/splash.png and /dev/null differ diff --git a/assets/ios/AppIcon.appiconset/Contents.json b/assets/ios/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..e83c3bf --- /dev/null +++ b/assets/ios/AppIcon.appiconset/Contents.json @@ -0,0 +1,128 @@ +{ + "images":[ + { + "idiom":"iphone", + "size":"20x20", + "scale":"2x", + "filename":"Icon-App-20x20@2x.png" + }, + { + "idiom":"iphone", + "size":"20x20", + "scale":"3x", + "filename":"Icon-App-20x20@3x.png" + }, + { + "idiom":"iphone", + "size":"29x29", + "scale":"1x", + "filename":"Icon-App-29x29@1x.png" + }, + { + "idiom":"iphone", + "size":"29x29", + "scale":"2x", + "filename":"Icon-App-29x29@2x.png" + }, + { + "idiom":"iphone", + "size":"29x29", + "scale":"3x", + "filename":"Icon-App-29x29@3x.png" + }, + { + "idiom":"iphone", + "size":"40x40", + "scale":"2x", + "filename":"Icon-App-40x40@2x.png" + }, + { + "idiom":"iphone", + "size":"40x40", + "scale":"3x", + "filename":"Icon-App-40x40@3x.png" + }, + { + "idiom":"iphone", + "size":"60x60", + "scale":"2x", + "filename":"Icon-App-60x60@2x.png" + }, + { + "idiom":"iphone", + "size":"60x60", + "scale":"3x", + "filename":"Icon-App-60x60@3x.png" + }, + { + "idiom":"iphone", + "size":"76x76", + "scale":"2x", + "filename":"Icon-App-76x76@2x.png" + }, + { + "idiom":"ipad", + "size":"20x20", + "scale":"1x", + "filename":"Icon-App-20x20@1x.png" + }, + { + "idiom":"ipad", + "size":"20x20", + "scale":"2x", + "filename":"Icon-App-20x20@2x.png" + }, + { + "idiom":"ipad", + "size":"29x29", + "scale":"1x", + "filename":"Icon-App-29x29@1x.png" + }, + { + "idiom":"ipad", + "size":"29x29", + "scale":"2x", + "filename":"Icon-App-29x29@2x.png" + }, + { + "idiom":"ipad", + "size":"40x40", + "scale":"1x", + "filename":"Icon-App-40x40@1x.png" + }, + { + "idiom":"ipad", + "size":"40x40", + "scale":"2x", + "filename":"Icon-App-40x40@2x.png" + }, + { + "idiom":"ipad", + "size":"76x76", + "scale":"1x", + "filename":"Icon-App-76x76@1x.png" + }, + { + "idiom":"ipad", + "size":"76x76", + "scale":"2x", + "filename":"Icon-App-76x76@2x.png" + }, + { + "idiom":"ipad", + "size":"83.5x83.5", + "scale":"2x", + "filename":"Icon-App-83.5x83.5@2x.png" + }, + { + "size" : "1024x1024", + "idiom" : "ios-marketing", + "scale" : "1x", + "filename" : "ItunesArtwork@2x.png" + } + ], + "info":{ + "version":1, + "author":"easyappicon" + } +} diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png new file mode 100644 index 0000000..ffc8aaa Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png new file mode 100644 index 0000000..4a52eaa Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png new file mode 100644 index 0000000..d5eea9b Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png new file mode 100644 index 0000000..379634b Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png new file mode 100644 index 0000000..4ff0ef2 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png new file mode 100644 index 0000000..edcf4d5 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png new file mode 100644 index 0000000..4a52eaa Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png new file mode 100644 index 0000000..e4afe63 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png new file mode 100644 index 0000000..152a8e6 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png new file mode 100644 index 0000000..152a8e6 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png new file mode 100644 index 0000000..bbc2ad2 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png new file mode 100644 index 0000000..a67bc43 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png new file mode 100644 index 0000000..3a87610 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png new file mode 100644 index 0000000..0f242d6 Binary files /dev/null and b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png new file mode 100644 index 0000000..e2f386e Binary files /dev/null and b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png differ diff --git a/assets/ios/iTunesArtwork@1x.png b/assets/ios/iTunesArtwork@1x.png new file mode 100644 index 0000000..8e950cf Binary files /dev/null and b/assets/ios/iTunesArtwork@1x.png differ diff --git a/assets/ios/iTunesArtwork@2x.png b/assets/ios/iTunesArtwork@2x.png new file mode 100644 index 0000000..e2f386e Binary files /dev/null and b/assets/ios/iTunesArtwork@2x.png differ diff --git a/assets/ios/iTunesArtwork@3x.png b/assets/ios/iTunesArtwork@3x.png new file mode 100644 index 0000000..5c2b758 Binary files /dev/null and b/assets/ios/iTunesArtwork@3x.png differ diff --git a/assets/splash-icon.png b/assets/splash-icon.png index 03d6f6b..5fa6129 100644 Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ diff --git a/assets/titlelogo.png b/assets/titlelogo.png index f1aebd5..a942923 100644 Binary files a/assets/titlelogo.png and b/assets/titlelogo.png differ diff --git a/components/AndroidVideoPlayer.tsx b/components/AndroidVideoPlayer.tsx index 8865071..73fe56f 100644 --- a/components/AndroidVideoPlayer.tsx +++ b/components/AndroidVideoPlayer.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useRef, useState } from 'react'; import { Platform } from 'react-native'; -import Video, { VideoRef, SelectedTrack, BufferingStrategyType } from 'react-native-video'; +import Video, { VideoRef, SelectedTrack, BufferingStrategyType, ResizeMode } from 'react-native-video'; interface VideoPlayerProps { src: string; @@ -9,6 +9,7 @@ interface VideoPlayerProps { currentTime: number; selectedAudioTrack?: SelectedTrack; selectedTextTrack?: SelectedTrack; + resizeMode?: ResizeMode; onProgress?: (data: { currentTime: number; playableDuration: number }) => void; onLoad?: (data: { duration: number }) => void; onError?: (error: any) => void; @@ -24,6 +25,7 @@ export const AndroidVideoPlayer: React.FC = ({ currentTime, selectedAudioTrack, selectedTextTrack, + resizeMode = 'contain' as ResizeMode, onProgress, onLoad, onError, @@ -93,7 +95,7 @@ export const AndroidVideoPlayer: React.FC = ({ onBuffer={handleBuffer} onError={handleError} onEnd={handleEnd} - resizeMode="contain" + resizeMode={resizeMode} controls={false} playInBackground={false} playWhenInactive={false} diff --git a/hdrezkas.js b/hdrezkas.js deleted file mode 100644 index 77749ae..0000000 --- a/hdrezkas.js +++ /dev/null @@ -1,516 +0,0 @@ -// Simplified standalone script to test hdrezka scraper flow -import fetch from 'node-fetch'; -import readline from 'readline'; - -// Constants -const rezkaBase = 'https://hdrezka.ag/'; -const baseHeaders = { - 'X-Hdrezka-Android-App': '1', - 'X-Hdrezka-Android-App-Version': '2.2.0', -}; - -// Parse command line arguments -const args = process.argv.slice(2); -const argOptions = { - title: null, - type: null, - year: null, - season: null, - episode: null -}; - -// Process command line arguments -for (let i = 0; i < args.length; i++) { - if (args[i] === '--title' || args[i] === '-t') { - argOptions.title = args[i + 1]; - i++; - } else if (args[i] === '--type' || args[i] === '-m') { - argOptions.type = args[i + 1].toLowerCase(); - i++; - } else if (args[i] === '--year' || args[i] === '-y') { - argOptions.year = parseInt(args[i + 1]); - i++; - } else if (args[i] === '--season' || args[i] === '-s') { - argOptions.season = parseInt(args[i + 1]); - i++; - } else if (args[i] === '--episode' || args[i] === '-e') { - argOptions.episode = parseInt(args[i + 1]); - i++; - } else if (args[i] === '--help' || args[i] === '-h') { - console.log(` -HDRezka Scraper Test Script - -Usage: - node hdrezka-test.js [options] - -Options: - --title, -t Title to search for - --type, -m <type> Media type (movie or show) - --year, -y <year> Release year - --season, -s <number> Season number (for shows) - --episode, -e <number> Episode number (for shows) - --help, -h Show this help message - -Examples: - node hdrezka-test.js --title "Breaking Bad" --type show --season 1 --episode 3 - node hdrezka-test.js --title "Inception" --type movie --year 2010 - node hdrezka-test.js (interactive mode) -`); - process.exit(0); - } -} - -// Create readline interface for user input -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); - -// Function to prompt user for input -function prompt(question) { - return new Promise((resolve) => { - rl.question(question, (answer) => { - resolve(answer); - }); - }); -} - -// Helper functions -function generateRandomFavs() { - const randomHex = () => Math.floor(Math.random() * 16).toString(16); - const generateSegment = (length) => Array.from({ length }, randomHex).join(''); - - return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`; -} - -function extractTitleAndYear(input) { - const regex = /^(.*?),.*?(\d{4})/; - const match = input.match(regex); - - if (match) { - const title = match[1]; - const year = match[2]; - return { title: title.trim(), year: year ? parseInt(year, 10) : null }; - } - return null; -} - -function parseVideoLinks(inputString) { - if (!inputString) { - throw new Error('No video links found'); - } - - console.log(`[PARSE] Parsing video links from stream URL data`); - const linksArray = inputString.split(','); - const result = {}; - - linksArray.forEach((link) => { - // Handle different quality formats: - // 1. Simple format: [360p]https://example.com/video.mp4 - // 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4 - - // Try simple format first (non-HTML) - let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/); - - // If not found, try HTML format with more flexible pattern - if (!match) { - // Extract quality text from HTML span - const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/); - // Extract URL separately - const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/); - - if (qualityMatch && urlMatch) { - match = [null, qualityMatch[1].trim(), urlMatch[1]]; - } - } - - if (match) { - const qualityText = match[1].trim(); - const mp4Url = match[2]; - - // Extract the quality value (e.g., "360p", "1080p Ultra") - let quality = qualityText; - - // Skip null URLs (premium content that requires login) - if (mp4Url !== 'null') { - result[quality] = { type: 'mp4', url: mp4Url }; - console.log(`[QUALITY] Found ${quality}: ${mp4Url}`); - } else { - console.log(`[QUALITY] Premium quality ${quality} requires login (null URL)`); - } - } else { - console.log(`[WARNING] Could not parse quality from: ${link}`); - } - }); - - console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`); - return result; -} - -function parseSubtitles(inputString) { - if (!inputString) { - console.log('[SUBTITLES] No subtitles found'); - return []; - } - - console.log(`[PARSE] Parsing subtitles data`); - const linksArray = inputString.split(','); - const captions = []; - - linksArray.forEach((link) => { - const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); - - if (match) { - const language = match[1]; - const url = match[2]; - - captions.push({ - id: url, - language, - hasCorsRestrictions: false, - type: 'vtt', - url: url, - }); - console.log(`[SUBTITLE] Found ${language}: ${url}`); - } - }); - - console.log(`[PARSE] Found ${captions.length} subtitles`); - return captions; -} - -// Main scraper functions -async function searchAndFindMediaId(media) { - console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`); - - const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g; - const idRegexPattern = /\/(\d+)-[^/]+\.html$/; - - const fullUrl = new URL('/engine/ajax/search.php', rezkaBase); - fullUrl.searchParams.append('q', media.title); - - console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`); - const response = await fetch(fullUrl.toString(), { - headers: baseHeaders - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const searchData = await response.text(); - console.log(`[RESPONSE] Search response length: ${searchData.length}`); - - const movieData = []; - let match; - - while ((match = itemRegexPattern.exec(searchData)) !== null) { - const url = match[1]; - const titleAndYear = match[3]; - - const result = extractTitleAndYear(titleAndYear); - if (result !== null) { - const id = url.match(idRegexPattern)?.[1] || null; - const isMovie = url.includes('/films/'); - const isShow = url.includes('/series/'); - const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown'; - - movieData.push({ - id: id ?? '', - year: result.year ?? 0, - type, - url, - title: match[2] - }); - console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`); - } - } - - // If year is provided, filter by year - let filteredItems = movieData; - if (media.releaseYear) { - filteredItems = movieData.filter(item => item.year === media.releaseYear); - console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`); - } - - // If type is provided, filter by type - if (media.type) { - filteredItems = filteredItems.filter(item => item.type === media.type); - console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`); - } - - if (filteredItems.length === 0 && movieData.length > 0) { - console.log(`[WARNING] No items match the exact criteria. Showing all results:`); - movieData.forEach((item, index) => { - console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`); - }); - - // Let user select from results - const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): "); - const selectedIndex = parseInt(selection) - 1; - - if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) { - console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`); - return movieData[selectedIndex]; - } else if (movieData.length > 0) { - console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`); - return movieData[0]; - } - - return null; - } - - if (filteredItems.length > 0) { - console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`); - return filteredItems[0]; - } else { - console.log(`[ERROR] No matching items found`); - return null; - } -} - -async function getTranslatorId(url, id, media) { - console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`); - - // Make sure the URL is absolute - const fullUrl = url.startsWith('http') ? url : `${rezkaBase}${url.startsWith('/') ? url.substring(1) : url}`; - console.log(`[REQUEST] Making request to: ${fullUrl}`); - - const response = await fetch(fullUrl, { - headers: baseHeaders, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const responseText = await response.text(); - console.log(`[RESPONSE] Translator page response length: ${responseText.length}`); - - // Translator ID 238 represents the Original + subtitles player. - if (responseText.includes(`data-translator_id="238"`)) { - console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`); - return '238'; - } - - const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents'; - const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i'); - const match = responseText.match(regexPattern); - const translatorId = match ? match[1] : null; - - console.log(`[RESULT] Extracted translator ID: ${translatorId}`); - return translatorId; -} - -async function getStream(id, translatorId, media) { - console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`); - - const searchParams = new URLSearchParams(); - searchParams.append('id', id); - searchParams.append('translator_id', translatorId); - - if (media.type === 'show') { - searchParams.append('season', media.season.number.toString()); - searchParams.append('episode', media.episode.number.toString()); - console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`); - } - - const randomFavs = generateRandomFavs(); - searchParams.append('favs', randomFavs); - searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie'); - - const fullUrl = `${rezkaBase}ajax/get_cdn_series/`; - console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`); - - // Log the request details - console.log('[HDRezka][FETCH DEBUG]', { - url: fullUrl, - method: 'POST', - headers: baseHeaders, - body: searchParams.toString() - }); - - const response = await fetch(fullUrl, { - method: 'POST', - body: searchParams, - headers: baseHeaders, - }); - - // Log the response details - let responseHeaders = {}; - if (response.headers && typeof response.headers.forEach === 'function') { - response.headers.forEach((value, key) => { - responseHeaders[key] = value; - }); - } else if (response.headers && response.headers.entries) { - for (const [key, value] of response.headers.entries()) { - responseHeaders[key] = value; - } - } - const responseText = await response.clone().text(); - console.log('[HDRezka][FETCH RESPONSE]', { - status: response.status, - headers: responseHeaders, - text: responseText - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const rawText = await response.text(); - console.log(`[RESPONSE] Stream response length: ${rawText.length}`); - - // Response content-type is text/html, but it's actually JSON - try { - const parsedResponse = JSON.parse(rawText); - console.log(`[RESULT] Parsed response successfully`); - - // Process video qualities and subtitles - const qualities = parseVideoLinks(parsedResponse.url); - const captions = parseSubtitles(parsedResponse.subtitle); - - // Add the parsed data to the response - parsedResponse.formattedQualities = qualities; - parsedResponse.formattedCaptions = captions; - - return parsedResponse; - } catch (e) { - console.error(`[ERROR] Failed to parse JSON response: ${e.message}`); - console.log(`[ERROR] Raw response: ${rawText.substring(0, 200)}...`); - return null; - } -} - -// Main execution -async function main() { - try { - console.log('=== HDREZKA SCRAPER TEST ==='); - - let media; - - // Check if we have command line arguments - if (argOptions.title) { - // Use command line arguments - media = { - type: argOptions.type || 'show', - title: argOptions.title, - releaseYear: argOptions.year || null - }; - - // If it's a show, add season and episode - if (media.type === 'show') { - media.season = { number: argOptions.season || 1 }; - media.episode = { number: argOptions.episode || 1 }; - - console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`); - } else { - console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`); - } - } else { - // Get user input interactively - const title = await prompt('Enter title to search: '); - const mediaType = await prompt('Enter media type (movie/show): ').then(type => - type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show' - ); - const releaseYear = await prompt('Enter release year (optional): ').then(year => - year ? parseInt(year) : null - ); - - // Create media object - media = { - type: mediaType, - title: title, - releaseYear: releaseYear - }; - - // If it's a show, get season and episode - if (mediaType === 'show') { - const seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1); - const episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1); - - media.season = { number: seasonNum }; - media.episode = { number: episodeNum }; - - console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`); - } else { - console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`); - } - } - - // Step 1: Search and find media ID - const result = await searchAndFindMediaId(media); - if (!result || !result.id) { - console.log('No result found, exiting'); - rl.close(); - return; - } - - // Step 2: Get translator ID - const translatorId = await getTranslatorId(result.url, result.id, media); - if (!translatorId) { - console.log('No translator ID found, exiting'); - rl.close(); - return; - } - - // Step 3: Get stream - const streamData = await getStream(result.id, translatorId, media); - if (!streamData) { - console.log('No stream data found, exiting'); - rl.close(); - return; - } - - // Format output in clean JSON similar to CLI output - const formattedOutput = { - embeds: [], - stream: [ - { - id: 'primary', - type: 'file', - flags: ['cors-allowed', 'ip-locked'], - captions: streamData.formattedCaptions.map(caption => ({ - id: caption.url, - language: caption.language === 'Русский' ? 'ru' : - caption.language === 'Українська' ? 'uk' : - caption.language === 'English' ? 'en' : caption.language.toLowerCase(), - hasCorsRestrictions: false, - type: 'vtt', - url: caption.url - })), - qualities: Object.entries(streamData.formattedQualities).reduce((acc, [quality, data]) => { - // Convert quality format to match CLI output - // "360p" -> "360", "1080p Ultra" -> "1080" (or keep as is if needed) - let qualityKey = quality; - const numericMatch = quality.match(/^(\d+)p/); - if (numericMatch) { - qualityKey = numericMatch[1]; - } - - acc[qualityKey] = { - type: data.type, - url: data.url - }; - return acc; - }, {}) - } - ] - }; - - // Display the formatted output - console.log('✓ Done!'); - console.log(JSON.stringify(formattedOutput, null, 2).replace(/"([^"]+)":/g, '$1:')); - - console.log('=== SCRAPING COMPLETE ==='); - } catch (error) { - console.error(`Error: ${error.message}`); - if (error.cause) { - console.error(`Cause: ${error.cause.message}`); - } - } finally { - rl.close(); - } -} - -main(); \ No newline at end of file diff --git a/ios/Nuvio.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Nuvio.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/ios/Nuvio.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="UTF-8"?> +<Workspace + version = "1.0"> + <FileRef + location = "self:"> + </FileRef> +</Workspace> diff --git a/ios/Nuvio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Nuvio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/ios/Nuvio.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>IDEDidComputeMac32BitWarning</key> + <true/> +</dict> +</plist> diff --git a/local-scrapers-repo b/local-scrapers-repo new file mode 160000 index 0000000..46fce12 --- /dev/null +++ b/local-scrapers-repo @@ -0,0 +1 @@ +Subproject commit 46fce12a69ce684962a76893520e89fec18e0989 diff --git a/metro.config.js b/metro.config.js index 79fe23f..dc78200 100644 --- a/metro.config.js +++ b/metro.config.js @@ -1,6 +1,8 @@ -const { getDefaultConfig } = require('expo/metro-config'); +const { + getSentryExpoConfig +} = require("@sentry/react-native/metro"); -const config = getDefaultConfig(__dirname); +const config = getSentryExpoConfig(__dirname); // Enable tree shaking and better minification config.transformer = { @@ -28,4 +30,4 @@ config.resolver = { resolverMainFields: ['react-native', 'browser', 'main'], }; -module.exports = config; \ No newline at end of file +module.exports = config; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b49aae1..6d52031 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,12 +21,14 @@ "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "@react-navigation/stack": "^7.2.10", + "@sentry/react-native": "^6.15.1", "@shopify/flash-list": "1.7.3", + "@shopify/react-native-skia": "^1.12.4", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.10.0", "base64-js": "^1.5.1", - "cheerio": "^1.1.0", + "cheerio-without-node-native": "^0.20.2", "cors": "^2.8.5", "date-fns": "^4.1.0", "eventemitter3": "^5.0.1", @@ -58,7 +60,7 @@ "react-native-modal": "^14.0.0-rc.1", "react-native-orientation-locker": "^1.7.0", "react-native-paper": "^5.13.1", - "react-native-reanimated": "~3.16.1", + "react-native-reanimated": "^3.18.0", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "^15.11.2", @@ -68,6 +70,7 @@ "react-native-vlc-media-player": "^1.0.87", "react-native-web": "~0.19.13", "react-native-wheel-color-picker": "^1.3.1", + "react-navigation-shared-element": "^3.1.3", "subsrt": "^1.1.1" }, "devDependencies": { @@ -107,23 +110,23 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.8.tgz", - "integrity": "sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz", + "integrity": "sha512-60X7qkglvrap8mn1lh2ebxXdZYtUcpd7gsmy9kLaBJ4i/WdY8PqTSdxyA8qraikqKQK5C1KRBKXqznrVapyNaw==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -160,15 +163,15 @@ } }, "node_modules/@babel/generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.0.tgz", - "integrity": "sha512-VybsKvpiN1gU1sdMZIp7FcqphVVKEwcuj02x73uvcHE0PTihx1nlBcowYWhDwjpoAXRv43+gDzyggGnn1XZhVw==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.0.tgz", + "integrity": "sha512-lJjzvrbEeWrhB4P3QBsH7tey117PjLZnDbLiQEKjQ/fNJTjuq4HSqgFA+UNSwZT8D7dxxbnuSBMsa1lrWzKlQg==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.0", + "@babel/types": "^7.28.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -176,25 +179,25 @@ } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", - "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.0.tgz", - "integrity": "sha512-LVk7fbXml0H2xH34dFzKQ7TDZ2G4/rVTOrq9V+icbbadjbVxxeFeDsNHv2SrZeWoA+6ZiTyWYWtScEIW07EAcA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-validator-option": "^7.25.9", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -204,17 +207,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.0.tgz", - "integrity": "sha512-vSGCvMecvFCd/BdpGlhpXYNhhC4ccxyvQWpbGL4CWbvfEoLFWUZuSuf7s9Aw70flgQF+6vptvgK2IfOnKlRmBg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.27.1.tgz", + "integrity": "sha512-QwGAmuvM17btKU5VqXfb+Giw4JcN0hjuufz3DYnpeVDvZLAObloM77bhMXiqry3Iio+Ai4phVRDwl6WU10+r5A==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/helper-replace-supers": "^7.26.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/traverse": "^7.27.0", + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.27.1", "semver": "^6.3.1" }, "engines": { @@ -225,12 +228,12 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.0.tgz", - "integrity": "sha512-fO8l08T76v48BhpNRW/nQ0MxfnSdoSKUJBMjubOAYffsVuGG5qOfMq7N6Es7UJvi7Y8goXXo07EfcHZXDPuELQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.27.1.tgz", + "integrity": "sha512-uVDC72XVf8UbrH5qQTc18Agb8emwjTiZrQE11Nv3CuBEZmVvTwwE9CBUEvHku06gQCAyYf8Nv6ja1IN+6LMbxQ==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-annotate-as-pure": "^7.27.1", "regexpu-core": "^6.2.0", "semver": "^6.3.1" }, @@ -242,56 +245,65 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.4.tgz", - "integrity": "sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz", + "integrity": "sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.22.6", - "@babel/helper-plugin-utils": "^7.22.5", - "debug": "^4.1.1", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "debug": "^4.4.1", "lodash.debounce": "^4.0.8", - "resolve": "^1.14.2" + "resolve": "^1.22.10" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", - "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz", + "integrity": "sha512-E5chM8eWjTp/aNoVpcbfM7mLxu9XGLWYise2eBKGQomAk/Mb4XoxyqXTZbuTohbsl8EKqdlMhnDI2CCLfcs9wA==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", - "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", - "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -301,35 +313,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", - "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.9" + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.26.5.tgz", - "integrity": "sha512-RS+jZcRdZdRFzMyr+wcsaqOmld1/EqTghfaBGQQd/WnRdzdlvSZ//kF7U8VQTxf1ynZ4cjUcYgjVGx13ewNPMg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", - "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-wrap-function": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -339,14 +351,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.26.5.tgz", - "integrity": "sha512-bJ6iIVdYX1YooY2X7w1q6VITt+LnUILtNk7zT78ykuwStx8BauCzxvFqFaHjOpW1bVnSUM1PN1f0p5P21wHxvg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.27.1.tgz", + "integrity": "sha512-7EHz6qDZc8RYS5ElPoShMheWvEgERonFCs7IAonWLLUTXW59DP14bCZt89/GKyreYn8g3S83m21FelHKbeDCKA==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.25.9", - "@babel/helper-optimise-call-expression": "^7.25.9", - "@babel/traverse": "^7.26.5" + "@babel/helper-member-expression-to-functions": "^7.27.1", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -356,54 +368,54 @@ } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", - "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", - "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", - "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.27.1.tgz", + "integrity": "sha512-NFJK2sHUvrjo8wAU/nQTWU890/zB2jj0qBcCbZbbf+005cAsv6tMjXz31fBign6M5ov1o0Bllu+9nbqkfsjjJQ==", "license": "MIT", "dependencies": { - "@babel/template": "^7.25.9", - "@babel/traverse": "^7.25.9", - "@babel/types": "^7.25.9" + "@babel/template": "^7.27.1", + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -509,12 +521,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.0.tgz", + "integrity": "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.28.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -524,14 +536,14 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", - "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz", + "integrity": "sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -541,13 +553,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", - "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -557,13 +569,13 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", - "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -573,15 +585,15 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", - "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -591,14 +603,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", - "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.27.1.tgz", + "integrity": "sha512-6BpaYGDavZqkI6yT+KSPdpZFfpnd68UKXbcjI9pJ13pvHhPrCKWOOLp+ysvMeA+DxnhuPpgIaRpxRxo5A9t5jw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -813,13 +825,13 @@ } }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", - "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", + "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -829,12 +841,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", - "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1017,12 +1029,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", - "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1032,14 +1044,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.26.8.tgz", - "integrity": "sha512-He9Ej2X7tNf2zdKMAGOsmg2MrFc+hfoAhd3po4cWfo/NWjzEAKa0oQruj1ROVUdl0e6fb6/kE/G3SSxE0lRJOg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz", + "integrity": "sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-remap-async-to-generator": "^7.25.9", - "@babel/traverse": "^7.26.8" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1049,14 +1061,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", - "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz", + "integrity": "sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-remap-async-to-generator": "^7.25.9" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-remap-async-to-generator": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1066,13 +1078,13 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.26.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.26.5.tgz", - "integrity": "sha512-chuTSY+hq09+/f5lMj8ZSYgCFpppV2CbYrhNFJ1BFoXpiWPnnAb7R0MqrafCpN8E1+YRrtM1MXZHJdIx8B6rMQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1082,12 +1094,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.27.0.tgz", - "integrity": "sha512-u1jGphZ8uDI2Pj/HJj6YQ6XQLZCNjOlprjxB5SVz6rq2T6SwAR+CdrWK0CP7F+9rDVMXdB0+r6Am5G5aobOjAQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.0.tgz", + "integrity": "sha512-gKKnwjpdx5sER/wl0WN0efUBFzF/56YZO0RJrSYP4CljXnP31ByY7fol89AzomdlLNzI36AvOTmYHsnZTCkq8Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1097,13 +1109,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", - "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz", + "integrity": "sha512-D0VcalChDMtuRvJIu3U/fwWjf8ZMykz5iZsg77Nuj821vCKI3zCyRLwRdWbsuJ/uRwZhZ002QtCqIkwC/ZkvbA==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1113,14 +1125,14 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", - "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.27.1.tgz", + "integrity": "sha512-s734HmYU78MVzZ++joYM+NkJusItbdRcbm+AGRgJCt3iA+yux0QpD9cBVdz3tKyrjVYWRl7j0mHSmv4lhV0aoA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1130,17 +1142,17 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", - "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.0.tgz", + "integrity": "sha512-IjM1IoJNw72AZFlj33Cu8X0q2XK/6AaVC3jQu+cgQ5lThWD5ajnuUAml80dqRmOhmPkTH8uAwnpMu9Rvj0LTRA==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9", - "@babel/traverse": "^7.25.9", - "globals": "^11.1.0" + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1150,13 +1162,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", - "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.27.1.tgz", + "integrity": "sha512-lj9PGWvMTVksbWiDT2tW68zGS/cyo4AkZ/QTp0sQT0mjPopCmrSkzxeXkznjqBxzDI6TclZhOJbBmbBLjuOZUw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/template": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/template": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1166,12 +1178,13 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", - "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.0.tgz", + "integrity": "sha512-v1nrSMBiKcodhsyJ4Gf+Z0U/yawmJDBOTpEB3mcQY52r9RIyPneGyAS/yM6seP/8I+mWI3elOMtT5dB8GJVs+A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1181,14 +1194,14 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", - "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", + "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1198,13 +1211,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", - "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1214,14 +1227,14 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", - "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1231,13 +1244,30 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", - "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", + "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1247,13 +1277,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.26.3.tgz", - "integrity": "sha512-7CAHcQ58z2chuXPWblnn1K6rLDnDWieghSOEmqQsrBenH0P9InCUtOJYD89pvngljmZlJcz3fcmgYsXFNGa1ZQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.27.1.tgz", + "integrity": "sha512-uspvXnhHvGKf2r4VVtBpeFnuDWsJLQ6MF6lGJLC89jBR1uoVeqM416AZtTuhTezOfgHicpJQmoD5YUakO/YmXQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1263,12 +1293,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", - "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1294,13 +1324,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.26.9.tgz", - "integrity": "sha512-Hry8AusVm8LW5BVFgiyUReuoGzPUpdHQQqJY5bZnbbf+ngOHWuCuYFKw/BqaaWlvEUrF91HMhDtEaI1hZzNbLg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1310,14 +1340,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", - "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1327,13 +1357,13 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", - "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", + "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1343,12 +1373,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", - "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1358,12 +1388,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", - "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.27.1.tgz", + "integrity": "sha512-SJvDs5dXxiae4FbSL1aBJlG4wvl594N6YEVVn9e3JGulwioy6z3oPjx/sQBO3Y4NwUu5HNix6KJ3wBZoewcdbw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1373,13 +1403,13 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", - "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1389,14 +1419,14 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", - "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1406,13 +1436,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.26.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.26.3.tgz", - "integrity": "sha512-MgR55l4q9KddUDITEzEFYn5ZsGDXMSsU9E+kh7fjRXTIC3RHqfCo8RPRbyReYJh44HQ/yomFkqbOFohXvDCiIQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.27.1.tgz", + "integrity": "sha512-OJguuwlTYlN0gBZFRPqwOGNWssZjfIUdS7HMYtN8c1KmwpwHFBwTeFZrg9XZa+DFTitWOW5iTAG7tyCUPsCCyw==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.26.0", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1422,16 +1452,16 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", - "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz", + "integrity": "sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9", - "@babel/traverse": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1441,14 +1471,14 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", - "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-module-transforms": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1458,13 +1488,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", - "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz", + "integrity": "sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1474,13 +1504,13 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", - "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1490,12 +1520,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.26.6", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.26.6.tgz", - "integrity": "sha512-CKW8Vu+uUZneQCPtXmSBUC6NCAUdya26hWCElAWh5mVSlSRsmiCPUUDKb3Z0szng1hiAJa098Hkhg9o4SE35Qw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz", + "integrity": "sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1505,12 +1535,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", - "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.27.1.tgz", + "integrity": "sha512-fdPKAcujuvEChxDBJ5c+0BTaS6revLV7CJL08e4m3de8qJfNIuCc2nc7XJYOjBoTMJeqSmwXJ0ypE14RCjLwaw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1520,14 +1550,16 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", - "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.0.tgz", + "integrity": "sha512-9VNGikXxzu5eCiQjdE4IZn8sb9q7Xsk5EXLDBKUYg1e/Tve8/05+KJEtcxGxAgCY5t/BpKQM+JEL/yT4tvgiUA==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9" + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.0" }, "engines": { "node": ">=6.9.0" @@ -1537,14 +1569,14 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", - "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-replace-supers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1554,12 +1586,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", - "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.27.1.tgz", + "integrity": "sha512-txEAEKzYrHEX4xSZN4kJ+OfKXFVSWKB2ZxM9dpcE3wT7smwkNmXo5ORRlVzMVdJbD+Q8ILTgSD7959uj+3Dm3Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1569,13 +1601,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", - "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz", + "integrity": "sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1585,12 +1617,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", - "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1600,13 +1632,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", - "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.27.1.tgz", + "integrity": "sha512-10FVt+X55AjRAYI9BrdISN9/AQWHqldOeZDUoLyif1Kn05a56xVBXb8ZouL8pZ9jem8QpXaOt8TS7RHUIS+GPA==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1616,14 +1648,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", - "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.27.1.tgz", + "integrity": "sha512-5J+IhqTi1XPa0DXF83jYOaARrX+41gOewWbkPyjMNRDqgOCqdffGh8L3f/Ek5utaEBZExjSAzcyjmV9SSAWObQ==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-create-class-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-create-class-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1633,13 +1665,13 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", - "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1744,13 +1776,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.27.0.tgz", - "integrity": "sha512-LX/vCajUJQDqE7Aum/ELUMZAY19+cDpghxrnyt5I1tV6X5PyC86AOoWXWFYFeIvauyeSA6/ktn4tQVn/3ZifsA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.0.tgz", + "integrity": "sha512-LOAozRVbqxEVjSKfhGnuLoE4Kz4Oc5UJzuvFUhSsQzdCdaAQu06mG8zDv2GFSerM62nImUZ7K92vxnQcLSDlCQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5", - "regenerator-transform": "^0.15.2" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1760,14 +1791,14 @@ } }, "node_modules/@babel/plugin-transform-regexp-modifiers": { - "version": "7.26.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", - "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", + "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1777,13 +1808,13 @@ } }, "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", - "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1813,12 +1844,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", - "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1828,13 +1859,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", - "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.27.1.tgz", + "integrity": "sha512-kpb3HUqaILBJcRFVhFUs6Trdd4mkrzcGXss+6/mxUd273PfbWqSDHRzMT2234gIg2QYfAjvXLSquP1xECSg09Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9", - "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1844,12 +1875,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", - "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1859,12 +1890,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", - "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1874,13 +1905,13 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz", - "integrity": "sha512-+LLkxA9rKJpNoGsbLnAgOCdESl73vwYn+V6b+5wHbrE7OGKVDPHIQvbFSzqE6rwqaCw2RE+zdJrlLkcf8YOA0w==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1909,13 +1940,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", - "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1925,14 +1956,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", - "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", + "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1942,13 +1973,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", - "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1958,14 +1989,14 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", - "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", + "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.9", - "@babel/helper-plugin-utils": "^7.25.9" + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1975,80 +2006,81 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.26.9", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.9.tgz", - "integrity": "sha512-vX3qPGE8sEKEAZCWk05k3cpTAE3/nOYca++JA+Rd0z2NCNzabmYvEiSShKzm10zdquOIAVXsy2Ei/DTW34KlKQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.28.0.tgz", + "integrity": "sha512-VmaxeGOwuDqzLl5JUkIRM1X2Qu2uKGxHEQWh+cvvbl7JuJRgKGJSfsEF/bUaxFhJl/XAyxBe7q7qSuTbKFuCyg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/compat-data": "^7.26.8", - "@babel/helper-compilation-targets": "^7.26.5", - "@babel/helper-plugin-utils": "^7.26.5", - "@babel/helper-validator-option": "^7.25.9", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/compat-data": "^7.28.0", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.27.1", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.27.1", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-import-assertions": "^7.26.0", - "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-import-assertions": "^7.27.1", + "@babel/plugin-syntax-import-attributes": "^7.27.1", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.25.9", - "@babel/plugin-transform-async-generator-functions": "^7.26.8", - "@babel/plugin-transform-async-to-generator": "^7.25.9", - "@babel/plugin-transform-block-scoped-functions": "^7.26.5", - "@babel/plugin-transform-block-scoping": "^7.25.9", - "@babel/plugin-transform-class-properties": "^7.25.9", - "@babel/plugin-transform-class-static-block": "^7.26.0", - "@babel/plugin-transform-classes": "^7.25.9", - "@babel/plugin-transform-computed-properties": "^7.25.9", - "@babel/plugin-transform-destructuring": "^7.25.9", - "@babel/plugin-transform-dotall-regex": "^7.25.9", - "@babel/plugin-transform-duplicate-keys": "^7.25.9", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-dynamic-import": "^7.25.9", - "@babel/plugin-transform-exponentiation-operator": "^7.26.3", - "@babel/plugin-transform-export-namespace-from": "^7.25.9", - "@babel/plugin-transform-for-of": "^7.26.9", - "@babel/plugin-transform-function-name": "^7.25.9", - "@babel/plugin-transform-json-strings": "^7.25.9", - "@babel/plugin-transform-literals": "^7.25.9", - "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", - "@babel/plugin-transform-member-expression-literals": "^7.25.9", - "@babel/plugin-transform-modules-amd": "^7.25.9", - "@babel/plugin-transform-modules-commonjs": "^7.26.3", - "@babel/plugin-transform-modules-systemjs": "^7.25.9", - "@babel/plugin-transform-modules-umd": "^7.25.9", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", - "@babel/plugin-transform-new-target": "^7.25.9", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.26.6", - "@babel/plugin-transform-numeric-separator": "^7.25.9", - "@babel/plugin-transform-object-rest-spread": "^7.25.9", - "@babel/plugin-transform-object-super": "^7.25.9", - "@babel/plugin-transform-optional-catch-binding": "^7.25.9", - "@babel/plugin-transform-optional-chaining": "^7.25.9", - "@babel/plugin-transform-parameters": "^7.25.9", - "@babel/plugin-transform-private-methods": "^7.25.9", - "@babel/plugin-transform-private-property-in-object": "^7.25.9", - "@babel/plugin-transform-property-literals": "^7.25.9", - "@babel/plugin-transform-regenerator": "^7.25.9", - "@babel/plugin-transform-regexp-modifiers": "^7.26.0", - "@babel/plugin-transform-reserved-words": "^7.25.9", - "@babel/plugin-transform-shorthand-properties": "^7.25.9", - "@babel/plugin-transform-spread": "^7.25.9", - "@babel/plugin-transform-sticky-regex": "^7.25.9", - "@babel/plugin-transform-template-literals": "^7.26.8", - "@babel/plugin-transform-typeof-symbol": "^7.26.7", - "@babel/plugin-transform-unicode-escapes": "^7.25.9", - "@babel/plugin-transform-unicode-property-regex": "^7.25.9", - "@babel/plugin-transform-unicode-regex": "^7.25.9", - "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.28.0", + "@babel/plugin-transform-async-to-generator": "^7.27.1", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.0", + "@babel/plugin-transform-class-properties": "^7.27.1", + "@babel/plugin-transform-class-static-block": "^7.27.1", + "@babel/plugin-transform-classes": "^7.28.0", + "@babel/plugin-transform-computed-properties": "^7.27.1", + "@babel/plugin-transform-destructuring": "^7.28.0", + "@babel/plugin-transform-dotall-regex": "^7.27.1", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.0", + "@babel/plugin-transform-exponentiation-operator": "^7.27.1", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.27.1", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.27.1", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.27.1", + "@babel/plugin-transform-modules-systemjs": "^7.27.1", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.27.1", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.27.1", + "@babel/plugin-transform-numeric-separator": "^7.27.1", + "@babel/plugin-transform-object-rest-spread": "^7.28.0", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.27.1", + "@babel/plugin-transform-private-property-in-object": "^7.27.1", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.28.0", + "@babel/plugin-transform-regexp-modifiers": "^7.27.1", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.27.1", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.27.1", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.27.1", "@babel/preset-modules": "0.1.6-no-external-plugins", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.40.0", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "core-js-compat": "^3.43.0", "semver": "^6.3.1" }, "engines": { @@ -2058,6 +2090,20 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/@babel/preset-flow": { "version": "7.25.9", "resolved": "https://registry.npmjs.org/@babel/preset-flow/-/preset-flow-7.25.9.tgz", @@ -2161,32 +2207,32 @@ } }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.0.tgz", - "integrity": "sha512-19lYZFzYVQkkHkl4Cy4WrAVcqBkgvV2YM2TU3xG6DIwO7O3ecbDPfW3yM3bjAGcqcQHi+CCtjMR3dIEHxsd6bA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.0.tgz", + "integrity": "sha512-mGe7UK5wWyh0bKRfupsUchrQGqvDbZDbKJw+kcRGSmdHVYrv+ltd0pnpDTVpiTqnaBru9iEvA8pz8W46v0Amwg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/generator": "^7.27.0", - "@babel/parser": "^7.27.0", - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.0", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" @@ -2212,13 +2258,13 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", + "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -3197,17 +3243,13 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", + "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -3219,15 +3261,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", @@ -3245,9 +3278,9 @@ "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.29", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", + "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -4014,6 +4047,349 @@ "join-component": "^1.1.0" } }, + "node_modules/@sentry-internal/browser-utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.54.0.tgz", + "integrity": "sha512-DKWCqb4YQosKn6aD45fhKyzhkdG7N6goGFDeyTaJFREJDFVDXiNDsYZu30nJ6BxMM7uQIaARhPAC5BXfoED3pQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/feedback": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.54.0.tgz", + "integrity": "sha512-nQqRacOXoElpE0L0ADxUUII0I3A94niqG9Z4Fmsw6057QvyrV/LvTiMQBop6r5qLjwMqK+T33iR4/NQI5RhsXQ==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.54.0.tgz", + "integrity": "sha512-8xuBe06IaYIGJec53wUC12tY2q4z2Z0RPS2s1sLtbA00EvK1YDGuXp96IDD+HB9mnDMrQ/jW5f97g9TvPsPQUg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry-internal/replay-canvas": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.54.0.tgz", + "integrity": "sha512-K/On3OAUBeq/TV2n+1EvObKC+WMV9npVXpVyJqCCyn8HYMm8FUGzuxeajzm0mlW4wDTPCQor6mK9/IgOquUzCw==", + "license": "MIT", + "dependencies": { + "@sentry-internal/replay": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz", + "integrity": "sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/@sentry/browser": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.54.0.tgz", + "integrity": "sha512-BgUtvxFHin0fS0CmJVKTLXXZcke0Av729IVfi+2fJ4COX8HO7/HAP02RKaSQGmL2HmvWYTfNZ7529AnUtrM4Rg==", + "license": "MIT", + "dependencies": { + "@sentry-internal/browser-utils": "8.54.0", + "@sentry-internal/feedback": "8.54.0", + "@sentry-internal/replay": "8.54.0", + "@sentry-internal/replay-canvas": "8.54.0", + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/cli": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.46.0.tgz", + "integrity": "sha512-nqoPl7UCr446QFkylrsRrUXF51x8Z9dGquyf4jaQU+OzbOJMqclnYEvU6iwbwvaw3tu/2DnoZE/Og+Nq1h63sA==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.46.0", + "@sentry/cli-linux-arm": "2.46.0", + "@sentry/cli-linux-arm64": "2.46.0", + "@sentry/cli-linux-i686": "2.46.0", + "@sentry/cli-linux-x64": "2.46.0", + "@sentry/cli-win32-arm64": "2.46.0", + "@sentry/cli-win32-i686": "2.46.0", + "@sentry/cli-win32-x64": "2.46.0" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.46.0.tgz", + "integrity": "sha512-5Ll+e5KAdIk9OYiZO8aifMBRNWmNyPjSqdjaHlBC1Qfh7pE3b1zyzoHlsUazG0bv0sNrSGea8e7kF5wIO1hvyg==", + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.46.0.tgz", + "integrity": "sha512-WRrLNq/TEX/TNJkGqq6Ad0tGyapd5dwlxtsPbVBrIdryuL1mA7VCBoaHBr3kcwJLsgBHFH0lmkMee2ubNZZdkg==", + "cpu": [ + "arm" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.46.0.tgz", + "integrity": "sha512-OEJN8yAjI9y5B4telyqzu27Hi3+S4T8VxZCqJz1+z2Mp0Q/MZ622AahVPpcrVq/5bxrnlZR16+lKh8L1QwNFPg==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.46.0.tgz", + "integrity": "sha512-xko3/BVa4LX8EmRxVOCipV+PwfcK5Xs8lP6lgF+7NeuAHMNL4DqF6iV9rrN8gkGUHCUI9RXSve37uuZnFy55+Q==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.46.0.tgz", + "integrity": "sha512-hJ1g5UEboYcOuRia96LxjJ0jhnmk8EWLDvlGnXLnYHkwy3ree/L7sNgdp/QsY8Z4j2PGO5f22Va+UDhSjhzlfQ==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.46.0.tgz", + "integrity": "sha512-mN7cpPoCv2VExFRGHt+IoK11yx4pM4ADZQGEso5BAUZ5duViXB2WrAXCLd8DrwMnP0OE978a7N8OtzsFqjkbNA==", + "cpu": [ + "arm64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.46.0.tgz", + "integrity": "sha512-6F73AUE3lm71BISUO19OmlnkFD5WVe4/wA1YivtLZTc1RU3eUYJLYxhDfaH3P77+ycDppQ2yCgemLRaA4A8mNQ==", + "cpu": [ + "x86", + "ia32" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.46.0", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.46.0.tgz", + "integrity": "sha512-yuGVcfepnNL84LGA0GjHzdMIcOzMe0bjPhq/rwPsPN+zu11N+nPR2wV2Bum4U0eQdqYH3iAlMdL5/BEQfuLJww==", + "cpu": [ + "x64" + ], + "license": "BSD-3-Clause", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sentry/core": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz", + "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==", + "license": "MIT", + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/react": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-8.54.0.tgz", + "integrity": "sha512-42T/fp8snYN19Fy/2P0Mwotu4gcdy+1Lx+uYCNcYP1o7wNGigJ7qb27sW7W34GyCCHjoCCfQgeOqDQsyY8LC9w==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "8.54.0", + "@sentry/core": "8.54.0", + "hoist-non-react-statics": "^3.3.2" + }, + "engines": { + "node": ">=14.18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/react-native": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-6.15.1.tgz", + "integrity": "sha512-uNYjkhi7LUeXe+a3ui3N+sUZ4PbBh/P3Q6Pz5esOQOAEV1N7hxkdnHVic1cVHsirEQvy9rUJPBnja47Va7OpQA==", + "license": "MIT", + "dependencies": { + "@sentry/babel-plugin-component-annotate": "3.5.0", + "@sentry/browser": "8.54.0", + "@sentry/cli": "2.46.0", + "@sentry/core": "8.54.0", + "@sentry/react": "8.54.0", + "@sentry/types": "8.54.0", + "@sentry/utils": "8.54.0" + }, + "bin": { + "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js" + }, + "peerDependencies": { + "expo": ">=49.0.0", + "react": ">=17.0.0", + "react-native": ">=0.65.0" + }, + "peerDependenciesMeta": { + "expo": { + "optional": true + } + } + }, + "node_modules/@sentry/types": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.54.0.tgz", + "integrity": "sha512-wztdtr7dOXQKi0iRvKc8XJhJ7HaAfOv8lGu0yqFOFwBZucO/SHnu87GOPi8mvrTiy1bentQO5l+zXWAaMvG4uw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, + "node_modules/@sentry/utils": { + "version": "8.54.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.54.0.tgz", + "integrity": "sha512-JL8UDjrsKxKclTdLXfuHfE7B3KbrAPEYP7tMyN/xiO2vsF6D84fjwYyalO0ZMtuFZE6vpSze8ZOLEh6hLnPYsw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "8.54.0" + }, + "engines": { + "node": ">=14.18" + } + }, "node_modules/@shopify/flash-list": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-1.7.3.tgz", @@ -4029,6 +4405,32 @@ "react-native": "*" } }, + "node_modules/@shopify/react-native-skia": { + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-1.12.4.tgz", + "integrity": "sha512-8QDIBKSU7XB3Lc1kAv4jSFddTQK8AE+1AEoJnQLNllsiex1gufLQ8kN7rs9zii+iboSY8tYKT7ocV+5cE2Exdw==", + "license": "MIT", + "dependencies": { + "canvaskit-wasm": "0.40.0", + "react-reconciler": "0.27.0" + }, + "bin": { + "setup-skia-web": "scripts/setup-canvaskit.js" + }, + "peerDependencies": { + "react": ">=18.0 <19.0.0", + "react-native": ">=0.64 <0.78.0", + "react-native-reanimated": ">=2.0.0" + }, + "peerDependenciesMeta": { + "react-native": { + "optional": true + }, + "react-native-reanimated": { + "optional": true + } + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -4724,6 +5126,12 @@ "@urql/core": "^5.0.0" } }, + "node_modules/@webgpu/types": { + "version": "0.1.21", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz", + "integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==", + "license": "BSD-3-Clause" + }, "node_modules/@xmldom/xmldom": { "version": "0.7.13", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.7.13.tgz", @@ -4734,6 +5142,14 @@ "node": ">=10.0.0" } }, + "node_modules/abab": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/abab/-/abab-1.0.4.tgz", + "integrity": "sha512-I+Wi+qiE2kUXyrRhNsWv6XsjUTBJjSoVSctKNBfLG5zG/Xe7Rjbxf13+vqYHNTwHaFU+FtSlVxOCTiMEVtPv0A==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "license": "ISC", + "optional": true + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -4771,6 +5187,29 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-globals": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-1.0.9.tgz", + "integrity": "sha512-j3/4pkfih8W4NK22gxVSXcEonTpAHOHh0hu5BoZrKcOsW/4oBPxTi4Yk3SAj+FhC1f3+bRTkXdm4019gw1vg9g==", + "license": "MIT", + "optional": true, + "dependencies": { + "acorn": "^2.1.0" + } + }, + "node_modules/acorn-globals/node_modules/acorn": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz", + "integrity": "sha512-pXK8ez/pVjqFdAgBkF1YPVRacuLQ9EXBKaKWaeh58WNfMkCmZhOZzu+NtKSPD5PHmCCHheQ5cD29qM1K4QTxIg==", + "license": "MIT", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/agent-base": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", @@ -4927,6 +5366,16 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/assert": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", @@ -4940,6 +5389,16 @@ "util": "^0.12.5" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.8" + } + }, "node_modules/ast-types": { "version": "0.15.2", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.15.2.tgz", @@ -4988,6 +5447,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "license": "MIT", + "optional": true + }, "node_modules/axios": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz", @@ -5082,13 +5558,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs2": { - "version": "0.4.13", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.13.tgz", - "integrity": "sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==", + "version": "0.4.14", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz", + "integrity": "sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.22.6", - "@babel/helper-define-polyfill-provider": "^0.6.4", + "@babel/compat-data": "^7.27.7", + "@babel/helper-define-polyfill-provider": "^0.6.5", "semver": "^6.3.1" }, "peerDependencies": { @@ -5109,12 +5585,12 @@ } }, "node_modules/babel-plugin-polyfill-regenerator": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.4.tgz", - "integrity": "sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==", + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz", + "integrity": "sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==", "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.4" + "@babel/helper-define-polyfill-provider": "^0.6.5" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -5350,6 +5826,16 @@ "node": ">=10.0.0" } }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, "node_modules/better-opn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz", @@ -5470,9 +5956,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.4.tgz", - "integrity": "sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "funding": [ { "type": "opencollective", @@ -5489,10 +5975,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001688", - "electron-to-chromium": "^1.5.73", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.1" + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -5699,9 +6185,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001713", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001713.tgz", - "integrity": "sha512-wCIWIg+A4Xr7NfhTuHdX+/FKh3+Op3LBbSp2N5Pfx6T/LhdQy3GTyoTg48BReaW/MyMNZAkTadsBtai3ldWK0Q==", + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", "funding": [ { "type": "opencollective", @@ -5718,6 +6204,22 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvaskit-wasm": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz", + "integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==", + "license": "BSD-3-Clause", + "dependencies": { + "@webgpu/types": "0.1.21" + } + }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "license": "Apache-2.0", + "optional": true + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -5785,6 +6287,86 @@ "url": "https://github.com/sponsors/fb55" } }, + "node_modules/cheerio-without-node-native": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/cheerio-without-node-native/-/cheerio-without-node-native-0.20.2.tgz", + "integrity": "sha512-TiXlwWtMZIYU9ujMWtvMiKIVw/aZcQ8G6kxvn53zr0fU12kXIXSs0ICH05LGzjAsmQxdoC/WBuN882MyTbLNrw==", + "license": "MIT", + "dependencies": { + "css-select": "~1.2.0", + "dom-serializer": "~0.1.0", + "entities": "~1.1.1", + "htmlparser2-without-node-native": "^3.9.0", + "lodash": "^4.1.0" + }, + "engines": { + "node": ">= 0.6" + }, + "optionalDependencies": { + "jsdom": "^7.0.2" + } + }, + "node_modules/cheerio-without-node-native/node_modules/css-select": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-1.2.0.tgz", + "integrity": "sha512-dUQOBoqdR7QwV90WysXPLXG5LO7nhYBgiWVfxF80DKPF8zx1t/pUd2FYy73emg3zrjtM6dzmYgbHKfV2rxiHQA==", + "license": "BSD-like", + "dependencies": { + "boolbase": "~1.0.0", + "css-what": "2.1", + "domutils": "1.5.1", + "nth-check": "~1.0.1" + } + }, + "node_modules/cheerio-without-node-native/node_modules/css-what": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-2.1.3.tgz", + "integrity": "sha512-a+EPoD+uZiNfh+5fxw2nO9QwFa6nJe2Or35fGY6Ipw1R3R4AGz1d1TEZrCegvw2YTmZ0jXirGYlzxxpYSHwpEg==", + "license": "BSD-2-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/cheerio-without-node-native/node_modules/dom-serializer": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.1.1.tgz", + "integrity": "sha512-l0IU0pPzLWSHBcieZbpOKgkIn3ts3vAh7ZuFyXNwJxJXk/c4Gwj9xaTJwIDVQCXawWD0qb3IzMGH5rglQaO0XA==", + "license": "MIT", + "dependencies": { + "domelementtype": "^1.3.0", + "entities": "^1.1.1" + } + }, + "node_modules/cheerio-without-node-native/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "license": "BSD-2-Clause" + }, + "node_modules/cheerio-without-node-native/node_modules/domutils": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.5.1.tgz", + "integrity": "sha512-gSu5Oi/I+3wDENBsOWBiRK1eoGxcywYSqg3rR960/+EfY0CF4EX1VPkgHOZ3WiS/Jg2DtliF6BhWcHlfpYUcGw==", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/cheerio-without-node-native/node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "license": "BSD-2-Clause" + }, + "node_modules/cheerio-without-node-native/node_modules/nth-check": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-1.0.2.tgz", + "integrity": "sha512-WeBOdju8SnzPN5vTUJYxYUxLeXpCaVP5i5e0LF8fg7WORF2Wd7wFX/pk0tYZk7s8T+J7VLy0Da6J1+wCT0AtHg==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "~1.0.0" + } + }, "node_modules/cheerio/node_modules/undici": { "version": "7.10.0", "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz", @@ -6190,18 +6772,24 @@ } }, "node_modules/core-js-compat": { - "version": "3.41.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz", - "integrity": "sha512-RFsU9LySVue9RTwdDVX/T0e2Y6jRYWXERKElIjpuEOEnxaXffI0X7RUwVzfYLfzuLXSNJDYoRYUAmRUcyln20A==", + "version": "3.43.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.43.0.tgz", + "integrity": "sha512-2GML2ZsCc5LR7hZYz4AXmjQw8zuy2T//2QntwdnpuYI7jteT6GVYJL7F6C2C57R7gSYrcqVW3lAALefdbhBLDA==", "license": "MIT", "dependencies": { - "browserslist": "^4.24.4" + "browserslist": "^4.25.0" }, "funding": { "type": "opencollective", "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -6372,12 +6960,42 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/cssom": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", + "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", + "license": "MIT", + "optional": true + }, + "node_modules/cssstyle": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-0.2.37.tgz", + "integrity": "sha512-FUpKc+1FNBsHUr9IsfSGCovr8VuGOiiuzlgCyppKBjJi2jYTOFLN3oiiNRMIvYqbFzF38mqKj4BgcevzU5/kIA==", + "license": "MIT", + "optional": true, + "dependencies": { + "cssom": "0.3.x" + } + }, "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", "license": "MIT" }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -6432,6 +7050,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "license": "MIT", + "optional": true + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -6728,6 +7353,24 @@ "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", "license": "MIT" }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "license": "MIT", + "optional": true, + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/ecc-jsbn/node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6735,9 +7378,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.135", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.135.tgz", - "integrity": "sha512-8gXUdEmvb+WCaYUhA0Svr08uSeRjM2w3x5uHOc1QbaEVzJXB8rgm5eptieXzyKoVEtinLvW6MtTcurA65PeS1Q==", + "version": "1.5.179", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", + "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -6977,6 +7620,12 @@ "node": ">=6" } }, + "node_modules/eventemitter2": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-1.0.5.tgz", + "integrity": "sha512-EUFhWUYzqqBZlzBMI+dPU8rnKXfQZEUnitnccQuEIAnvWFHCpt3+4fts2+4dpxLtlsiseVXCMFg37KjYChSxpg==", + "license": "MIT" + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", @@ -7723,6 +8372,13 @@ "node": ">= 0.8" } }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "optional": true + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -7758,6 +8414,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -7792,6 +8458,13 @@ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "license": "MIT", + "optional": true + }, "node_modules/fast-loops": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-loops/-/fast-loops-1.1.4.tgz", @@ -8040,6 +8713,16 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/form-data": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.3.tgz", @@ -8257,6 +8940,16 @@ "node": ">=6" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -8360,6 +9053,55 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "license": "ISC", + "optional": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/har-validator/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/har-validator/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT", + "optional": true + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -8493,6 +9235,83 @@ "entities": "^6.0.0" } }, + "node_modules/htmlparser2-without-node-native": { + "version": "3.9.2", + "resolved": "https://registry.npmjs.org/htmlparser2-without-node-native/-/htmlparser2-without-node-native-3.9.2.tgz", + "integrity": "sha512-+FplQXqmY5fRx6vCIp2P5urWaoBCpTNJMXnKP/6mNCcyb+AZWWJzA8D03peXfozlxDL+vpgLK5dJblqEgu8j6A==", + "license": "MIT", + "dependencies": { + "domelementtype": "^1.3.0", + "domhandler": "^2.3.0", + "domutils": "^1.5.1", + "entities": "^1.1.1", + "eventemitter2": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.2" + } + }, + "node_modules/htmlparser2-without-node-native/node_modules/dom-serializer": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz", + "integrity": "sha512-2/xPb3ORsQ42nHYiSunXkDjPLBaEj/xTwUO4B7XCZQTRk7EBtTOPaygh10YAAh2OI1Qrp6NWfpAhzswj0ydt9g==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "entities": "^2.0.0" + } + }, + "node_modules/htmlparser2-without-node-native/node_modules/dom-serializer/node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2-without-node-native/node_modules/dom-serializer/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/htmlparser2-without-node-native/node_modules/domelementtype": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-1.3.1.tgz", + "integrity": "sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==", + "license": "BSD-2-Clause" + }, + "node_modules/htmlparser2-without-node-native/node_modules/domhandler": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-2.4.2.tgz", + "integrity": "sha512-JiK04h0Ht5u/80fdLMCEmV4zkNh2BcoMFBmZ/91WtYZ8qVXSKjiw7fXMgFPnHcSZgOo3XdinHvmnDUeMf5R4wA==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "1" + } + }, + "node_modules/htmlparser2-without-node-native/node_modules/domutils": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-1.7.0.tgz", + "integrity": "sha512-Lgd2XcJ/NjEw+7tFvfKxOzCYKZsdct5lczQ2ZaQY8Djz7pfAD3Gbp8ySJWtreII/vDlMVmxwa6pHmdxIYgttDg==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "0", + "domelementtype": "1" + } + }, + "node_modules/htmlparser2-without-node-native/node_modules/entities": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz", + "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==", + "license": "BSD-2-Clause" + }, "node_modules/htmlparser2/node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -8543,6 +9362,22 @@ "node": ">= 14" } }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -8998,6 +9833,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT", + "optional": true + }, "node_modules/is-wsl": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", @@ -9010,6 +9852,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -9034,6 +9882,13 @@ "node": ">=0.10.0" } }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "license": "MIT", + "optional": true + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -9338,6 +10193,99 @@ "@babel/preset-env": "^7.1.6" } }, + "node_modules/jsdom": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-7.2.2.tgz", + "integrity": "sha512-kYeYuos/pYp0V/V8VAoGnUc0va0UZjTjwCsldBFZNBrOi9Q5kUXrvsw6W5/lQllB7hKXBARC4HRk1Sfk4dPFtA==", + "license": "MIT", + "optional": true, + "dependencies": { + "abab": "^1.0.0", + "acorn": "^2.4.0", + "acorn-globals": "^1.0.4", + "cssom": ">= 0.3.0 < 0.4.0", + "cssstyle": ">= 0.2.29 < 0.3.0", + "escodegen": "^1.6.1", + "nwmatcher": ">= 1.3.7 < 2.0.0", + "parse5": "^1.5.1", + "request": "^2.55.0", + "sax": "^1.1.4", + "symbol-tree": ">= 3.1.0 < 4.0.0", + "tough-cookie": "^2.2.0", + "webidl-conversions": "^2.0.0", + "whatwg-url-compat": "~0.6.5", + "xml-name-validator": ">= 2.0.1 < 3.0.0" + } + }, + "node_modules/jsdom/node_modules/acorn": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-2.7.0.tgz", + "integrity": "sha512-pXK8ez/pVjqFdAgBkF1YPVRacuLQ9EXBKaKWaeh58WNfMkCmZhOZzu+NtKSPD5PHmCCHheQ5cD29qM1K4QTxIg==", + "license": "MIT", + "optional": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/jsdom/node_modules/escodegen": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", + "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", + "license": "BSD-2-Clause", + "optional": true, + "dependencies": { + "esprima": "^4.0.1", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "optionator": "^0.8.1" + }, + "bin": { + "escodegen": "bin/escodegen.js", + "esgenerate": "bin/esgenerate.js" + }, + "engines": { + "node": ">=4.0" + }, + "optionalDependencies": { + "source-map": "~0.6.1" + } + }, + "node_modules/jsdom/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "optional": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/jsdom/node_modules/parse5": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-1.5.1.tgz", + "integrity": "sha512-w2jx/0tJzvgKwZa58sj2vAYq/S/K1QJfIB3cWYea/Iu1scFPDQQ3IQiVZTHWtRBwAjv2Yd7S/xeZf3XqLDb3bA==", + "optional": true + }, + "node_modules/jsdom/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-2.0.1.tgz", + "integrity": "sha512-OZ7I/f0sM+T28T2/OXinNGfmvjm3KKptdyQy8NPRZyLfYBn+9vt72Bfr+uQaE9OvWyxJjQ5kHFygH2wOTUb76g==", + "license": "BSD-2-Clause", + "optional": true + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9362,12 +10310,26 @@ "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)", + "optional": true + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "license": "MIT" }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC", + "optional": true + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -9389,6 +10351,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -9416,6 +10394,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", + "license": "MIT", + "optional": true, + "dependencies": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lighthouse-logger": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/lighthouse-logger/-/lighthouse-logger-1.4.2.tgz", @@ -10766,6 +11758,23 @@ "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", "license": "MIT" }, + "node_modules/nwmatcher": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/nwmatcher/-/nwmatcher-1.4.4.tgz", + "integrity": "sha512-3iuY4N5dhgMpCUrOVnuAdGrgxVqV2cJpM+XNccjR2DKOB1RUP0aA+wGXEiNziG/UKboFyGBIoKOaNlJxx8bciQ==", + "license": "MIT", + "optional": true + }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": "*" + } + }, "node_modules/ob1": { "version": "0.81.4", "resolved": "https://registry.npmjs.org/ob1/-/ob1-0.81.4.tgz", @@ -10902,6 +11911,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", + "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", + "license": "MIT", + "optional": true, + "dependencies": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.6", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "word-wrap": "~1.2.3" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/ora": { "version": "3.4.0", "resolved": "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz", @@ -11302,6 +12329,13 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11495,6 +12529,15 @@ "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", "license": "MIT" }, + "node_modules/prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", + "optional": true, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -11542,6 +12585,12 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -11637,6 +12686,19 @@ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "license": "MIT" }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", @@ -12108,6 +13170,16 @@ "react-native": ">=0.60.5" } }, + "node_modules/react-native-is-edge-to-edge": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.1.7.tgz", + "integrity": "sha512-EH6i7E8epJGIcu7KpfXYXiV2JFIYITtq+rVS8uEb+92naMRBdxhTuS8Wn2Q7j9sqyO0B+Xbaaf9VdipIAmGW4w==", + "license": "MIT", + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native-modal": { "version": "14.0.0-rc.1", "resolved": "https://registry.npmjs.org/react-native-modal/-/react-native-modal-14.0.0-rc.1.tgz", @@ -12138,9 +13210,9 @@ } }, "node_modules/react-native-pager-view": { - "version": "6.7.0", - "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.7.0.tgz", - "integrity": "sha512-sutxKiMqBuQrEyt4mLaLNzy8taIC7IuYpxfcwQBXfSYBSSpAa0qE9G1FXlP/iXqTSlFgBXyK7BESsl9umOjECQ==", + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/react-native-pager-view/-/react-native-pager-view-6.8.1.tgz", + "integrity": "sha512-XIyVEMhwq7sZqM7GobOJZXxFCfdFgVNq/CFB2rZIRNRSVPJqE1k1fsc8xfQKfdzsp6Rpt6I7VOIvhmP7/YHdVg==", "license": "MIT", "peer": true, "peerDependencies": { @@ -12200,9 +13272,9 @@ } }, "node_modules/react-native-reanimated": { - "version": "3.16.7", - "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz", - "integrity": "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==", + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.18.0.tgz", + "integrity": "sha512-eVcNcqeOkMW+BUWAHdtvN3FKgC8J8wiEJkX6bNGGQaLS7m7e4amTfjIcqf/Ta+lerZLurmDaQ0lICI1CKPrb1Q==", "license": "MIT", "dependencies": { "@babel/plugin-transform-arrow-functions": "^7.0.0-0", @@ -12215,7 +13287,8 @@ "@babel/plugin-transform-unicode-regex": "^7.0.0-0", "@babel/preset-typescript": "^7.16.7", "convert-source-map": "^2.0.0", - "invariant": "^2.2.4" + "invariant": "^2.2.4", + "react-native-is-edge-to-edge": "1.1.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0", @@ -12247,6 +13320,13 @@ "react-native": "*" } }, + "node_modules/react-native-shared-element": { + "version": "0.8.9", + "resolved": "https://registry.npmjs.org/react-native-shared-element/-/react-native-shared-element-0.8.9.tgz", + "integrity": "sha512-vlzhv3amkJm+8gA0WSeLzcCKNtN/ypZbic3IZ4Bwwr6GeWDrYzZ6k7PdHCioy7fwIVOJ1X9Pi/aYF9HK4Kb0qg==", + "license": "MIT", + "peer": true + }, "node_modules/react-native-slider": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-native-slider/-/react-native-slider-0.11.0.tgz", @@ -12627,6 +13707,45 @@ "async-limiter": "~1.0.0" } }, + "node_modules/react-navigation-shared-element": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/react-navigation-shared-element/-/react-navigation-shared-element-3.1.3.tgz", + "integrity": "sha512-U1BZp7dEdcTNHggfkq3WEBlJeg4HwFhFdj7a0i0Uql/7mg2IHQg/bZaqM2jQvJITkABge6Hz5fZixIF8jyzpkg==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*", + "react-native-shared-element": "*" + } + }, + "node_modules/react-reconciler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.27.0.tgz", + "integrity": "sha512-HmMDKciQjYmBRGuuhIaKA1ba/7a+UsM5FzOZsMO2JYHt9Jh8reCb7j1eDC95NOyUlKM9KRyvdx0flBuDvYSBoA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.21.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "peerDependencies": { + "react": "^18.0.0" + } + }, + "node_modules/react-reconciler/node_modules/scheduler": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.21.0.tgz", + "integrity": "sha512-1r87x5fz9MXqswA2ERLo0EbOAU74DpIUO090gIasYTqlVoJeMcl+Z1Rg7WHz+qtPujhS/hGIt9kxZOYBV3faRQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -12636,6 +13755,27 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/readline": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", @@ -12705,15 +13845,6 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "license": "MIT" }, - "node_modules/regenerator-transform": { - "version": "0.15.2", - "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", - "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.8.4" - } - }, "node_modules/regexpu-core": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.2.0.tgz", @@ -12767,6 +13898,75 @@ "integrity": "sha512-o4S4Qh6L2jpnCy83ysZDau+VORNvnFw07CKSAymkd6ICNVEPisMyzlc00KlvvicsxKck94SEwhDnMNdICzO+tA==", "license": "MIT" }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, + "node_modules/request/node_modules/qs": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", + "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", + "license": "BSD-3-Clause", + "optional": true, + "engines": { + "node": ">=0.6" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "license": "MIT", + "optional": true, + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -13555,6 +14755,39 @@ "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", "license": "BSD-3-Clause" }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "license": "MIT", + "optional": true, + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sshpk/node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "license": "MIT", + "optional": true + }, "node_modules/ssri": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", @@ -13646,6 +14879,21 @@ "node": ">=4" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -13913,6 +15161,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "license": "MIT", + "optional": true + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -14248,6 +15503,20 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "license": "BSD-3-Clause", + "optional": true, + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -14272,6 +15541,39 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", + "license": "Unlicense", + "optional": true + }, + "node_modules/type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", + "license": "MIT", + "optional": true, + "dependencies": { + "prelude-ls": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -14547,6 +15849,12 @@ "which-typed-array": "^1.1.2" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -14583,6 +15891,28 @@ "node": ">= 0.8" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "license": "MIT", + "optional": true + }, "node_modules/vlq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/vlq/-/vlq-1.0.1.tgz", @@ -14665,6 +15995,16 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/whatwg-url-compat": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/whatwg-url-compat/-/whatwg-url-compat-0.6.5.tgz", + "integrity": "sha512-vbg5+JVNwGtHRI3GheZGWrcUlxF9BXHbA80dLa+2XqJjlV/BK6upoi2j8dIRW9FGPUUyaMm7Hf1pTexHnsk85g==", + "license": "MIT", + "optional": true, + "dependencies": { + "tr46": "~0.0.1" + } + }, "node_modules/whatwg-url-without-unicode": { "version": "8.0.0-3", "resolved": "https://registry.npmjs.org/whatwg-url-without-unicode/-/whatwg-url-without-unicode-8.0.0-3.tgz", @@ -14730,6 +16070,16 @@ "integrity": "sha512-SSil+ecw6B4/Dm7Pf2sAshKQ5hWFvfyGlfPbEd6A14dOH6VDjrmbY86u6nZvy9omGwwIPFR8V41+of1EezgoUw==", "license": "MIT" }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -14895,6 +16245,13 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/xml-name-validator": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-2.0.1.tgz", + "integrity": "sha512-jRKe/iQYMyVJpzPH+3HL97Lgu5HrCfii+qSo+TfjKHtOnvbnvdVfMYrn9Q34YV81M2e5sviJlI6Ko9y+nByzvA==", + "license": "WTFPL", + "optional": true + }, "node_modules/xml2js": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz", diff --git a/package.json b/package.json index 6aae894..ef32883 100644 --- a/package.json +++ b/package.json @@ -22,12 +22,14 @@ "@react-navigation/native": "^7.1.6", "@react-navigation/native-stack": "^7.3.10", "@react-navigation/stack": "^7.2.10", + "@sentry/react-native": "^6.15.1", "@shopify/flash-list": "1.7.3", + "@shopify/react-native-skia": "^1.12.4", "@types/lodash": "^4.17.16", "@types/react-native-video": "^5.0.20", "axios": "^1.10.0", "base64-js": "^1.5.1", - "cheerio": "^1.1.0", + "cheerio-without-node-native": "^0.20.2", "cors": "^2.8.5", "date-fns": "^4.1.0", "eventemitter3": "^5.0.1", @@ -59,7 +61,7 @@ "react-native-modal": "^14.0.0-rc.1", "react-native-orientation-locker": "^1.7.0", "react-native-paper": "^5.13.1", - "react-native-reanimated": "~3.16.1", + "react-native-reanimated": "^3.18.0", "react-native-safe-area-context": "4.12.0", "react-native-screens": "~4.4.0", "react-native-svg": "^15.11.2", @@ -69,6 +71,7 @@ "react-native-vlc-media-player": "^1.0.87", "react-native-web": "~0.19.13", "react-native-wheel-color-picker": "^1.3.1", + "react-navigation-shared-element": "^3.1.3", "subsrt": "^1.1.1" }, "devDependencies": { diff --git a/scripts/test-hdrezka.js b/scripts/test-hdrezka.js deleted file mode 100644 index 1d190aa..0000000 --- a/scripts/test-hdrezka.js +++ /dev/null @@ -1,434 +0,0 @@ -d// Test script for HDRezka service -// Run with: node scripts/test-hdrezka.js - -const fetch = require('node-fetch'); -const readline = require('readline'); - -// Constants -const REZKA_BASE = 'https://hdrezka.ag/'; -const BASE_HEADERS = { - 'X-Hdrezka-Android-App': '1', - 'X-Hdrezka-Android-App-Version': '2.2.0', -}; - -// Create readline interface for user input -const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout -}); - -// Function to prompt user for input -function prompt(question) { - return new Promise((resolve) => { - rl.question(question, (answer) => { - resolve(answer); - }); - }); -} - -// Helper functions -function generateRandomFavs() { - const randomHex = () => Math.floor(Math.random() * 16).toString(16); - const generateSegment = (length) => Array.from({ length }, randomHex).join(''); - - return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`; -} - -function extractTitleAndYear(input) { - const regex = /^(.*?),.*?(\d{4})/; - const match = input.match(regex); - - if (match) { - const title = match[1]; - const year = match[2]; - return { title: title.trim(), year: year ? parseInt(year, 10) : null }; - } - return null; -} - -function parseVideoLinks(inputString) { - if (!inputString) { - console.warn('No video links found'); - return {}; - } - - console.log(`[PARSE] Parsing video links from stream URL data`); - const linksArray = inputString.split(','); - const result = {}; - - linksArray.forEach((link) => { - // Handle different quality formats - let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/); - - // If not found, try HTML format with more flexible pattern - if (!match) { - const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/); - const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/); - - if (qualityMatch && urlMatch) { - match = [null, qualityMatch[1].trim(), urlMatch[1]]; - } - } - - if (match) { - const qualityText = match[1].trim(); - const mp4Url = match[2]; - - // Skip null URLs (premium content that requires login) - if (mp4Url !== 'null') { - result[qualityText] = { type: 'mp4', url: mp4Url }; - console.log(`[QUALITY] Found ${qualityText}: ${mp4Url}`); - } else { - console.log(`[QUALITY] Premium quality ${qualityText} requires login (null URL)`); - } - } else { - console.log(`[WARNING] Could not parse quality from: ${link}`); - } - }); - - console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`); - return result; -} - -function parseSubtitles(inputString) { - if (!inputString) { - console.log('[SUBTITLES] No subtitles found'); - return []; - } - - console.log(`[PARSE] Parsing subtitles data`); - const linksArray = inputString.split(','); - const captions = []; - - linksArray.forEach((link) => { - const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); - - if (match) { - const language = match[1]; - const url = match[2]; - - captions.push({ - id: url, - language, - hasCorsRestrictions: false, - type: 'vtt', - url: url, - }); - console.log(`[SUBTITLE] Found ${language}: ${url}`); - } - }); - - console.log(`[PARSE] Found ${captions.length} subtitles`); - return captions; -} - -// Main scraper functions -async function searchAndFindMediaId(media) { - console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`); - - const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g; - const idRegexPattern = /\/(\d+)-[^/]+\.html$/; - - const fullUrl = new URL('/engine/ajax/search.php', REZKA_BASE); - fullUrl.searchParams.append('q', media.title); - - console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`); - const response = await fetch(fullUrl.toString(), { - headers: BASE_HEADERS - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const searchData = await response.text(); - console.log(`[RESPONSE] Search response length: ${searchData.length}`); - - const movieData = []; - let match; - - while ((match = itemRegexPattern.exec(searchData)) !== null) { - const url = match[1]; - const titleAndYear = match[3]; - - const result = extractTitleAndYear(titleAndYear); - if (result !== null) { - const id = url.match(idRegexPattern)?.[1] || null; - const isMovie = url.includes('/films/'); - const isShow = url.includes('/series/'); - const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown'; - - movieData.push({ - id: id ?? '', - year: result.year ?? 0, - type, - url, - title: match[2] - }); - console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`); - } - } - - // If year is provided, filter by year - let filteredItems = movieData; - if (media.releaseYear) { - filteredItems = movieData.filter(item => item.year === media.releaseYear); - console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`); - } - - // If type is provided, filter by type - if (media.type) { - filteredItems = filteredItems.filter(item => item.type === media.type); - console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`); - } - - if (filteredItems.length === 0 && movieData.length > 0) { - console.log(`[WARNING] No items match the exact criteria. Showing all results:`); - movieData.forEach((item, index) => { - console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`); - }); - - // Let user select from results - const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): "); - const selectedIndex = parseInt(selection) - 1; - - if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) { - console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`); - return movieData[selectedIndex]; - } else if (movieData.length > 0) { - console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`); - return movieData[0]; - } - - return null; - } - - if (filteredItems.length > 0) { - console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`); - return filteredItems[0]; - } else { - console.log(`[ERROR] No matching items found`); - return null; - } -} - -async function getTranslatorId(url, id, media) { - console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`); - - // Make sure the URL is absolute - const fullUrl = url.startsWith('http') ? url : `${REZKA_BASE}${url.startsWith('/') ? url.substring(1) : url}`; - console.log(`[REQUEST] Making request to: ${fullUrl}`); - - const response = await fetch(fullUrl, { - headers: BASE_HEADERS, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const responseText = await response.text(); - console.log(`[RESPONSE] Translator page response length: ${responseText.length}`); - - // Translator ID 238 represents the Original + subtitles player. - if (responseText.includes(`data-translator_id="238"`)) { - console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`); - return '238'; - } - - const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents'; - const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i'); - const match = responseText.match(regexPattern); - const translatorId = match ? match[1] : null; - - console.log(`[RESULT] Extracted translator ID: ${translatorId}`); - return translatorId; -} - -async function getStream(id, translatorId, media) { - console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`); - - const searchParams = new URLSearchParams(); - searchParams.append('id', id); - searchParams.append('translator_id', translatorId); - - if (media.type === 'show') { - searchParams.append('season', media.season.number.toString()); - searchParams.append('episode', media.episode.number.toString()); - console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`); - } - - const randomFavs = generateRandomFavs(); - searchParams.append('favs', randomFavs); - searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie'); - - const fullUrl = `${REZKA_BASE}ajax/get_cdn_series/`; - console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`); - - const response = await fetch(fullUrl, { - method: 'POST', - body: searchParams, - headers: BASE_HEADERS, - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const responseText = await response.text(); - console.log(`[RESPONSE] Stream response length: ${responseText.length}`); - - // Response content-type is text/html, but it's actually JSON - try { - const parsedResponse = JSON.parse(responseText); - console.log(`[RESULT] Parsed response successfully`); - - // Process video qualities and subtitles - const qualities = parseVideoLinks(parsedResponse.url); - const captions = parseSubtitles(parsedResponse.subtitle); - - return { - qualities, - captions - }; - } catch (e) { - console.error(`[ERROR] Failed to parse JSON response: ${e.message}`); - return null; - } -} - -async function getStreams(mediaId, mediaType, season, episode) { - try { - console.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`); - - // Check if the mediaId appears to be an ID rather than a title - let title = mediaId; - let year; - - // If it's an ID format (starts with 'tt' for IMDB or contains ':' like TMDB IDs) - // For testing, we'll replace it with an example title instead of implementing full TMDB API calls - if (mediaId.startsWith('tt') || mediaId.includes(':')) { - console.log(`[HDRezka] ID format detected for "${mediaId}". Using title search instead.`); - - // For demo purposes only - you would actually get this from TMDB API in real implementation - if (mediaType === 'movie') { - title = "Inception"; // Example movie - year = 2010; - } else { - title = "Breaking Bad"; // Example show - year = 2008; - } - - console.log(`[HDRezka] Using title "${title}" (${year}) for search instead of ID`); - } - - const media = { - title, - type: mediaType === 'movie' ? 'movie' : 'show', - releaseYear: year - }; - - // Step 1: Search and find media ID - const searchResult = await searchAndFindMediaId(media); - if (!searchResult || !searchResult.id) { - console.log('[HDRezka] No search results found'); - return []; - } - - // Step 2: Get translator ID - const translatorId = await getTranslatorId( - searchResult.url, - searchResult.id, - media - ); - - if (!translatorId) { - console.log('[HDRezka] No translator ID found'); - return []; - } - - // Step 3: Get stream - const streamParams = { - type: media.type, - season: season ? { number: season } : undefined, - episode: episode ? { number: episode } : undefined - }; - - const streamData = await getStream(searchResult.id, translatorId, streamParams); - if (!streamData) { - console.log('[HDRezka] No stream data found'); - return []; - } - - // Convert to Stream format - const streams = []; - - Object.entries(streamData.qualities).forEach(([quality, data]) => { - streams.push({ - name: 'HDRezka', - title: quality, - url: data.url, - behaviorHints: { - notWebReady: false - } - }); - }); - - console.log(`[HDRezka] Found ${streams.length} streams`); - return streams; - } catch (error) { - console.error(`[HDRezka] Error getting streams: ${error}`); - return []; - } -} - -// Main execution -async function main() { - try { - console.log('=== HDREZKA SCRAPER TEST ==='); - - // Get user input interactively - const title = await prompt('Enter title to search: '); - const mediaType = await prompt('Enter media type (movie/show): ').then(type => - type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show' - ); - const releaseYear = await prompt('Enter release year (optional): ').then(year => - year ? parseInt(year) : null - ); - - // Create media object - let media = { - title, - type: mediaType, - releaseYear - }; - - let seasonNum, episodeNum; - - // If it's a show, get season and episode - if (mediaType === 'show') { - seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1); - episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1); - - console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${seasonNum}E${episodeNum}`); - } else { - console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`); - } - - const streams = await getStreams(title, mediaType, seasonNum, episodeNum); - - if (streams && streams.length > 0) { - console.log('✓ Found streams:'); - console.log(JSON.stringify(streams, null, 2)); - } else { - console.log('✗ No streams found'); - } - - } catch (error) { - console.error(`Error: ${error.message}`); - } finally { - rl.close(); - } -} - -main(); \ No newline at end of file diff --git a/src/components/FirstTimeWelcome.tsx b/src/components/FirstTimeWelcome.tsx new file mode 100644 index 0000000..f5edee5 --- /dev/null +++ b/src/components/FirstTimeWelcome.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Dimensions, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { FadeInDown } from 'react-native-reanimated'; +import { useTheme } from '../contexts/ThemeContext'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { RootStackParamList } from '../navigation/AppNavigator'; + +const { width } = Dimensions.get('window'); + +const FirstTimeWelcome = () => { + const { currentTheme } = useTheme(); + const navigation = useNavigation<NavigationProp<RootStackParamList>>(); + + return ( + <Animated.View + entering={FadeInDown.delay(200).duration(600)} + style={[styles.container, { backgroundColor: currentTheme.colors.elevation1 }]} + > + <LinearGradient + colors={[currentTheme.colors.primary, currentTheme.colors.secondary]} + style={styles.iconContainer} + start={{ x: 0, y: 0 }} + end={{ x: 1, y: 1 }} + > + <MaterialIcons name="explore" size={40} color="white" /> + </LinearGradient> + + <Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}> + Welcome to Nuvio! + </Text> + + <Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}> + To get started, install some addons to access content from various sources. + </Text> + + <TouchableOpacity + style={[styles.button, { backgroundColor: currentTheme.colors.primary }]} + onPress={() => navigation.navigate('Addons')} + > + <MaterialIcons name="extension" size={20} color="white" /> + <Text style={styles.buttonText}>Install Addons</Text> + </TouchableOpacity> + </Animated.View> + ); +}; + +const styles = StyleSheet.create({ + container: { + margin: 16, + padding: 24, + borderRadius: 16, + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 2, + }, + shadowOpacity: 0.1, + shadowRadius: 8, + elevation: 4, + }, + iconContainer: { + width: 80, + height: 80, + borderRadius: 40, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 16, + }, + title: { + fontSize: 20, + fontWeight: 'bold', + marginBottom: 8, + textAlign: 'center', + }, + description: { + fontSize: 14, + textAlign: 'center', + lineHeight: 20, + marginBottom: 20, + maxWidth: width * 0.7, + }, + button: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 25, + gap: 8, + }, + buttonText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, +}); + +export default FirstTimeWelcome; \ No newline at end of file diff --git a/src/components/common/OptimizedImage.tsx b/src/components/common/OptimizedImage.tsx new file mode 100644 index 0000000..d8c50cc --- /dev/null +++ b/src/components/common/OptimizedImage.tsx @@ -0,0 +1,195 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { View, StyleSheet, Dimensions } from 'react-native'; +import { Image as ExpoImage } from 'expo-image'; +import { imageCacheService } from '../../services/imageCacheService'; +import { logger } from '../../utils/logger'; + +interface OptimizedImageProps { + source: { uri: string } | string; + style?: any; + placeholder?: string; + priority?: 'low' | 'normal' | 'high'; + lazy?: boolean; + onLoad?: () => void; + onError?: (error: any) => void; + contentFit?: 'cover' | 'contain' | 'fill' | 'scale-down'; + transition?: number; + cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none'; +} + +const { width: screenWidth } = Dimensions.get('window'); + +// Image size optimization based on container size +const getOptimizedImageUrl = (originalUrl: string, containerWidth?: number, containerHeight?: number): string => { + if (!originalUrl || originalUrl.includes('placeholder')) { + return originalUrl; + } + + // For TMDB images, we can request specific sizes + if (originalUrl.includes('image.tmdb.org')) { + const width = containerWidth || 300; + let size = 'w300'; + + if (width <= 92) size = 'w92'; + else if (width <= 154) size = 'w154'; + else if (width <= 185) size = 'w185'; + else if (width <= 342) size = 'w342'; + else if (width <= 500) size = 'w500'; + else if (width <= 780) size = 'w780'; + else size = 'w1280'; + + // Replace the size in the URL + return originalUrl.replace(/\/w\d+\//, `/${size}/`); + } + + // For other image services, add query parameters if supported + if (originalUrl.includes('?')) { + return `${originalUrl}&w=${containerWidth || 300}&h=${containerHeight || 450}&q=80`; + } else { + return `${originalUrl}?w=${containerWidth || 300}&h=${containerHeight || 450}&q=80`; + } +}; + +const OptimizedImage: React.FC<OptimizedImageProps> = ({ + source, + style, + placeholder = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=Loading', + priority = 'normal', + lazy = true, + onLoad, + onError, + contentFit = 'cover', + transition = 200, + cachePolicy = 'memory-disk' +}) => { + const [isLoaded, setIsLoaded] = useState(false); + const [hasError, setHasError] = useState(false); + const [isVisible, setIsVisible] = useState(!lazy); + const [optimizedUrl, setOptimizedUrl] = useState<string>(''); + const mountedRef = useRef(true); + const loadTimeoutRef = useRef<NodeJS.Timeout | null>(null); + + // Extract URL from source + const sourceUrl = typeof source === 'string' ? source : source?.uri || ''; + + // Calculate container dimensions from style + const containerWidth = style?.width || (style?.aspectRatio ? screenWidth * 0.3 : 300); + const containerHeight = style?.height || (containerWidth / (style?.aspectRatio || 0.67)); + + useEffect(() => { + return () => { + mountedRef.current = false; + if (loadTimeoutRef.current) { + clearTimeout(loadTimeoutRef.current); + } + }; + }, []); + + // Optimize image URL based on container size + useEffect(() => { + if (sourceUrl) { + const optimized = getOptimizedImageUrl(sourceUrl, containerWidth, containerHeight); + setOptimizedUrl(optimized); + } + }, [sourceUrl, containerWidth, containerHeight]); + + // Lazy loading intersection observer simulation + useEffect(() => { + if (lazy && !isVisible) { + // Simple lazy loading - load after a short delay to simulate intersection + const timer = setTimeout(() => { + if (mountedRef.current) { + setIsVisible(true); + } + }, priority === 'high' ? 100 : priority === 'normal' ? 300 : 500); + + return () => clearTimeout(timer); + } + }, [lazy, isVisible, priority]); + + // Preload image with caching + const preloadImage = useCallback(async () => { + if (!optimizedUrl || !isVisible) return; + + try { + // Use our cache service to manage the image + const cachedUrl = await imageCacheService.getCachedImageUrl(optimizedUrl); + + // Set a timeout for loading + loadTimeoutRef.current = setTimeout(() => { + if (mountedRef.current && !isLoaded) { + logger.warn(`[OptimizedImage] Load timeout for: ${optimizedUrl.substring(0, 50)}...`); + setHasError(true); + } + }, 10000); // 10 second timeout + + // Prefetch the image + await ExpoImage.prefetch(cachedUrl); + + if (mountedRef.current) { + setIsLoaded(true); + if (loadTimeoutRef.current) { + clearTimeout(loadTimeoutRef.current); + loadTimeoutRef.current = null; + } + onLoad?.(); + } + } catch (error) { + if (mountedRef.current) { + logger.error(`[OptimizedImage] Failed to load: ${optimizedUrl.substring(0, 50)}...`, error); + setHasError(true); + onError?.(error); + } + } + }, [optimizedUrl, isVisible, isLoaded, onLoad, onError]); + + useEffect(() => { + if (isVisible && optimizedUrl && !isLoaded && !hasError) { + preloadImage(); + } + }, [isVisible, optimizedUrl, isLoaded, hasError, preloadImage]); + + // Don't render anything if not visible (lazy loading) + if (!isVisible) { + return <View style={[style, styles.placeholder]} />; + } + + // Show placeholder while loading or on error + if (!isLoaded || hasError) { + return ( + <ExpoImage + source={{ uri: placeholder }} + style={style} + contentFit={contentFit} + transition={0} + cachePolicy="memory" + /> + ); + } + + return ( + <ExpoImage + source={{ uri: optimizedUrl }} + style={style} + contentFit={contentFit} + transition={transition} + cachePolicy={cachePolicy} + onLoad={() => { + setIsLoaded(true); + onLoad?.(); + }} + onError={(error) => { + setHasError(true); + onError?.(error); + }} + /> + ); +}; + +const styles = StyleSheet.create({ + placeholder: { + backgroundColor: '#1a1a1a', + }, +}); + +export default OptimizedImage; \ No newline at end of file diff --git a/src/components/common/TraktLoadingSpinner.tsx b/src/components/common/TraktLoadingSpinner.tsx new file mode 100644 index 0000000..7e85c6b --- /dev/null +++ b/src/components/common/TraktLoadingSpinner.tsx @@ -0,0 +1,43 @@ +import React, { useEffect, useRef } from 'react'; +import { View, StyleSheet, Animated, Easing } from 'react-native'; +import TraktIcon from '../../../assets/rating-icons/trakt.svg'; + +export const TraktLoadingSpinner = () => { + const spinValue = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const spin = Animated.loop( + Animated.timing(spinValue, { + toValue: 1, + duration: 1500, + easing: Easing.linear, + useNativeDriver: true, + }) + ); + spin.start(); + return () => spin.stop(); + }, [spinValue]); + + const rotation = spinValue.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); + + return ( + <View style={styles.container}> + <Animated.View style={{ transform: [{ rotate: rotation }] }}> + <TraktIcon width={80} height={80} /> + </Animated.View> + </View> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, + transform: [{ translateY: -60 }], + }, +}); \ No newline at end of file diff --git a/src/components/discover/CatalogSection.tsx b/src/components/discover/CatalogSection.tsx deleted file mode 100644 index 68b0251..0000000 --- a/src/components/discover/CatalogSection.tsx +++ /dev/null @@ -1,132 +0,0 @@ -import React, { useCallback, useMemo } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, FlatList, Dimensions } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { useNavigation } from '@react-navigation/native'; -import { NavigationProp } from '@react-navigation/native'; -import { useTheme } from '../../contexts/ThemeContext'; -import { GenreCatalog, Category } from '../../constants/discover'; -import { StreamingContent } from '../../services/catalogService'; -import { RootStackParamList } from '../../navigation/AppNavigator'; -import ContentItem from './ContentItem'; - -interface CatalogSectionProps { - catalog: GenreCatalog; - selectedCategory: Category; -} - -const CatalogSection = ({ catalog, selectedCategory }: CatalogSectionProps) => { - const navigation = useNavigation<NavigationProp<RootStackParamList>>(); - const { currentTheme } = useTheme(); - const { width } = Dimensions.get('window'); - const itemWidth = (width - 48) / 2.2; // 2 items per row with spacing - - // Only display first 3 items in each section - const displayItems = useMemo(() => - catalog.items.slice(0, 3), - [catalog.items] - ); - - const handleContentPress = useCallback((item: StreamingContent) => { - navigation.navigate('Metadata', { id: item.id, type: item.type }); - }, [navigation]); - - const handleSeeMorePress = useCallback(() => { - navigation.navigate('Catalog', { - id: catalog.genre, - type: selectedCategory.type, - name: `${catalog.genre} ${selectedCategory.name}`, - genreFilter: catalog.genre - }); - }, [navigation, selectedCategory, catalog.genre]); - - const renderItem = useCallback(({ item }: { item: StreamingContent }) => ( - <ContentItem - item={item} - onPress={() => handleContentPress(item)} - width={itemWidth} - /> - ), [handleContentPress, itemWidth]); - - const keyExtractor = useCallback((item: StreamingContent) => item.id, []); - - const ItemSeparator = useCallback(() => ( - <View style={{ width: 16 }} /> - ), []); - - return ( - <View style={styles.container}> - <View style={styles.header}> - <View style={styles.titleContainer}> - <Text style={[styles.title, { color: currentTheme.colors.white }]}> - {catalog.genre} - </Text> - <View style={[styles.titleBar, { backgroundColor: currentTheme.colors.primary }]} /> - </View> - <TouchableOpacity - onPress={handleSeeMorePress} - style={styles.seeAllButton} - activeOpacity={0.6} - > - <Text style={[styles.seeAllText, { color: currentTheme.colors.primary }]}>See All</Text> - <MaterialIcons name="arrow-forward-ios" color={currentTheme.colors.primary} size={14} /> - </TouchableOpacity> - </View> - - <FlatList - data={displayItems} - renderItem={renderItem} - keyExtractor={keyExtractor} - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={{ paddingHorizontal: 16 }} - snapToInterval={itemWidth + 16} - decelerationRate="fast" - snapToAlignment="start" - ItemSeparatorComponent={ItemSeparator} - initialNumToRender={3} - maxToRenderPerBatch={3} - windowSize={3} - removeClippedSubviews={true} - /> - </View> - ); -}; - -const styles = StyleSheet.create({ - container: { - marginBottom: 32, - }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingHorizontal: 20, - marginBottom: 16, - }, - titleContainer: { - flexDirection: 'column', - }, - titleBar: { - width: 32, - height: 3, - marginTop: 6, - borderRadius: 2, - }, - title: { - fontSize: 20, - fontWeight: '700', - }, - seeAllButton: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingVertical: 6, - paddingHorizontal: 4, - }, - seeAllText: { - fontWeight: '600', - fontSize: 14, - }, -}); - -export default React.memo(CatalogSection); \ No newline at end of file diff --git a/src/components/discover/CatalogsList.tsx b/src/components/discover/CatalogsList.tsx deleted file mode 100644 index 6a8bcdf..0000000 --- a/src/components/discover/CatalogsList.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { useCallback } from 'react'; -import { FlatList, StyleSheet, Platform } from 'react-native'; -import { GenreCatalog, Category } from '../../constants/discover'; -import CatalogSection from './CatalogSection'; - -interface CatalogsListProps { - catalogs: GenreCatalog[]; - selectedCategory: Category; -} - -const CatalogsList = ({ catalogs, selectedCategory }: CatalogsListProps) => { - const renderCatalogItem = useCallback(({ item }: { item: GenreCatalog }) => ( - <CatalogSection - catalog={item} - selectedCategory={selectedCategory} - /> - ), [selectedCategory]); - - // Memoize list key extractor - const catalogKeyExtractor = useCallback((item: GenreCatalog) => item.genre, []); - - return ( - <FlatList - data={catalogs} - renderItem={renderCatalogItem} - keyExtractor={catalogKeyExtractor} - contentContainerStyle={styles.container} - showsVerticalScrollIndicator={false} - initialNumToRender={3} - maxToRenderPerBatch={3} - windowSize={5} - removeClippedSubviews={Platform.OS === 'android'} - /> - ); -}; - -const styles = StyleSheet.create({ - container: { - paddingVertical: 8, - paddingBottom: 90, - }, -}); - -export default React.memo(CatalogsList); \ No newline at end of file diff --git a/src/components/discover/CategorySelector.tsx b/src/components/discover/CategorySelector.tsx deleted file mode 100644 index c090e8a..0000000 --- a/src/components/discover/CategorySelector.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import React, { useCallback } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet } from 'react-native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { useTheme } from '../../contexts/ThemeContext'; -import { Category } from '../../constants/discover'; - -interface CategorySelectorProps { - categories: Category[]; - selectedCategory: Category; - onSelectCategory: (category: Category) => void; -} - -const CategorySelector = ({ - categories, - selectedCategory, - onSelectCategory -}: CategorySelectorProps) => { - const { currentTheme } = useTheme(); - - const renderCategoryButton = useCallback((category: Category) => { - const isSelected = selectedCategory.id === category.id; - - return ( - <TouchableOpacity - key={category.id} - style={[ - styles.categoryButton, - isSelected && { backgroundColor: currentTheme.colors.primary } - ]} - onPress={() => onSelectCategory(category)} - activeOpacity={0.7} - > - <MaterialIcons - name={category.icon} - size={24} - color={isSelected ? currentTheme.colors.white : currentTheme.colors.mediumGray} - /> - <Text - style={[ - styles.categoryText, - isSelected && { color: currentTheme.colors.white, fontWeight: '700' } - ]} - > - {category.name} - </Text> - </TouchableOpacity> - ); - }, [selectedCategory, onSelectCategory, currentTheme]); - - return ( - <View style={styles.container}> - <View style={styles.content}> - {categories.map(renderCategoryButton)} - </View> - </View> - ); -}; - -const styles = StyleSheet.create({ - container: { - paddingVertical: 20, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255,255,255,0.05)', - }, - content: { - flexDirection: 'row', - justifyContent: 'center', - paddingHorizontal: 20, - gap: 16, - }, - categoryButton: { - paddingHorizontal: 20, - paddingVertical: 14, - borderRadius: 24, - backgroundColor: 'rgba(255,255,255,0.05)', - flexDirection: 'row', - alignItems: 'center', - gap: 10, - flex: 1, - maxWidth: 160, - justifyContent: 'center', - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.15, - shadowRadius: 8, - elevation: 4, - }, - categoryText: { - color: '#9e9e9e', // Default medium gray - fontWeight: '600', - fontSize: 16, - }, -}); - -export default React.memo(CategorySelector); \ No newline at end of file diff --git a/src/components/discover/ContentItem.tsx b/src/components/discover/ContentItem.tsx deleted file mode 100644 index de22a61..0000000 --- a/src/components/discover/ContentItem.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import React from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, Dimensions } from 'react-native'; -import { Image } from 'expo-image'; -import { LinearGradient } from 'expo-linear-gradient'; -import { useTheme } from '../../contexts/ThemeContext'; -import { StreamingContent } from '../../services/catalogService'; - -interface ContentItemProps { - item: StreamingContent; - onPress: () => void; - width?: number; -} - -const ContentItem = ({ item, onPress, width }: ContentItemProps) => { - const { width: screenWidth } = Dimensions.get('window'); - const { currentTheme } = useTheme(); - const itemWidth = width || (screenWidth - 48) / 2.2; // Default to 2 items per row with spacing - - return ( - <TouchableOpacity - style={[styles.container, { width: itemWidth }]} - onPress={onPress} - activeOpacity={0.6} - > - <View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}> - <Image - source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} - style={styles.poster} - contentFit="cover" - cachePolicy="memory-disk" - transition={300} - /> - <LinearGradient - colors={['transparent', 'rgba(0,0,0,0.85)']} - style={styles.gradient} - > - <Text style={[styles.title, { color: currentTheme.colors.white }]} numberOfLines={2}> - {item.name} - </Text> - {item.year && ( - <Text style={styles.year}>{item.year}</Text> - )} - </LinearGradient> - </View> - </TouchableOpacity> - ); -}; - -const styles = StyleSheet.create({ - container: { - marginHorizontal: 0, - }, - posterContainer: { - borderRadius: 8, - overflow: 'hidden', - backgroundColor: 'rgba(255,255,255,0.03)', - elevation: 5, - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - }, - poster: { - aspectRatio: 2/3, - width: '100%', - }, - gradient: { - position: 'absolute', - bottom: 0, - left: 0, - right: 0, - padding: 16, - justifyContent: 'flex-end', - height: '45%', - }, - title: { - fontSize: 15, - fontWeight: '700', - marginBottom: 4, - textShadowColor: 'rgba(0, 0, 0, 0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - letterSpacing: 0.3, - }, - year: { - fontSize: 12, - color: 'rgba(255,255,255,0.7)', - textShadowColor: 'rgba(0, 0, 0, 0.75)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }, -}); - -export default React.memo(ContentItem); \ No newline at end of file diff --git a/src/components/discover/GenreSelector.tsx b/src/components/discover/GenreSelector.tsx deleted file mode 100644 index 7cc4df0..0000000 --- a/src/components/discover/GenreSelector.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useCallback } from 'react'; -import { View, Text, TouchableOpacity, StyleSheet, ScrollView } from 'react-native'; -import { useTheme } from '../../contexts/ThemeContext'; - -interface GenreSelectorProps { - genres: string[]; - selectedGenre: string; - onSelectGenre: (genre: string) => void; -} - -const GenreSelector = ({ - genres, - selectedGenre, - onSelectGenre -}: GenreSelectorProps) => { - const { currentTheme } = useTheme(); - - const renderGenreButton = useCallback((genre: string) => { - const isSelected = selectedGenre === genre; - - return ( - <TouchableOpacity - key={genre} - style={[ - styles.genreButton, - isSelected && { backgroundColor: currentTheme.colors.primary } - ]} - onPress={() => onSelectGenre(genre)} - activeOpacity={0.7} - > - <Text - style={[ - styles.genreText, - isSelected && { color: currentTheme.colors.white, fontWeight: '600' } - ]} - > - {genre} - </Text> - </TouchableOpacity> - ); - }, [selectedGenre, onSelectGenre, currentTheme]); - - return ( - <View style={styles.container}> - <ScrollView - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.scrollViewContent} - decelerationRate="fast" - snapToInterval={10} - > - {genres.map(renderGenreButton)} - </ScrollView> - </View> - ); -}; - -const styles = StyleSheet.create({ - container: { - paddingTop: 20, - paddingBottom: 12, - zIndex: 10, - }, - scrollViewContent: { - paddingHorizontal: 20, - paddingBottom: 8, - }, - genreButton: { - paddingHorizontal: 18, - paddingVertical: 10, - marginRight: 12, - borderRadius: 20, - backgroundColor: 'rgba(255,255,255,0.05)', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 2, - overflow: 'hidden', - }, - genreText: { - color: '#9e9e9e', // Default medium gray - fontWeight: '500', - fontSize: 14, - }, -}); - -export default React.memo(GenreSelector); \ No newline at end of file diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx index 1a2987d..b7edc37 100644 --- a/src/components/home/CatalogSection.tsx +++ b/src/components/home/CatalogSection.tsx @@ -36,11 +36,8 @@ const calculatePosterLayout = (screenWidth: number) => { const usableWidth = availableWidth - 8; const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); - console.log(`[CatalogSection] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); - if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { bestLayout = { numFullPosters: n, posterWidth }; - console.log(`[CatalogSection] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); } } @@ -79,17 +76,12 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { return ( <Animated.View style={styles.catalogContainer} - entering={FadeIn.duration(400).delay(50)} + entering={FadeIn.duration(300).delay(50)} > <View style={styles.catalogHeader}> <View style={styles.titleContainer}> - <Text style={[styles.catalogTitle, { color: currentTheme.colors.highEmphasis }]}>{catalog.name}</Text> - <LinearGradient - colors={[currentTheme.colors.primary, currentTheme.colors.secondary]} - start={{ x: 0, y: 0 }} - end={{ x: 1, y: 0 }} - style={styles.titleUnderline} - /> + <Text style={[styles.catalogTitle, { color: currentTheme.colors.text }]} numberOfLines={1}>{catalog.name}</Text> + <View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} /> </View> <TouchableOpacity onPress={() => @@ -99,10 +91,10 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { addonId: catalog.addon }) } - style={styles.seeAllButton} + style={styles.viewAllButton} > - <Text style={[styles.seeAllText, { color: currentTheme.colors.primary }]}>See More</Text> - <MaterialIcons name="arrow-forward" color={currentTheme.colors.primary} size={16} /> + <Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text> + <MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} /> </TouchableOpacity> </View> @@ -117,15 +109,20 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { decelerationRate="fast" snapToAlignment="start" ItemSeparatorComponent={() => <View style={{ width: 8 }} />} - initialNumToRender={4} - maxToRenderPerBatch={4} - windowSize={5} + initialNumToRender={3} + maxToRenderPerBatch={2} + windowSize={3} removeClippedSubviews={Platform.OS === 'android'} + updateCellsBatchingPeriod={50} getItemLayout={(data, index) => ({ length: POSTER_WIDTH + 8, offset: (POSTER_WIDTH + 8) * index, index, })} + maintainVisibleContentPosition={{ + minIndexForVisible: 0 + }} + onEndReachedThreshold={1} /> </Animated.View> ); @@ -133,45 +130,51 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => { const styles = StyleSheet.create({ catalogContainer: { - marginBottom: 24, + marginBottom: 28, }, catalogHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, - marginBottom: 12, + marginBottom: 16, }, titleContainer: { position: 'relative', + flex: 1, + marginRight: 16, }, catalogTitle: { - fontSize: 19, - fontWeight: '700', - letterSpacing: 0.2, + fontSize: 24, + fontWeight: '800', + letterSpacing: 0.5, marginBottom: 4, }, titleUnderline: { position: 'absolute', bottom: -2, left: 0, - width: 35, - height: 2, - borderRadius: 1, + width: 40, + height: 3, + borderRadius: 2, opacity: 0.8, }, - seeAllButton: { + viewAllButton: { flexDirection: 'row', alignItems: 'center', - gap: 4, + paddingVertical: 8, + paddingHorizontal: 10, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.1)', }, - seeAllText: { + viewAllText: { fontSize: 14, fontWeight: '600', + marginRight: 4, }, catalogList: { paddingHorizontal: 16, }, }); -export default CatalogSection; \ No newline at end of file +export default React.memo(CatalogSection); \ No newline at end of file diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx index 860a301..1c7728c 100644 --- a/src/components/home/ContentItem.tsx +++ b/src/components/home/ContentItem.tsx @@ -1,10 +1,10 @@ import React, { useState, useEffect, useCallback } from 'react'; -import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; import { catalogService, StreamingContent } from '../../services/catalogService'; -import DropUpMenu from './DropUpMenu'; +import { DropUpMenu } from './DropUpMenu'; interface ContentItemProps { item: StreamingContent; @@ -34,11 +34,8 @@ const calculatePosterLayout = (screenWidth: number) => { const usableWidth = availableWidth - 8; const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); - console.log(`[ContentItem] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); - if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { bestLayout = { numFullPosters: n, posterWidth }; - console.log(`[ContentItem] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); } } @@ -53,12 +50,9 @@ const calculatePosterLayout = (screenWidth: number) => { const posterLayout = calculatePosterLayout(width); const POSTER_WIDTH = posterLayout.posterWidth; -const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { +const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => { const [menuVisible, setMenuVisible] = useState(false); - const [localItem, setLocalItem] = useState(initialItem); const [isWatched, setIsWatched] = useState(false); - const [imageLoaded, setImageLoaded] = useState(false); - const [imageError, setImageError] = useState(false); const { currentTheme } = useTheme(); const handleLongPress = useCallback(() => { @@ -66,16 +60,16 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { }, []); const handlePress = useCallback(() => { - onPress(localItem.id, localItem.type); - }, [localItem.id, localItem.type, onPress]); + onPress(item.id, item.type); + }, [item.id, item.type, onPress]); const handleOptionSelect = useCallback((option: string) => { switch (option) { case 'library': - if (localItem.inLibrary) { - catalogService.removeFromLibrary(localItem.type, localItem.id); + if (item.inLibrary) { + catalogService.removeFromLibrary(item.type, item.id); } else { - catalogService.addToLibrary(localItem); + catalogService.addToLibrary(item); } break; case 'watched': @@ -86,87 +80,64 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { case 'share': break; } - }, [localItem]); + }, [item]); const handleMenuClose = useCallback(() => { setMenuVisible(false); }, []); - useEffect(() => { - setLocalItem(initialItem); - }, [initialItem]); - - useEffect(() => { - const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => { - const isInLibrary = libraryItems.some( - libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type - ); - setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary })); - }); - - return () => unsubscribe(); - }, [localItem.id, localItem.type]); - return ( <> - <TouchableOpacity - style={styles.contentItem} - activeOpacity={0.7} - onPress={handlePress} - onLongPress={handleLongPress} - delayLongPress={300} - > - <View style={styles.contentItemContainer}> - <ExpoImage - source={{ uri: localItem.poster }} - style={styles.poster} - contentFit="cover" - transition={300} - cachePolicy="memory-disk" - recyclingKey={`poster-${localItem.id}`} - onLoadStart={() => { - setImageLoaded(false); - setImageError(false); - }} - onLoadEnd={() => setImageLoaded(true)} - onError={() => { - setImageError(true); - setImageLoaded(true); - }} - /> - {(!imageLoaded || imageError) && ( - <View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation2 }]}> - {!imageError ? ( - <ActivityIndicator color={currentTheme.colors.primary} size="small" /> - ) : ( - <MaterialIcons name="broken-image" size={24} color={currentTheme.colors.lightGray} /> - )} - </View> - )} - {isWatched && ( - <View style={styles.watchedIndicator}> - <MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} /> - </View> - )} - {localItem.inLibrary && ( - <View style={styles.libraryBadge}> - <MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} /> - </View> - )} - </View> - </TouchableOpacity> + <View style={styles.itemContainer}> + <TouchableOpacity + style={styles.contentItem} + activeOpacity={0.7} + onPress={handlePress} + onLongPress={handleLongPress} + delayLongPress={300} + > + <View style={styles.contentItemContainer}> + <ExpoImage + source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} + style={styles.poster} + contentFit="cover" + cachePolicy="memory" + transition={200} + placeholder={{ uri: 'https://via.placeholder.com/300x450' }} + placeholderContentFit="cover" + recyclingKey={item.id} + /> + {isWatched && ( + <View style={styles.watchedIndicator}> + <MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} /> + </View> + )} + {item.inLibrary && ( + <View style={styles.libraryBadge}> + <MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} /> + </View> + )} + </View> + </TouchableOpacity> + <Text style={[styles.title, { color: currentTheme.colors.text }]} numberOfLines={2}> + {item.name} + </Text> + </View> <DropUpMenu visible={menuVisible} onClose={handleMenuClose} - item={localItem} + item={item} onOptionSelect={handleOptionSelect} /> </> ); -}; +}); const styles = StyleSheet.create({ + itemContainer: { + width: POSTER_WIDTH, + }, contentItem: { width: POSTER_WIDTH, aspectRatio: 2/3, @@ -181,6 +152,7 @@ const styles = StyleSheet.create({ shadowRadius: 6, borderWidth: 0.5, borderColor: 'rgba(255,255,255,0.12)', + marginBottom: 8, }, contentItemContainer: { width: '100%', @@ -218,6 +190,13 @@ const styles = StyleSheet.create({ borderRadius: 8, padding: 4, }, + title: { + fontSize: 14, + fontWeight: '500', + marginTop: 4, + textAlign: 'center', + fontFamily: 'SpaceMono-Regular', + } }); export default ContentItem; \ No newline at end of file diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 7aeda06..9ed120f 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -7,9 +7,11 @@ import { TouchableOpacity, Dimensions, AppState, - AppStateStatus + AppStateStatus, + Alert, + ActivityIndicator } from 'react-native'; -import Animated, { FadeIn } from 'react-native-reanimated'; +import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -19,6 +21,9 @@ import { Image as ExpoImage } from 'expo-image'; import { useTheme } from '../../contexts/ThemeContext'; import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; +import * as Haptics from 'expo-haptics'; +import { TraktService } from '../../services/traktService'; +import { stremioService } from '../../services/stremioService'; // Define interface for continue watching items interface ContinueWatchingItem extends StreamingContent { @@ -68,6 +73,20 @@ const isValidImdbId = (id: string): boolean => { return imdbPattern.test(id); }; +// Function to check if an episode has been released +const isEpisodeReleased = (video: any): boolean => { + if (!video.released) return false; + + try { + const releaseDate = new Date(video.released); + const now = new Date(); + return releaseDate <= now; + } catch (error) { + // If we can't parse the date, assume it's not released + return false; + } +}; + // Create a proper imperative handle with React.forwardRef and updated type const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => { const navigation = useNavigation<NavigationProp<RootStackParamList>>(); @@ -76,11 +95,55 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re const [loading, setLoading] = useState(true); const appState = useRef(AppState.currentState); const refreshTimerRef = useRef<NodeJS.Timeout | null>(null); + const [deletingItemId, setDeletingItemId] = useState<string | null>(null); + const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null); + + // Use a state to track if a background refresh is in progress + const [isRefreshing, setIsRefreshing] = useState(false); + + // Cache for metadata to avoid redundant API calls + const metadataCache = useRef<Record<string, { metadata: any; basicContent: StreamingContent | null; timestamp: number }>>({}); + const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + + // Helper function to get cached or fetch metadata + const getCachedMetadata = useCallback(async (type: string, id: string) => { + const cacheKey = `${type}:${id}`; + const cached = metadataCache.current[cacheKey]; + const now = Date.now(); + + if (cached && (now - cached.timestamp) < CACHE_DURATION) { + return cached; + } + + try { + const [metadata, basicContent] = await Promise.all([ + stremioService.getMetaDetails(type, id), + catalogService.getBasicContentDetails(type, id) + ]); + + if (basicContent) { + const result = { metadata, basicContent, timestamp: now }; + metadataCache.current[cacheKey] = result; + return result; + } + return null; + } catch (error) { + logger.error(`Failed to fetch metadata for ${type}:${id}:`, error); + return null; + } + }, []); // Modified loadContinueWatching to be more efficient - const loadContinueWatching = useCallback(async () => { - try { + const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { + // Prevent multiple concurrent refreshes + if (isRefreshing) return; + + if (!isBackgroundRefresh) { setLoading(true); + } + setIsRefreshing(true); + + try { const allProgress = await storageService.getAllWatchProgress(); if (Object.keys(allProgress).length === 0) { @@ -90,96 +153,253 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re const progressItems: ContinueWatchingItem[] = []; const latestEpisodes: Record<string, ContinueWatchingItem> = {}; - const contentPromises: Promise<void>[] = []; - // Process each saved progress + // Group progress items by content ID to batch API calls + const contentGroups: Record<string, { type: string; id: string; episodes: Array<{ key: string; episodeId?: string; progress: any; progressPercent: number }> }> = {}; + + // First pass: group by content ID for (const key in allProgress) { - // Parse the key to get type and id const keyParts = key.split(':'); const [type, id, ...episodeIdParts] = keyParts; const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined; const progress = allProgress[key]; - - // Skip items that are more than 85% complete (effectively finished) const progressPercent = (progress.currentTime / progress.duration) * 100; - if (progressPercent >= 85) { + // Skip fully watched movies + if (type === 'movie' && progressPercent >= 85) { continue; } - const contentPromise = (async () => { - try { - // Validate IMDB ID format before attempting to fetch - if (!isValidImdbId(id)) { - return; - } + const contentKey = `${type}:${id}`; + if (!contentGroups[contentKey]) { + contentGroups[contentKey] = { type, id, episodes: [] }; + } + + contentGroups[contentKey].episodes.push({ key, episodeId, progress, progressPercent }); + } + + // Second pass: process each content group with batched API calls + const contentPromises = Object.values(contentGroups).map(async (group) => { + try { + // Validate IMDB ID format before attempting to fetch + if (!isValidImdbId(group.id)) { + return; + } + + // Get metadata once per content + const cachedData = await getCachedMetadata(group.type, group.id); + if (!cachedData?.basicContent) { + return; + } + + const { metadata, basicContent } = cachedData; + + // Process all episodes for this content + for (const episode of group.episodes) { + const { key, episodeId, progress, progressPercent } = episode; - let content: StreamingContent | null = null; - - // Get basic content details using catalogService (no enhanced metadata needed for continue watching) - content = await catalogService.getBasicContentDetails(type, id); - - if (content) { - // Extract season and episode info from episodeId if available - let season: number | undefined; - let episode: number | undefined; - let episodeTitle: string | undefined; - - if (episodeId && type === 'series') { - // Try different episode ID formats - let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1 + if (group.type === 'series' && progressPercent >= 85) { + // Handle next episode logic for completed episodes + let nextSeason: number | undefined; + let nextEpisode: number | undefined; + + if (episodeId) { + // Pattern 1: s1e1 + const match = episodeId.match(/s(\d+)e(\d+)/i); if (match) { - season = parseInt(match[1], 10); - episode = parseInt(match[2], 10); - episodeTitle = `Episode ${episode}`; + const currentSeason = parseInt(match[1], 10); + const currentEpisode = parseInt(match[2], 10); + nextSeason = currentSeason; + nextEpisode = currentEpisode + 1; } else { - // Try format: seriesId:season:episode (e.g., tt0108778:4:6) + // Pattern 2: id:season:episode const parts = episodeId.split(':'); - if (parts.length >= 3) { - const seasonPart = parts[parts.length - 2]; // Second to last part - const episodePart = parts[parts.length - 1]; // Last part - - const seasonNum = parseInt(seasonPart, 10); - const episodeNum = parseInt(episodePart, 10); - + if (parts.length >= 2) { + const seasonNum = parseInt(parts[parts.length - 2], 10); + const episodeNum = parseInt(parts[parts.length - 1], 10); if (!isNaN(seasonNum) && !isNaN(episodeNum)) { - season = seasonNum; - episode = episodeNum; - episodeTitle = `Episode ${episode}`; + nextSeason = seasonNum; + nextEpisode = episodeNum + 1; } } } } - - const continueWatchingItem: ContinueWatchingItem = { - ...content, - progress: progressPercent, - lastUpdated: progress.lastUpdated, - season, - episode, - episodeTitle - }; - - if (type === 'series') { - // For series, keep only the latest watched episode for each show - if (!latestEpisodes[id] || latestEpisodes[id].lastUpdated < progress.lastUpdated) { - latestEpisodes[id] = continueWatchingItem; + + // Check if next episode exists and has been released using cached metadata + if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) { + const nextEpisodeVideo = metadata.videos.find((video: any) => + video.season === nextSeason && video.episode === nextEpisode + ); + + if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { + const nextEpisodeItem = { + ...basicContent, + id: group.id, + type: group.type, + progress: 0, + lastUpdated: progress.lastUpdated, + season: nextSeason, + episode: nextEpisode, + episodeTitle: `Episode ${nextEpisode}`, + } as ContinueWatchingItem; + + // Store in latestEpisodes to ensure single entry per show + const existingLatest = latestEpisodes[group.id]; + if (!existingLatest || existingLatest.lastUpdated < nextEpisodeItem.lastUpdated) { + latestEpisodes[group.id] = nextEpisodeItem; + } } + } + continue; + } + + // Handle in-progress episodes + let season: number | undefined; + let episodeNumber: number | undefined; + let episodeTitle: string | undefined; + + if (episodeId && group.type === 'series') { + // Try different episode ID formats + let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1 + if (match) { + season = parseInt(match[1], 10); + episodeNumber = parseInt(match[2], 10); + episodeTitle = `Episode ${episodeNumber}`; } else { - // For movies, add to the list directly - progressItems.push(continueWatchingItem); + // Try format: seriesId:season:episode (e.g., tt0108778:4:6) + const parts = episodeId.split(':'); + if (parts.length >= 3) { + const seasonPart = parts[parts.length - 2]; // Second to last part + const episodePart = parts[parts.length - 1]; // Last part + + const seasonNum = parseInt(seasonPart, 10); + const episodeNum = parseInt(episodePart, 10); + + if (!isNaN(seasonNum) && !isNaN(episodeNum)) { + season = seasonNum; + episodeNumber = episodeNum; + episodeTitle = `Episode ${episodeNumber}`; + } + } } } - } catch (error) { - logger.error(`Failed to get content details for ${type}:${id}`, error); + + const continueWatchingItem: ContinueWatchingItem = { + ...basicContent, + progress: progressPercent, + lastUpdated: progress.lastUpdated, + season, + episode: episodeNumber, + episodeTitle + }; + + if (group.type === 'series') { + // For series, keep only the latest watched episode for each show + if (!latestEpisodes[group.id] || latestEpisodes[group.id].lastUpdated < progress.lastUpdated) { + latestEpisodes[group.id] = continueWatchingItem; + } + } else { + // For movies, add to the list directly + progressItems.push(continueWatchingItem); + } } - })(); - - contentPromises.push(contentPromise); - } + } catch (error) { + logger.error(`Failed to process content group ${group.type}:${group.id}:`, error); + } + }); // Wait for all content to be processed - await Promise.all(contentPromises); + await Promise.all(contentPromises); + + // -------------------- TRAKT HISTORY INTEGRATION -------------------- + try { + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (isAuthed) { + const historyItems = await traktService.getWatchedEpisodesHistory(1, 200); + const latestWatchedByShow: Record<string, { season: number; episode: number; watchedAt: number }> = {}; + + for (const item of historyItems) { + if (item.type !== 'episode') continue; + const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null; + if (!showImdb) continue; + + const season = item.episode?.season; + const epNum = item.episode?.number; + if (season === undefined || epNum === undefined) continue; + const watchedAt = new Date(item.watched_at).getTime(); + + const existing = latestWatchedByShow[showImdb]; + if (!existing || existing.watchedAt < watchedAt) { + latestWatchedByShow[showImdb] = { season, episode: epNum, watchedAt }; + } + } + + // Process Trakt shows in batches using cached metadata + const traktPromises = Object.entries(latestWatchedByShow).map(async ([showId, info]) => { + try { + const nextEpisode = info.episode + 1; + + // Use cached metadata to validate next episode exists + const cachedData = await getCachedMetadata('series', showId); + if (!cachedData?.basicContent) return; + + const { metadata, basicContent } = cachedData; + let nextEpisodeVideo = null; + + if (metadata?.videos && Array.isArray(metadata.videos)) { + nextEpisodeVideo = metadata.videos.find((video: any) => + video.season === info.season && video.episode === nextEpisode + ); + } + + if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) { + const placeholder: ContinueWatchingItem = { + ...basicContent, + id: showId, + type: 'series', + progress: 0, + lastUpdated: info.watchedAt, + season: info.season, + episode: nextEpisode, + episodeTitle: `Episode ${nextEpisode}`, + } as ContinueWatchingItem; + + const existing = latestEpisodes[showId]; + if (!existing || existing.lastUpdated < info.watchedAt) { + latestEpisodes[showId] = placeholder; + } + } + + // Persist "watched" progress for the episode that Trakt reported + const watchedEpisodeId = `${showId}:${info.season}:${info.episode}`; + const existingProgress = allProgress[`series:${showId}:${watchedEpisodeId}`]; + const existingPercent = existingProgress ? (existingProgress.currentTime / existingProgress.duration) * 100 : 0; + + if (!existingProgress || existingPercent < 85) { + await storageService.setWatchProgress( + showId, + 'series', + { + currentTime: 1, + duration: 1, + lastUpdated: info.watchedAt, + traktSynced: true, + traktProgress: 100, + } as any, + `${info.season}:${info.episode}` + ); + } + } catch (err) { + logger.error('Failed to build placeholder from history:', err); + } + }); + + await Promise.all(traktPromises); + } + } catch (err) { + logger.error('Error merging Trakt history:', err); + } // Add the latest episodes for each series to the items list progressItems.push(...Object.values(latestEpisodes)); @@ -187,15 +407,21 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re // Sort by last updated time (most recent first) progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated); - // Limit to 10 items - const finalItems = progressItems.slice(0, 10); - - setContinueWatchingItems(finalItems); + // Show all continue watching items (no limit) + setContinueWatchingItems(progressItems); } catch (error) { logger.error('Failed to load continue watching items:', error); } finally { setLoading(false); + setIsRefreshing(false); } + }, [isRefreshing, getCachedMetadata]); + + // Clear cache when component unmounts or when needed + useEffect(() => { + return () => { + metadataCache.current = {}; + }; }, []); // Function to handle app state changes @@ -204,8 +430,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re appState.current.match(/inactive|background/) && nextAppState === 'active' ) { - // App has come to the foreground - refresh data - loadContinueWatching(); + // App has come to the foreground - trigger a background refresh + loadContinueWatching(true); } appState.current = nextAppState; }, [loadContinueWatching]); @@ -222,8 +448,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re clearTimeout(refreshTimerRef.current); } refreshTimerRef.current = setTimeout(() => { - loadContinueWatching(); - }, 300); + // Trigger a background refresh + loadContinueWatching(true); + }, 500); // Increased debounce time slightly }; // Try to set up a custom event listener or use a timer as fallback @@ -235,16 +462,22 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); } + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } }; } else { - // Fallback: poll for updates every 30 seconds - const intervalId = setInterval(loadContinueWatching, 30000); + // Reduced polling frequency from 30s to 2 minutes to reduce heating + const intervalId = setInterval(() => loadContinueWatching(true), 120000); return () => { subscription.remove(); clearInterval(intervalId); if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current); } + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } }; } }, [loadContinueWatching, handleAppStateChange]); @@ -254,13 +487,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re loadContinueWatching(); }, [loadContinueWatching]); - // Properly expose the refresh method + // Expose the refresh function via the ref React.useImperativeHandle(ref, () => ({ refresh: async () => { - await loadContinueWatching(); - // Return whether there are items to help parent determine visibility - const hasItems = continueWatchingItems.length > 0; - return hasItems; + // Allow manual refresh to show loading indicator + await loadContinueWatching(false); + return true; } })); @@ -268,22 +500,83 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re navigation.navigate('Metadata', { id, type }); }, [navigation]); + // Handle long press to delete + const handleLongPress = useCallback((item: ContinueWatchingItem) => { + try { + // Trigger haptic feedback + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium); + } catch (error) { + // Ignore haptic errors + } + + // Show confirmation alert + Alert.alert( + "Remove from Continue Watching", + `Remove "${item.name}" from your continue watching list?`, + [ + { + text: "Cancel", + style: "cancel" + }, + { + text: "Remove", + style: "destructive", + onPress: async () => { + setDeletingItemId(item.id); + try { + // Trigger haptic feedback for confirmation + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + + // Remove the watch progress + await storageService.removeWatchProgress( + item.id, + item.type, + item.type === 'series' && item.season && item.episode + ? `${item.season}:${item.episode}` + : undefined + ); + + // Also remove from Trakt playback queue if authenticated + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (isAuthed) { + await traktService.deletePlaybackForContent( + item.id, + item.type as 'movie' | 'series', + item.season, + item.episode + ); + } + + // Update the list by filtering out the deleted item + setContinueWatchingItems(prev => + prev.filter(i => i.id !== item.id || + (i.type === 'series' && item.type === 'series' && + (i.season !== item.season || i.episode !== item.episode)) + ) + ); + } catch (error) { + logger.error('Failed to remove watch progress:', error); + } finally { + setDeletingItemId(null); + } + } + } + ] + ); + }, []); + // If no continue watching items, don't render anything if (continueWatchingItems.length === 0) { return null; } return ( - <Animated.View entering={FadeIn.duration(400).delay(250)} style={styles.container}> + <Animated.View entering={FadeIn.duration(300).delay(150)} style={styles.container}> <View style={styles.header}> <View style={styles.titleContainer}> - <Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text> - <LinearGradient - colors={[currentTheme.colors.primary, currentTheme.colors.secondary]} - start={{ x: 0, y: 0 }} - end={{ x: 1, y: 0 }} - style={styles.titleUnderline} - /> + <Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text> + <View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} /> </View> </View> @@ -298,30 +591,55 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re }]} activeOpacity={0.8} onPress={() => handleContentPress(item.id, item.type)} + onLongPress={() => handleLongPress(item)} + delayLongPress={800} > {/* Poster Image */} <View style={styles.posterContainer}> <ExpoImage - source={{ uri: item.poster }} - style={styles.widePoster} + source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }} + style={styles.continueWatchingPoster} contentFit="cover" + cachePolicy="memory" transition={200} - cachePolicy="memory-disk" + placeholder={{ uri: 'https://via.placeholder.com/300x450' }} + placeholderContentFit="cover" + recyclingKey={item.id} /> + + {/* Delete Indicator Overlay */} + {deletingItemId === item.id && ( + <Animated.View + entering={FadeIn.duration(200)} + exiting={FadeOut.duration(200)} + style={styles.deletingOverlay} + > + <ActivityIndicator size="large" color="#FFFFFF" /> + </Animated.View> + )} </View> {/* Content Details */} <View style={styles.contentDetails}> <View style={styles.titleRow}> - <Text - style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]} - numberOfLines={1} - > - {item.name} - </Text> - <View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}> - <Text style={styles.progressText}>{Math.round(item.progress)}%</Text> - </View> + {(() => { + const isUpNext = item.progress === 0; + return ( + <View style={styles.titleRow}> + <Text + style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]} + numberOfLines={1} + > + {item.name} + </Text> + {isUpNext && ( + <View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}> + <Text style={styles.progressText}>Up Next</Text> + </View> + )} + </View> + ); + })()} </View> {/* Episode Info or Year */} @@ -352,22 +670,24 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re })()} {/* Progress Bar */} - <View style={styles.wideProgressContainer}> - <View style={styles.wideProgressTrack}> - <View - style={[ - styles.wideProgressBar, - { - width: `${item.progress}%`, - backgroundColor: currentTheme.colors.primary - } - ]} - /> + {item.progress > 0 && ( + <View style={styles.wideProgressContainer}> + <View style={styles.wideProgressTrack}> + <View + style={[ + styles.wideProgressBar, + { + width: `${item.progress}%`, + backgroundColor: currentTheme.colors.primary + } + ]} + /> + </View> + <Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}> + {Math.round(item.progress)}% watched + </Text> </View> - <Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}> - {Math.round(item.progress)}% watched - </Text> - </View> + )} </View> </TouchableOpacity> )} @@ -386,7 +706,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re const styles = StyleSheet.create({ container: { - marginBottom: 24, + marginBottom: 28, paddingTop: 0, marginTop: 12, }, @@ -395,15 +715,15 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, - marginBottom: 12, + marginBottom: 16, }, titleContainer: { position: 'relative', }, title: { - fontSize: 20, - fontWeight: '700', - letterSpacing: 0.3, + fontSize: 24, + fontWeight: '800', + letterSpacing: 0.5, marginBottom: 4, }, titleUnderline: { @@ -411,8 +731,8 @@ const styles = StyleSheet.create({ bottom: -2, left: 0, width: 40, - height: 2, - borderRadius: 1, + height: 3, + borderRadius: 2, opacity: 0.8, }, wideList: { @@ -435,13 +755,26 @@ const styles = StyleSheet.create({ posterContainer: { width: 80, height: '100%', + position: 'relative', }, - widePoster: { + continueWatchingPoster: { width: '100%', height: '100%', borderTopLeftRadius: 12, borderBottomLeftRadius: 12, }, + deletingOverlay: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0,0,0,0.7)', + justifyContent: 'center', + alignItems: 'center', + borderTopLeftRadius: 12, + borderBottomLeftRadius: 12, + }, contentDetails: { flex: 1, padding: 12, @@ -560,4 +893,4 @@ const styles = StyleSheet.create({ }, }); -export default React.memo(ContinueWatchingSection); \ No newline at end of file +export default React.memo(ContinueWatchingSection); \ No newline at end of file diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index 89389d8..8a6dc27 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -17,10 +17,10 @@ import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; -import Animated, { - FadeIn, - useAnimatedStyle, - useSharedValue, +import Animated, { + FadeIn, + useAnimatedStyle, + useSharedValue, withTiming, Easing, withDelay @@ -32,6 +32,7 @@ import { useSettings } from '../../hooks/useSettings'; import { TMDBService } from '../../services/tmdbService'; import { logger } from '../../utils/logger'; import { useTheme } from '../../contexts/ThemeContext'; +import { imageCacheService } from '../../services/imageCacheService'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -44,6 +45,80 @@ const imageCache: Record<string, boolean> = {}; const { width, height } = Dimensions.get('window'); +const NoFeaturedContent = () => { + const navigation = useNavigation<NavigationProp<RootStackParamList>>(); + const { currentTheme } = useTheme(); + + const styles = StyleSheet.create({ + noContentContainer: { + height: height * 0.55, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + backgroundColor: currentTheme.colors.elevation1, + borderRadius: 12, + marginBottom: 12, + }, + noContentTitle: { + fontSize: 22, + fontWeight: 'bold', + color: currentTheme.colors.highEmphasis, + marginTop: 16, + marginBottom: 8, + textAlign: 'center', + }, + noContentText: { + fontSize: 16, + color: currentTheme.colors.mediumEmphasis, + textAlign: 'center', + marginBottom: 24, + }, + noContentButtons: { + flexDirection: 'row', + justifyContent: 'center', + gap: 16, + width: '100%', + }, + noContentButton: { + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 30, + backgroundColor: currentTheme.colors.elevation3, + alignItems: 'center', + justifyContent: 'center' + }, + noContentButtonText: { + color: currentTheme.colors.highEmphasis, + fontWeight: '600', + fontSize: 14, + } + }); + + return ( + <View style={styles.noContentContainer}> + <MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} /> + <Text style={styles.noContentTitle}>No Featured Content</Text> + <Text style={styles.noContentText}> + Install addons with catalogs or change the content source in your settings. + </Text> + <View style={styles.noContentButtons}> + <TouchableOpacity + style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]} + onPress={() => navigation.navigate('Addons')} + > + <Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text> + </TouchableOpacity> + <TouchableOpacity + style={styles.noContentButton} + onPress={() => navigation.navigate('HomeScreenSettings')} + > + <Text style={styles.noContentButtonText}>Settings</Text> + </TouchableOpacity> + </View> + </View> + ); +}; + const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: FeaturedContentProps) => { const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const { currentTheme } = useTheme(); @@ -63,12 +138,12 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const [logoLoadError, setLogoLoadError] = useState(false); // Add a ref to track logo fetch in progress const logoFetchInProgress = useRef<boolean>(false); - + // Enhanced poster transition animations const posterScale = useSharedValue(1); const posterTranslateY = useSharedValue(0); const overlayOpacity = useSharedValue(0.15); - + // Animation values const posterAnimatedStyle = useAnimatedStyle(() => ({ opacity: posterOpacity.value, @@ -77,14 +152,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat { translateY: posterTranslateY.value } ], })); - + const logoAnimatedStyle = useAnimatedStyle(() => ({ opacity: logoOpacity.value, })); - + const contentOpacity = useSharedValue(1); // Start visible const buttonsOpacity = useSharedValue(1); - + const contentAnimatedStyle = useAnimatedStyle(() => ({ opacity: contentOpacity.value, })); @@ -99,28 +174,20 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat // Preload the image const preloadImage = async (url: string): Promise<boolean> => { - if (!url) return false; + // Skip if already cached to prevent redundant prefetch if (imageCache[url]) return true; - + try { - // For Metahub logos, only do validation if enabled - // Note: Temporarily disable metahub validation until fixed - if (false && url.includes('metahub.space')) { - try { - const isValid = await isValidMetahubLogo(url); - if (!isValid) { - return false; - } - } catch (validationError) { - // If validation fails, still try to load the image - } - } - - // Always attempt to prefetch the image regardless of format validation - await ExpoImage.prefetch(url); + // Simplified validation to reduce CPU overhead + if (!url || typeof url !== 'string') return false; + + // Use our optimized cache service instead of direct prefetch + await imageCacheService.getCachedImageUrl(url); imageCache[url] = true; return true; } catch (error) { + // Clear any partial cache entry on error + delete imageCache[url]; return false; } }; @@ -129,27 +196,27 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat useEffect(() => { setLogoLoadError(false); }, [featuredContent?.id]); - + // Fetch logo based on preference useEffect(() => { if (!featuredContent || logoFetchInProgress.current) return; - + const fetchLogo = async () => { logoFetchInProgress.current = true; - + try { const contentId = featuredContent.id; const contentData = featuredContent; // Use a clearer variable name const currentLogo = contentData.logo; - + // Get preferences const logoPreference = settings.logoSourcePreference || 'metahub'; const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - + // Reset state for new fetch setLogoUrl(null); setLogoLoadError(false); - + // Extract IDs let imdbId: string | null = null; if (contentData.id.startsWith('tt')) { @@ -159,14 +226,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } else if ((contentData as any).externalIds?.imdb_id) { imdbId = (contentData as any).externalIds.imdb_id; } - + let tmdbId: string | null = null; if (contentData.id.startsWith('tmdb:')) { tmdbId = contentData.id.split(':')[1]; } else if ((contentData as any).tmdb_id) { - tmdbId = String((contentData as any).tmdb_id); + tmdbId = String((contentData as any).tmdb_id); } - + // If we only have IMDB ID, try to find TMDB ID proactively if (imdbId && !tmdbId) { try { @@ -179,14 +246,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat // logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError); } } - + const tmdbType = contentData.type === 'series' ? 'tv' : 'movie'; let finalLogoUrl: string | null = null; let primaryAttempted = false; let fallbackAttempted = false; - + // --- Logo Fetching Logic --- - + if (logoPreference === 'metahub') { // Primary: Metahub (needs imdbId) if (imdbId) { @@ -199,7 +266,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } catch (error) { /* Log if needed */ } } - + // Fallback: TMDB (needs tmdbId) if (!finalLogoUrl && tmdbId) { fallbackAttempted = true; @@ -211,7 +278,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } catch (error) { /* Log if needed */ } } - + } else { // logoPreference === 'tmdb' // Primary: TMDB (needs tmdbId) if (tmdbId) { @@ -224,7 +291,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } catch (error) { /* Log if needed */ } } - + // Fallback: Metahub (needs imdbId) if (!finalLogoUrl && imdbId) { fallbackAttempted = true; @@ -237,7 +304,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } catch (error) { /* Log if needed */ } } } - + // --- Set Final Logo --- if (finalLogoUrl) { setLogoUrl(finalLogoUrl); @@ -249,7 +316,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat setLogoLoadError(true); // logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`); } - + } catch (error) { // logger.error('[FeaturedContent] Error in fetchLogo:', error); setLogoLoadError(true); @@ -257,7 +324,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat logoFetchInProgress.current = false; } }; - + // Trigger fetch when content changes fetchLogo(); }, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]); @@ -265,11 +332,11 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat // Load poster and logo useEffect(() => { if (!featuredContent) return; - + const posterUrl = featuredContent.banner || featuredContent.poster; const contentId = featuredContent.id; const isContentChange = contentId !== prevContentIdRef.current; - + // Enhanced content change detection and animations if (isContentChange) { // Animate out current content @@ -304,17 +371,17 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } logoOpacity.value = 0; } - + prevContentIdRef.current = contentId; - + // Set poster URL for immediate display if (posterUrl) setBannerUrl(posterUrl); - + // Load images with enhanced animations const loadImages = async () => { // Small delay to allow fade out animation to complete await new Promise(resolve => setTimeout(resolve, isContentChange && prevContentIdRef.current ? 300 : 0)); - + // Load poster with enhanced transition if (posterUrl) { const posterSuccess = await preloadImage(posterUrl); @@ -332,7 +399,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat duration: 600, easing: Easing.out(Easing.cubic) }); - + // Animate content back in with delay contentOpacity.value = withDelay(200, withTiming(1, { duration: 600, @@ -344,7 +411,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat })); } } - + // Load logo if available with enhanced timing if (logoUrl) { const logoSuccess = await preloadImage(logoUrl); @@ -355,23 +422,36 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat })); } else { setLogoLoadError(true); - console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`); } } }; - + loadImages(); }, [featuredContent?.id, logoUrl]); + const onLogoLoadError = () => { + setLogoLoaded(true); // Treat error as "loaded" to stop spinner + setLogoError(true); + }; + + const handleInfoPress = () => { + if (featuredContent) { + navigation.navigate('Metadata', { + id: featuredContent.id, + type: featuredContent.type + }); + } + }; + if (!featuredContent) { - return <SkeletonFeatured />; + return <NoFeaturedContent />; } return ( <Animated.View - entering={FadeIn.duration(800).easing(Easing.out(Easing.cubic))} + entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))} > - <TouchableOpacity + <TouchableOpacity activeOpacity={0.95} onPress={() => { navigation.navigate('Metadata', { @@ -389,7 +469,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat > {/* Subtle content overlay for better readability */} <Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} /> - + <LinearGradient colors={[ 'rgba(0,0,0,0.1)', @@ -401,21 +481,19 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat locations={[0, 0.2, 0.5, 0.8, 1]} style={styles.featuredGradient as ViewStyle} > - <Animated.View + <Animated.View style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]} > {logoUrl && !logoLoadError ? ( <Animated.View style={logoAnimatedStyle}> - <ExpoImage - source={{ uri: logoUrl }} + <ExpoImage + source={{ uri: logoUrl }} style={styles.featuredLogo as ImageStyle} contentFit="contain" - cachePolicy="memory-disk" - transition={400} - onError={() => { - console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`); - setLogoLoadError(true); - }} + cachePolicy="memory" + transition={300} + recyclingKey={`logo-${featuredContent.id}`} + onError={onLogoLoadError} /> </Animated.View> ) : ( @@ -438,27 +516,27 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat </Animated.View> <Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}> - <TouchableOpacity + <TouchableOpacity style={styles.myListButton as ViewStyle} onPress={handleSaveToLibrary} activeOpacity={0.7} > - <MaterialIcons - name={isSaved ? "bookmark" : "bookmark-border"} - size={24} - color={currentTheme.colors.white} + <MaterialIcons + name={isSaved ? "bookmark" : "bookmark-border"} + size={24} + color={currentTheme.colors.white} /> <Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}> {isSaved ? "Saved" : "Save"} </Text> </TouchableOpacity> - - <TouchableOpacity + + <TouchableOpacity style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]} onPress={() => { if (featuredContent) { - navigation.navigate('Streams', { - id: featuredContent.id, + navigation.navigate('Streams', { + id: featuredContent.id, type: featuredContent.type }); } @@ -471,16 +549,9 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat </Text> </TouchableOpacity> - <TouchableOpacity + <TouchableOpacity style={styles.infoButton as ViewStyle} - onPress={() => { - if (featuredContent) { - navigation.navigate('Metadata', { - id: featuredContent.id, - type: featuredContent.type - }); - } - }} + onPress={handleInfoPress} activeOpacity={0.7} > <MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} /> @@ -655,4 +726,4 @@ const styles = StyleSheet.create({ }, }); -export default FeaturedContent; \ No newline at end of file +export default React.memo(FeaturedContent); \ No newline at end of file diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx index 30ebfe4..892ffff 100644 --- a/src/components/home/ThisWeekSection.tsx +++ b/src/components/home/ThisWeekSection.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { View, Text, @@ -14,17 +14,18 @@ import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../../contexts/ThemeContext'; +import { useTraktContext } from '../../contexts/TraktContext'; import { stremioService } from '../../services/stremioService'; import { tmdbService } from '../../services/tmdbService'; import { useLibrary } from '../../hooks/useLibrary'; import { RootStackParamList } from '../../navigation/AppNavigator'; import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns'; import Animated, { FadeIn, FadeInRight } from 'react-native-reanimated'; -import { catalogService } from '../../services/catalogService'; +import { useCalendarData } from '../../hooks/useCalendarData'; const { width } = Dimensions.get('window'); -const ITEM_WIDTH = width * 0.85; -const ITEM_HEIGHT = 180; +const ITEM_WIDTH = width * 0.75; // Reduced width for better spacing +const ITEM_HEIGHT = 180; // Compact height for cleaner design interface ThisWeekEpisode { id: string; @@ -42,108 +43,29 @@ interface ThisWeekEpisode { season_poster_path: string | null; } -export const ThisWeekSection = () => { +export const ThisWeekSection = React.memo(() => { const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const { libraryItems, loading: libraryLoading } = useLibrary(); - const [episodes, setEpisodes] = useState<ThisWeekEpisode[]>([]); - const [loading, setLoading] = useState(true); + const { + isAuthenticated: traktAuthenticated, + isLoading: traktLoading, + watchedShows, + watchlistShows, + continueWatching, + loadAllCollections + } = useTraktContext(); const { currentTheme } = useTheme(); + const { calendarData, loading } = useCalendarData(); - const fetchThisWeekEpisodes = useCallback(async () => { - if (libraryItems.length === 0) { - setLoading(false); - return; - } + const thisWeekEpisodes = useMemo(() => { + const thisWeekSection = calendarData.find(section => section.title === 'This Week'); + if (!thisWeekSection) return []; - setLoading(true); - - try { - const seriesItems = libraryItems.filter(item => item.type === 'series'); - let allEpisodes: ThisWeekEpisode[] = []; - - for (const series of seriesItems) { - try { - const metadata = await stremioService.getMetaDetails(series.type, series.id); - - if (metadata?.videos) { - // Get TMDB ID for additional metadata - const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id); - let tmdbEpisodes: { [key: string]: any } = {}; - - if (tmdbId) { - const allTMDBEpisodes = await tmdbService.getAllEpisodes(tmdbId); - // Flatten episodes into a map for easy lookup - Object.values(allTMDBEpisodes).forEach(seasonEpisodes => { - seasonEpisodes.forEach(episode => { - const key = `${episode.season_number}:${episode.episode_number}`; - tmdbEpisodes[key] = episode; - }); - }); - } - - const thisWeekEpisodes = metadata.videos - .filter(video => { - if (!video.released) return false; - const releaseDate = parseISO(video.released); - return isThisWeek(releaseDate); - }) - .map(video => { - const releaseDate = parseISO(video.released); - const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {}; - - return { - id: video.id, - seriesId: series.id, - seriesName: series.name || metadata.name, - title: tmdbEpisode.name || video.title || `Episode ${video.episode}`, - poster: series.poster || metadata.poster || '', - releaseDate: video.released, - season: video.season || 0, - episode: video.episode || 0, - isReleased: isBefore(releaseDate, new Date()), - overview: tmdbEpisode.overview || '', - vote_average: tmdbEpisode.vote_average || 0, - still_path: tmdbEpisode.still_path || null, - season_poster_path: tmdbEpisode.season_poster_path || null - }; - }); - - allEpisodes = [...allEpisodes, ...thisWeekEpisodes]; - } - } catch (error) { - console.error(`Error fetching episodes for ${series.name}:`, error); - } - } - - // Sort episodes by release date - allEpisodes.sort((a, b) => { - return new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime(); - }); - - setEpisodes(allEpisodes); - } catch (error) { - console.error('Error fetching this week episodes:', error); - } finally { - setLoading(false); - } - }, [libraryItems]); - - // Subscribe to library updates - useEffect(() => { - const unsubscribe = catalogService.subscribeToLibraryUpdates(() => { - console.log('[ThisWeekSection] Library updated, refreshing episodes'); - fetchThisWeekEpisodes(); - }); - - return () => unsubscribe(); - }, [fetchThisWeekEpisodes]); - - // Initial load - useEffect(() => { - if (!libraryLoading) { - fetchThisWeekEpisodes(); - } - }, [libraryLoading, fetchThisWeekEpisodes]); + return thisWeekSection.data.map(episode => ({ + ...episode, + isReleased: isBefore(parseISO(episode.releaseDate), new Date()), + })); + }, [calendarData]); const handleEpisodePress = (episode: ThisWeekEpisode) => { // For upcoming episodes, go to the metadata screen @@ -170,15 +92,7 @@ export const ThisWeekSection = () => { navigation.navigate('Calendar' as any); }; - if (loading) { - return ( - <View style={styles.loadingContainer}> - <ActivityIndicator size="small" color={currentTheme.colors.primary} /> - </View> - ); - } - - if (episodes.length === 0) { + if (thisWeekEpisodes.length === 0) { return null; } @@ -196,72 +110,72 @@ export const ThisWeekSection = () => { return ( <Animated.View - entering={FadeInRight.delay(index * 100).duration(400)} + entering={FadeInRight.delay(index * 50).duration(300)} style={styles.episodeItemContainer} > <TouchableOpacity - style={styles.episodeItem} + style={[ + styles.episodeItem, + { + shadowColor: currentTheme.colors.black, + backgroundColor: currentTheme.colors.background, + } + ]} onPress={() => handleEpisodePress(item)} - activeOpacity={0.7} + activeOpacity={0.8} > + <View style={styles.imageContainer}> <Image source={{ uri: imageUrl }} style={styles.poster} contentFit="cover" - transition={300} + transition={400} /> + {/* Enhanced gradient overlay */} <LinearGradient - colors={['transparent', 'rgba(0,0,0,0.8)', 'rgba(0,0,0,0.9)']} + colors={[ + 'transparent', + 'transparent', + 'rgba(0,0,0,0.4)', + 'rgba(0,0,0,0.8)', + 'rgba(0,0,0,0.95)' + ]} style={styles.gradient} - > - <View style={styles.badgeContainer}> - <View style={[ - styles.badge, - isReleased ? styles.releasedBadge : styles.upcomingBadge, - { backgroundColor: isReleased ? currentTheme.colors.success + 'CC' : currentTheme.colors.primary + 'CC' } - ]}> - <MaterialIcons - name={isReleased ? "check-circle" : "event"} - size={12} - color={currentTheme.colors.white} - /> - <Text style={[styles.badgeText, { color: currentTheme.colors.white }]}> - {isReleased ? 'Released' : 'Coming Soon'} + locations={[0, 0.4, 0.6, 0.8, 1]} + > + {/* Content area */} + <View style={styles.contentArea}> + <Text style={[styles.seriesName, { color: currentTheme.colors.white }]} numberOfLines={1}> + {item.seriesName} + </Text> + + <Text style={[styles.episodeTitle, { color: 'rgba(255,255,255,0.9)' }]} numberOfLines={2}> + {item.title} </Text> - </View> - {item.vote_average > 0 && ( - <View style={[styles.ratingBadge, { backgroundColor: 'rgba(0,0,0,0.8)' }]}> + {item.overview && ( + <Text style={[styles.overview, { color: 'rgba(255,255,255,0.8)' }]} numberOfLines={2}> + {item.overview} + </Text> + )} + + <View style={styles.dateContainer}> + <Text style={[styles.episodeInfo, { color: 'rgba(255,255,255,0.7)' }]}> + S{item.season}:E{item.episode} • + </Text> <MaterialIcons - name="star" - size={12} + name="event" + size={14} color={currentTheme.colors.primary} /> - <Text style={[styles.ratingText, { color: currentTheme.colors.primary }]}> - {item.vote_average.toFixed(1)} + <Text style={[styles.releaseDate, { color: currentTheme.colors.primary }]}> + {formattedDate} </Text> </View> - )} - </View> - - <View style={styles.content}> - <Text style={[styles.seriesName, { color: currentTheme.colors.text }]} numberOfLines={1}> - {item.seriesName} - </Text> - <Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}> - S{item.season}:E{item.episode} - {item.title} - </Text> - {item.overview ? ( - <Text style={[styles.overview, { color: currentTheme.colors.lightGray, opacity: 0.8 }]} numberOfLines={2}> - {item.overview} - </Text> - ) : null} - <Text style={[styles.releaseDate, { color: currentTheme.colors.primary }]}> - {formattedDate} - </Text> </View> </LinearGradient> + </View> </TouchableOpacity> </Animated.View> ); @@ -270,132 +184,157 @@ export const ThisWeekSection = () => { return ( <Animated.View entering={FadeIn.duration(300)} style={styles.container}> <View style={styles.header}> + <View style={styles.titleContainer}> <Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text> + <View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} /> + </View> <TouchableOpacity onPress={handleViewAll} style={styles.viewAllButton}> - <Text style={[styles.viewAllText, { color: currentTheme.colors.lightGray }]}>View All</Text> - <MaterialIcons name="chevron-right" size={18} color={currentTheme.colors.lightGray} /> + <Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text> + <MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} /> </TouchableOpacity> </View> <FlatList - data={episodes} + data={thisWeekEpisodes} keyExtractor={(item) => item.id} renderItem={renderEpisodeItem} horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.listContent} - snapToInterval={ITEM_WIDTH + 12} + snapToInterval={ITEM_WIDTH + 16} decelerationRate="fast" + snapToAlignment="start" + ItemSeparatorComponent={() => <View style={{ width: 16 }} />} /> </Animated.View> ); -}; +}); const styles = StyleSheet.create({ container: { - marginVertical: 16, + marginVertical: 20, }, header: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingHorizontal: 16, - marginBottom: 12, + marginBottom: 16, + }, + titleContainer: { + position: 'relative', }, title: { - fontSize: 19, - fontWeight: '700', - letterSpacing: 0.2, + fontSize: 24, + fontWeight: '800', + letterSpacing: 0.5, + marginBottom: 4, + }, + titleUnderline: { + position: 'absolute', + bottom: -2, + left: 0, + width: 40, + height: 3, + borderRadius: 2, + opacity: 0.8, }, viewAllButton: { flexDirection: 'row', alignItems: 'center', + paddingVertical: 8, + paddingHorizontal: 10, + borderRadius: 20, + backgroundColor: 'rgba(255,255,255,0.1)', + marginRight: -10, }, viewAllText: { fontSize: 14, + fontWeight: '600', marginRight: 4, }, listContent: { - paddingHorizontal: 8, + paddingLeft: 16, + paddingRight: 16, + paddingBottom: 8, }, loadingContainer: { - padding: 20, + padding: 32, alignItems: 'center', }, + loadingText: { + marginTop: 12, + fontSize: 16, + fontWeight: '500', + }, episodeItemContainer: { width: ITEM_WIDTH, height: ITEM_HEIGHT, - marginHorizontal: 6, }, episodeItem: { width: '100%', height: '100%', - borderRadius: 8, + borderRadius: 16, overflow: 'hidden', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 12, + }, + imageContainer: { + width: '100%', + height: '100%', + position: 'relative', }, poster: { width: '100%', height: '100%', + borderRadius: 16, }, gradient: { position: 'absolute', left: 0, right: 0, + top: 0, bottom: 0, - height: '80%', justifyContent: 'flex-end', - padding: 16, + padding: 12, + borderRadius: 16, }, - badgeContainer: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - marginBottom: 12, - }, - badge: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 4, - }, - releasedBadge: {}, - upcomingBadge: {}, - badgeText: { - fontSize: 10, - fontWeight: 'bold', - marginLeft: 4, - }, - ratingBadge: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 4, - }, - ratingText: { - fontSize: 10, - fontWeight: 'bold', - marginLeft: 4, - }, - content: { + contentArea: { width: '100%', }, seriesName: { fontSize: 16, - fontWeight: 'bold', - marginBottom: 4, + fontWeight: '700', + marginBottom: 6, }, episodeTitle: { fontSize: 14, + fontWeight: '600', marginBottom: 4, + lineHeight: 18, }, overview: { fontSize: 12, - marginBottom: 4, + lineHeight: 16, + marginBottom: 6, + opacity: 0.9, + }, + dateContainer: { + flexDirection: 'row', + alignItems: 'center', + marginTop: 4, + }, + episodeInfo: { + fontSize: 12, + fontWeight: '600', + marginRight: 4, }, releaseDate: { - fontSize: 12, - fontWeight: 'bold', + fontSize: 13, + fontWeight: '600', + marginLeft: 6, + letterSpacing: 0.3, }, }); \ No newline at end of file diff --git a/src/components/metadata/CastDetailsModal.tsx b/src/components/metadata/CastDetailsModal.tsx new file mode 100644 index 0000000..a1c0104 --- /dev/null +++ b/src/components/metadata/CastDetailsModal.tsx @@ -0,0 +1,472 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + TouchableOpacity, + ScrollView, + ActivityIndicator, + Dimensions, + Platform, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { BlurView } from 'expo-blur'; +import { Image } from 'expo-image'; +import Animated, { + FadeIn, + FadeOut, + useAnimatedStyle, + useSharedValue, + withTiming, + withSpring, + runOnJS, +} from 'react-native-reanimated'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useTheme } from '../../contexts/ThemeContext'; +import { Cast } from '../../types/cast'; +import { tmdbService } from '../../services/tmdbService'; + +interface CastDetailsModalProps { + visible: boolean; + onClose: () => void; + castMember: Cast | null; +} + +const { width, height } = Dimensions.get('window'); +const MODAL_WIDTH = Math.min(width - 40, 400); +const MODAL_HEIGHT = height * 0.7; + +interface PersonDetails { + id: number; + name: string; + biography: string; + birthday: string | null; + place_of_birth: string | null; + known_for_department: string; + profile_path: string | null; + also_known_as: string[]; +} + +export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({ + visible, + onClose, + castMember, +}) => { + const { currentTheme } = useTheme(); + const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null); + const [loading, setLoading] = useState(false); + const [hasFetched, setHasFetched] = useState(false); + const modalOpacity = useSharedValue(0); + const modalScale = useSharedValue(0.9); + + useEffect(() => { + if (visible && castMember) { + modalOpacity.value = withTiming(1, { duration: 250 }); + modalScale.value = withSpring(1, { damping: 20, stiffness: 200 }); + + if (!hasFetched || personDetails?.id !== castMember.id) { + fetchPersonDetails(); + } + } else { + modalOpacity.value = withTiming(0, { duration: 200 }); + modalScale.value = withTiming(0.9, { duration: 200 }); + + if (!visible) { + setHasFetched(false); + setPersonDetails(null); + } + } + }, [visible, castMember]); + + const fetchPersonDetails = async () => { + if (!castMember || loading) return; + + setLoading(true); + try { + const details = await tmdbService.getPersonDetails(castMember.id); + setPersonDetails(details); + setHasFetched(true); + } catch (error) { + console.error('Error fetching person details:', error); + } finally { + setLoading(false); + } + }; + + const modalStyle = useAnimatedStyle(() => ({ + opacity: modalOpacity.value, + transform: [{ scale: modalScale.value }], + })); + + const handleClose = () => { + modalOpacity.value = withTiming(0, { duration: 200 }); + modalScale.value = withTiming(0.9, { duration: 200 }, () => { + runOnJS(onClose)(); + }); + }; + + const formatDate = (dateString: string | null) => { + if (!dateString) return null; + const date = new Date(dateString); + return date.toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + }; + + const calculateAge = (birthday: string | null) => { + if (!birthday) return null; + const today = new Date(); + const birthDate = new Date(birthday); + let age = today.getFullYear() - birthDate.getFullYear(); + const monthDiff = today.getMonth() - birthDate.getMonth(); + + if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) { + age--; + } + + return age; + }; + + if (!visible || !castMember) return null; + + return ( + <Animated.View + entering={FadeIn.duration(250)} + exiting={FadeOut.duration(200)} + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.85)', + justifyContent: 'center', + alignItems: 'center', + zIndex: 9999, + padding: 20, + }} + > + <TouchableOpacity + style={{ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + }} + onPress={handleClose} + activeOpacity={1} + /> + + <Animated.View + style={[ + { + width: MODAL_WIDTH, + height: MODAL_HEIGHT, + overflow: 'hidden', + borderRadius: 24, + backgroundColor: Platform.OS === 'android' + ? 'rgba(20, 20, 20, 0.95)' + : 'transparent', + }, + modalStyle, + ]} + > + {Platform.OS === 'ios' ? ( + <BlurView + intensity={100} + tint="dark" + style={{ + width: '100%', + height: '100%', + backgroundColor: 'rgba(20, 20, 20, 0.8)', + }} + > + {renderContent()} + </BlurView> + ) : ( + renderContent() + )} + </Animated.View> + </Animated.View> + ); + + function renderContent() { + return ( + <> + {/* Header */} + <LinearGradient + colors={[ + currentTheme.colors.primary + 'DD', + currentTheme.colors.primaryVariant + 'CC', + ]} + style={{ + padding: 20, + paddingTop: 24, + }} + > + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + <View style={{ + width: 60, + height: 60, + borderRadius: 30, + overflow: 'hidden', + marginRight: 16, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + }}> + {castMember.profile_path ? ( + <Image + source={{ + uri: `https://image.tmdb.org/t/p/w185${castMember.profile_path}`, + }} + style={{ width: '100%', height: '100%' }} + contentFit="cover" + /> + ) : ( + <View style={{ + width: '100%', + height: '100%', + alignItems: 'center', + justifyContent: 'center', + }}> + <Text style={{ + color: '#fff', + fontSize: 18, + fontWeight: '700', + }}> + {castMember.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)} + </Text> + </View> + )} + </View> + + <View style={{ flex: 1 }}> + <Text style={{ + color: '#fff', + fontSize: 18, + fontWeight: '800', + marginBottom: 4, + }} numberOfLines={2}> + {castMember.name} + </Text> + {castMember.character && ( + <Text style={{ + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + fontWeight: '500', + }} numberOfLines={2}> + as {castMember.character} + </Text> + )} + </View> + + <TouchableOpacity + style={{ + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255, 255, 255, 0.2)', + justifyContent: 'center', + alignItems: 'center', + }} + onPress={handleClose} + activeOpacity={0.7} + > + <MaterialIcons name="close" size={20} color="#fff" /> + </TouchableOpacity> + </View> + </LinearGradient> + + {/* Content */} + <ScrollView + style={{ flex: 1 }} + contentContainerStyle={{ padding: 20 }} + showsVerticalScrollIndicator={false} + > + {loading ? ( + <View style={{ + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 40, + }}> + <ActivityIndicator size="large" color={currentTheme.colors.primary} /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + marginTop: 12, + }}> + Loading details... + </Text> + </View> + ) : ( + <View> + {/* Quick Info */} + {(personDetails?.known_for_department || personDetails?.birthday || personDetails?.place_of_birth) && ( + <View style={{ + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 16, + marginBottom: 20, + }}> + {personDetails?.known_for_department && ( + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: personDetails?.birthday || personDetails?.place_of_birth ? 12 : 0 + }}> + <MaterialIcons name="work" size={16} color={currentTheme.colors.primary} /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginLeft: 8, + marginRight: 12, + }}> + Department + </Text> + <Text style={{ + color: '#fff', + fontSize: 14, + fontWeight: '600', + }}> + {personDetails.known_for_department} + </Text> + </View> + )} + + {personDetails?.birthday && ( + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: personDetails?.place_of_birth ? 12 : 0 + }}> + <MaterialIcons name="cake" size={16} color="#22C55E" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginLeft: 8, + marginRight: 12, + }}> + Age + </Text> + <Text style={{ + color: '#fff', + fontSize: 14, + fontWeight: '600', + }}> + {calculateAge(personDetails.birthday)} years old + </Text> + </View> + )} + + {personDetails?.place_of_birth && ( + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + <MaterialIcons name="place" size={16} color="#F59E0B" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginLeft: 8, + marginRight: 12, + }}> + Born in + </Text> + <Text style={{ + color: '#fff', + fontSize: 14, + fontWeight: '600', + flex: 1, + }}> + {personDetails.place_of_birth} + </Text> + </View> + )} + + {personDetails?.birthday && ( + <View style={{ + marginTop: 12, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: 'rgba(255, 255, 255, 0.1)', + }}> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 12, + marginBottom: 4, + }}> + Born on {formatDate(personDetails.birthday)} + </Text> + </View> + )} + </View> + )} + + {/* Biography */} + {personDetails?.biography && ( + <View style={{ marginBottom: 20 }}> + <Text style={{ + color: '#fff', + fontSize: 16, + fontWeight: '700', + marginBottom: 12, + }}> + Biography + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.9)', + fontSize: 14, + lineHeight: 20, + fontWeight: '400', + }}> + {personDetails.biography} + </Text> + </View> + )} + + {/* Also Known As - Compact */} + {personDetails?.also_known_as && personDetails.also_known_as.length > 0 && ( + <View> + <Text style={{ + color: '#fff', + fontSize: 16, + fontWeight: '700', + marginBottom: 12, + }}> + Also Known As + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.8)', + fontSize: 14, + lineHeight: 20, + }}> + {personDetails.also_known_as.slice(0, 4).join(' • ')} + </Text> + </View> + )} + + {/* No details available */} + {!loading && !personDetails?.biography && !personDetails?.birthday && !personDetails?.place_of_birth && ( + <View style={{ + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 40, + }}> + <MaterialIcons name="info" size={32} color="rgba(255, 255, 255, 0.3)" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + marginTop: 12, + textAlign: 'center', + }}> + No additional details available + </Text> + </View> + )} + </View> + )} + </ScrollView> + </> + ); + } +}; + +export default CastDetailsModal; \ No newline at end of file diff --git a/src/components/metadata/CastSection.tsx b/src/components/metadata/CastSection.tsx index f904eb7..305f31e 100644 --- a/src/components/metadata/CastSection.tsx +++ b/src/components/metadata/CastSection.tsx @@ -10,7 +10,6 @@ import { import { Image } from 'expo-image'; import Animated, { FadeIn, - Layout, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; @@ -42,8 +41,7 @@ export const CastSection: React.FC<CastSectionProps> = ({ return ( <Animated.View style={styles.castSection} - entering={FadeIn.duration(500).delay(300)} - layout={Layout} + entering={FadeIn.duration(300).delay(150)} > <View style={styles.sectionHeader}> <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>Cast</Text> @@ -56,8 +54,7 @@ export const CastSection: React.FC<CastSectionProps> = ({ keyExtractor={(item) => item.id.toString()} renderItem={({ item, index }) => ( <Animated.View - entering={FadeIn.duration(500).delay(100 + index * 50)} - layout={Layout} + entering={FadeIn.duration(300).delay(50 + index * 30)} > <TouchableOpacity style={styles.castCard} @@ -75,7 +72,7 @@ export const CastSection: React.FC<CastSectionProps> = ({ transition={200} /> ) : ( - <View style={[styles.castImagePlaceholder, { backgroundColor: currentTheme.colors.cardBackground }]}> + <View style={[styles.castImagePlaceholder, { backgroundColor: currentTheme.colors.darkBackground }]}> <Text style={[styles.placeholderText, { color: currentTheme.colors.textMuted }]}> {item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)} </Text> diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx index 80d15ab..85cc74e 100644 --- a/src/components/metadata/HeroSection.tsx +++ b/src/components/metadata/HeroSection.tsx @@ -21,6 +21,7 @@ import Animated, { withTiming, runOnJS, withRepeat, + FadeIn, } from 'react-native-reanimated'; import { useTheme } from '../../contexts/ThemeContext'; import { useTraktContext } from '../../contexts/TraktContext'; @@ -66,6 +67,7 @@ interface HeroSectionProps { getPlayButtonText: () => string; setBannerImage: (bannerImage: string | null) => void; setLogoLoadError: (error: boolean) => void; + groupedEpisodes?: { [seasonNumber: number]: any[] }; } // Ultra-optimized ActionButtons Component - minimal re-renders @@ -79,7 +81,8 @@ const ActionButtons = React.memo(({ playButtonText, animatedStyle, isWatched, - watchProgress + watchProgress, + groupedEpisodes }: { handleShowStreams: () => void; toggleLibrary: () => void; @@ -91,6 +94,7 @@ const ActionButtons = React.memo(({ animatedStyle: any; isWatched: boolean; watchProgress: any; + groupedEpisodes?: { [seasonNumber: number]: any[] }; }) => { const { currentTheme } = useTheme(); @@ -128,25 +132,95 @@ const ActionButtons = React.memo(({ // Determine play button style and text based on watched status const playButtonStyle = useMemo(() => { - if (isWatched) { + if (isWatched && type === 'movie') { + // Only movies get the dark watched style for "Watch Again" return [styles.actionButton, styles.playButton, styles.watchedPlayButton]; } + // All other buttons (Resume, Play SxxEyy, regular Play) get white background return [styles.actionButton, styles.playButton]; - }, [isWatched]); + }, [isWatched, type]); const playButtonTextStyle = useMemo(() => { - if (isWatched) { + if (isWatched && type === 'movie') { + // Only movies get white text for "Watch Again" return [styles.playButtonText, styles.watchedPlayButtonText]; } + // All other buttons get black text return styles.playButtonText; - }, [isWatched]); + }, [isWatched, type]); const finalPlayButtonText = useMemo(() => { - if (isWatched) { - return 'Watch Again'; + // For movies, handle watched state + if (type === 'movie') { + return isWatched ? 'Watch Again' : playButtonText; } - return playButtonText; - }, [isWatched, playButtonText]); + + // For series, validate next episode existence for both watched and resume cases + if (type === 'series' && watchProgress?.episodeId && groupedEpisodes) { + let seasonNum: number | null = null; + let episodeNum: number | null = null; + + const parts = watchProgress.episodeId.split(':'); + + if (parts.length === 3) { + // Format: showId:season:episode + seasonNum = parseInt(parts[1], 10); + episodeNum = parseInt(parts[2], 10); + } else if (parts.length === 2) { + // Format: season:episode (no show id) + seasonNum = parseInt(parts[0], 10); + episodeNum = parseInt(parts[1], 10); + } else { + // Try pattern s1e2 + const match = watchProgress.episodeId.match(/s(\d+)e(\d+)/i); + if (match) { + seasonNum = parseInt(match[1], 10); + episodeNum = parseInt(match[2], 10); + } + } + + if (seasonNum !== null && episodeNum !== null && !isNaN(seasonNum) && !isNaN(episodeNum)) { + if (isWatched) { + // For watched episodes, check if next episode exists + const nextEpisode = episodeNum + 1; + const currentSeasonEpisodes = groupedEpisodes[seasonNum] || []; + const nextEpisodeExists = currentSeasonEpisodes.some(ep => + ep.episode_number === nextEpisode + ); + + if (nextEpisodeExists) { + // Show the NEXT episode number only if it exists + const seasonStr = seasonNum.toString().padStart(2, '0'); + const episodeStr = nextEpisode.toString().padStart(2, '0'); + return `Play S${seasonStr}E${episodeStr}`; + } else { + // If next episode doesn't exist, show generic text + return 'Completed'; + } + } else { + // For non-watched episodes, check if current episode exists + const currentSeasonEpisodes = groupedEpisodes[seasonNum] || []; + const currentEpisodeExists = currentSeasonEpisodes.some(ep => + ep.episode_number === episodeNum + ); + + if (currentEpisodeExists) { + // Current episode exists, use original button text + return playButtonText; + } else { + // Current episode doesn't exist, fallback to generic play + return 'Play'; + } + } + } + + // Fallback label if parsing fails + return isWatched ? 'Play Next Episode' : playButtonText; + } + + // Default fallback for non-series or missing data + return isWatched ? 'Play' : playButtonText; + }, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]); return ( <Animated.View style={[styles.actionButtons, animatedStyle]}> @@ -156,18 +230,16 @@ const ActionButtons = React.memo(({ activeOpacity={0.85} > <MaterialIcons - name={isWatched ? "replay" : (playButtonText === 'Resume' ? "play-circle-outline" : "play-arrow")} + name={(() => { + if (isWatched) { + return type === 'movie' ? 'replay' : 'play-arrow'; + } + return playButtonText === 'Resume' ? 'play-circle-outline' : 'play-arrow'; + })()} size={24} - color={isWatched ? "#fff" : "#000"} + color={isWatched && type === 'movie' ? "#fff" : "#000"} /> <Text style={playButtonTextStyle}>{finalPlayButtonText}</Text> - - {/* Subtle watched indicator in play button */} - {isWatched && ( - <View style={styles.watchedIndicator}> - <MaterialIcons name="check" size={12} color="#fff" /> - </View> - )} </TouchableOpacity> <TouchableOpacity @@ -178,17 +250,7 @@ const ActionButtons = React.memo(({ {Platform.OS === 'ios' ? ( <ExpoBlurView intensity={80} style={styles.blurBackground} tint="dark" /> ) : ( - Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? ( - <View style={styles.androidFallbackBlur} /> - ) : ( - <CommunityBlurView - style={styles.blurBackground} - blurType="dark" - blurAmount={8} - overlayColor="rgba(255,255,255,0.1)" - reducedTransparencyFallbackColor="rgba(255,255,255,0.15)" - /> - ) + <View style={styles.androidFallbackBlur} /> )} <MaterialIcons name={inLibrary ? 'bookmark' : 'bookmark-border'} @@ -209,17 +271,7 @@ const ActionButtons = React.memo(({ {Platform.OS === 'ios' ? ( <ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" /> ) : ( - Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? ( - <View style={styles.androidFallbackBlurRound} /> - ) : ( - <CommunityBlurView - style={styles.blurBackgroundRound} - blurType="dark" - blurAmount={8} - overlayColor="rgba(255,255,255,0.1)" - reducedTransparencyFallbackColor="rgba(255,255,255,0.15)" - /> - ) + <View style={styles.androidFallbackBlurRound} /> )} <MaterialIcons name="assessment" @@ -256,6 +308,10 @@ const WatchProgressDisplay = React.memo(({ const { currentTheme } = useTheme(); const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext(); + // State to trigger refresh after manual sync + const [refreshTrigger, setRefreshTrigger] = useState(0); + const [isSyncing, setIsSyncing] = useState(false); + // Animated values for enhanced effects const completionGlow = useSharedValue(0); const celebrationScale = useSharedValue(1); @@ -263,19 +319,50 @@ const WatchProgressDisplay = React.memo(({ const progressBoxOpacity = useSharedValue(0); const progressBoxScale = useSharedValue(0.8); const progressBoxTranslateY = useSharedValue(20); + const syncRotation = useSharedValue(0); + + // Animate the sync icon when syncing + useEffect(() => { + if (isSyncing) { + syncRotation.value = withRepeat( + withTiming(360, { duration: 1000 }), + -1, // Infinite repeats + false // No reverse + ); + } else { + syncRotation.value = 0; + } + }, [isSyncing, syncRotation]); // Handle manual Trakt sync const handleTraktSync = useMemo(() => async () => { if (isTraktAuthenticated && forceSyncTraktProgress) { logger.log('[HeroSection] Manual Trakt sync requested'); + setIsSyncing(true); try { const success = await forceSyncTraktProgress(); logger.log(`[HeroSection] Manual Trakt sync ${success ? 'successful' : 'failed'}`); + + // Force component to re-render after a short delay to update sync status + if (success) { + setTimeout(() => { + setRefreshTrigger(prev => prev + 1); + setIsSyncing(false); + }, 500); + } else { + setIsSyncing(false); + } } catch (error) { logger.error('[HeroSection] Manual Trakt sync error:', error); + setIsSyncing(false); } } - }, [isTraktAuthenticated, forceSyncTraktProgress]); + }, [isTraktAuthenticated, forceSyncTraktProgress, setRefreshTrigger]); + + // Sync rotation animation style + const syncIconStyle = useAnimatedStyle(() => ({ + transform: [{ rotate: `${syncRotation.value}deg` }], + })); // Memoized progress calculation with Trakt integration const progressData = useMemo(() => { @@ -350,7 +437,8 @@ const WatchProgressDisplay = React.memo(({ displayText = `${Math.round(progressPercent)}% watched (${Math.round(watchProgress.traktProgress)}% on Trakt)`; } } else { - syncStatus = ' • Sync pending'; + // Do not show "Sync pending" label anymore; leave status empty. + syncStatus = ''; } } @@ -363,7 +451,7 @@ const WatchProgressDisplay = React.memo(({ isTraktSynced: watchProgress.traktSynced && isTraktAuthenticated, isWatched: false }; - }, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated, isWatched]); + }, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated, isWatched, refreshTrigger]); // Trigger appearance and completion animations useEffect(() => { @@ -483,14 +571,6 @@ const WatchProgressDisplay = React.memo(({ {progressData.displayText} </Text> - {/* Progress percentage badge */} - {!isCompleted && ( - <View style={styles.percentageBadge}> - <Text style={styles.percentageText}> - {Math.round(progressData.progressPercent)}% - </Text> - </View> - )} </View> <Text style={[styles.watchProgressSubText, { @@ -519,16 +599,19 @@ const WatchProgressDisplay = React.memo(({ style={styles.traktSyncButtonInline} onPress={handleTraktSync} activeOpacity={0.7} + disabled={isSyncing} > <LinearGradient colors={['#E50914', '#B8070F']} style={styles.syncButtonGradientInline} > - <MaterialIcons - name="refresh" + <Animated.View style={syncIconStyle}> + <MaterialIcons + name={isSyncing ? "sync" : "refresh"} size={12} color="#fff" - /> + /> + </Animated.View> </LinearGradient> </TouchableOpacity> )} @@ -563,6 +646,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ getPlayButtonText, setBannerImage, setLogoLoadError, + groupedEpisodes, }) => { const { currentTheme } = useTheme(); const { isAuthenticated: isTraktAuthenticated } = useTraktContext(); @@ -684,20 +768,24 @@ const HeroSection: React.FC<HeroSectionProps> = ({ }] }), []); - // Ultra-optimized genre rendering + // Ultra-optimized genre rendering with smooth animation const genreElements = useMemo(() => { if (!metadata?.genres?.length) return null; const genresToDisplay = metadata.genres.slice(0, 3); // Reduced to 3 for performance return genresToDisplay.map((genreName: string, index: number, array: string[]) => ( - <React.Fragment key={`${genreName}-${index}`}> + <Animated.View + key={`${genreName}-${index}`} + entering={FadeIn.duration(400).delay(200 + index * 100)} + style={{ flexDirection: 'row', alignItems: 'center' }} + > <Text style={[styles.genreText, { color: currentTheme.colors.text }]}> {genreName} </Text> {index < array.length - 1 && ( <Text style={[styles.genreDot, { color: currentTheme.colors.text }]}>•</Text> )} - </React.Fragment> + </Animated.View> )); }, [metadata.genres, currentTheme.colors.text]); @@ -814,6 +902,7 @@ const HeroSection: React.FC<HeroSectionProps> = ({ animatedStyle={buttonsAnimatedStyle} isWatched={isWatched} watchProgress={watchProgress} + groupedEpisodes={groupedEpisodes} /> </View> </LinearGradient> @@ -1158,18 +1247,6 @@ const styles = StyleSheet.create({ fontWeight: '600', textAlign: 'center', }, - percentageBadge: { - backgroundColor: 'rgba(255,255,255,0.2)', - borderRadius: 8, - paddingHorizontal: 6, - paddingVertical: 2, - marginLeft: 8, - }, - percentageText: { - fontSize: 10, - fontWeight: '600', - color: '#fff', - }, watchProgressSubText: { fontSize: 9, textAlign: 'center', @@ -1228,4 +1305,4 @@ const styles = StyleSheet.create({ }, }); -export default React.memo(HeroSection); \ No newline at end of file +export default React.memo(HeroSection); \ No newline at end of file diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx index 1011359..fdc4b43 100644 --- a/src/components/metadata/MetadataDetails.tsx +++ b/src/components/metadata/MetadataDetails.tsx @@ -60,7 +60,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({ {/* Creator/Director Info */} <Animated.View - entering={FadeIn.duration(500).delay(200)} + entering={FadeIn.duration(300).delay(100)} style={styles.creatorContainer} > {metadata.directors && metadata.directors.length > 0 && ( @@ -81,7 +81,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({ {metadata.description && ( <Animated.View style={styles.descriptionContainer} - layout={Layout.duration(300).easing(Easing.inOut(Easing.ease))} + entering={FadeIn.duration(300)} > <TouchableOpacity onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)} diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx index 5f5feb5..8af37b7 100644 --- a/src/components/metadata/SeriesContent.tsx +++ b/src/components/metadata/SeriesContent.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState, useRef } from 'react'; -import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native'; +import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native'; import { Image } from 'expo-image'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import { LinearGradient } from 'expo-linear-gradient'; @@ -10,6 +10,8 @@ import { tmdbService } from '../../services/tmdbService'; import { storageService } from '../../services/storageService'; import { useFocusEffect } from '@react-navigation/native'; import Animated, { FadeIn } from 'react-native-reanimated'; +import { TraktService } from '../../services/traktService'; +import { logger } from '../../utils/logger'; interface SeriesContentProps { episodes: Episode[]; @@ -45,6 +47,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ // Add refs for the scroll views const seasonScrollViewRef = useRef<ScrollView | null>(null); const episodeScrollViewRef = useRef<ScrollView | null>(null); + + const loadEpisodesProgress = async () => { if (!metadata?.id) return; @@ -64,23 +68,57 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ } }); + // ---------------- Trakt watched-history integration ---------------- + try { + const traktService = TraktService.getInstance(); + const isAuthed = await traktService.isAuthenticated(); + if (isAuthed && metadata?.id) { + const historyItems = await traktService.getWatchedEpisodesHistory(1, 400); + + historyItems.forEach(item => { + if (item.type !== 'episode') return; + + const showImdb = item.show?.ids?.imdb ? `tt${item.show.ids.imdb.replace(/^tt/, '')}` : null; + if (!showImdb || showImdb !== metadata.id) return; + + const season = item.episode?.season; + const epNum = item.episode?.number; + if (season === undefined || epNum === undefined) return; + + const episodeId = `${metadata.id}:${season}:${epNum}`; + const watchedAt = new Date(item.watched_at).getTime(); + + // Mark as 100% completed (use 1/1 to avoid divide-by-zero) + const traktProgressEntry = { + currentTime: 1, + duration: 1, + lastUpdated: watchedAt, + }; + + const existing = progress[episodeId]; + const existingPercent = existing ? (existing.currentTime / existing.duration) * 100 : 0; + + // Prefer local progress if it is already >=85%; otherwise use Trakt data + if (!existing || existingPercent < 85) { + progress[episodeId] = traktProgressEntry; + } + }); + } + } catch (err) { + logger.error('[SeriesContent] Failed to merge Trakt history:', err); + } + setEpisodeProgress(progress); }; // Function to find and scroll to the most recently watched episode const scrollToMostRecentEpisode = () => { - if (!metadata?.id || !episodeScrollViewRef.current || settings.episodeLayoutStyle !== 'horizontal') { - console.log('[SeriesContent] Scroll conditions not met:', { - hasMetadataId: !!metadata?.id, - hasScrollRef: !!episodeScrollViewRef.current, - isHorizontal: settings.episodeLayoutStyle === 'horizontal' - }); + if (!metadata?.id || !episodeScrollViewRef.current || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') { return; } const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; if (currentSeasonEpisodes.length === 0) { - console.log('[SeriesContent] No episodes in current season:', selectedSeason); return; } @@ -100,30 +138,18 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ } }); - console.log('[SeriesContent] Episode scroll analysis:', { - totalEpisodes: currentSeasonEpisodes.length, - mostRecentIndex: mostRecentEpisodeIndex, - mostRecentEpisode: mostRecentEpisodeName, - selectedSeason - }); - // Scroll to the most recently watched episode if found if (mostRecentEpisodeIndex >= 0) { const cardWidth = isTablet ? width * 0.4 + 16 : width * 0.85 + 16; const scrollPosition = mostRecentEpisodeIndex * cardWidth; - console.log('[SeriesContent] Scrolling to episode:', { - index: mostRecentEpisodeIndex, - cardWidth, - scrollPosition, - episodeName: mostRecentEpisodeName - }); - setTimeout(() => { - episodeScrollViewRef.current?.scrollTo({ - x: scrollPosition, - animated: true - }); + if (episodeScrollViewRef.current && typeof (episodeScrollViewRef.current as any).scrollToOffset === 'function') { + (episodeScrollViewRef.current as any).scrollToOffset({ + offset: scrollPosition, + animated: true + }); + } }, 500); // Delay to ensure the season has loaded } }; @@ -150,10 +176,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ if (selectedIndex !== -1) { // Wait a small amount of time for layout to be ready setTimeout(() => { - seasonScrollViewRef.current?.scrollTo({ - x: selectedIndex * 116, // 100px width + 16px margin - animated: true - }); + if (seasonScrollViewRef.current && typeof (seasonScrollViewRef.current as any).scrollToOffset === 'function') { + (seasonScrollViewRef.current as any).scrollToOffset({ + offset: selectedIndex * 116, // 100px width + 16px margin + animated: true + }); + } }, 300); } } @@ -161,10 +189,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ // Add effect to scroll to most recently watched episode when season changes or progress loads useEffect(() => { - if (Object.keys(episodeProgress).length > 0 && selectedSeason) { + if (Object.keys(episodeProgress).length > 0 && selectedSeason && settings?.episodeLayoutStyle) { scrollToMostRecentEpisode(); } - }, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]); + }, [selectedSeason, episodeProgress, settings?.episodeLayoutStyle, groupedEpisodes]); + + if (loadingSeasons) { return ( @@ -185,23 +215,27 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ } const renderSeasonSelector = () => { + // Show selector if we have grouped episodes data or can derive from episodes if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) { return null; } - + const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); return ( <View style={styles.seasonSelectorWrapper}> <Text style={[styles.seasonSelectorTitle, { color: currentTheme.colors.highEmphasis }]}>Seasons</Text> - <ScrollView - ref={seasonScrollViewRef} - horizontal + <FlatList + ref={seasonScrollViewRef as React.RefObject<FlatList<any>>} + data={seasons} + horizontal showsHorizontalScrollIndicator={false} style={styles.seasonSelectorContainer} contentContainerStyle={styles.seasonSelectorContent} - > - {seasons.map(season => { + initialNumToRender={5} + maxToRenderPerBatch={5} + windowSize={3} + renderItem={({ item: season }) => { const seasonEpisodes = groupedEpisodes[season] || []; let seasonPoster = DEFAULT_PLACEHOLDER; if (seasonEpisodes[0]?.season_poster_path) { @@ -229,6 +263,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ {selectedSeason === season && ( <View style={[styles.selectedSeasonIndicator, { backgroundColor: currentTheme.colors.primary }]} /> )} + {/* Show episode count badge, including when there are no episodes */} + <View style={[styles.episodeCountBadge, { backgroundColor: currentTheme.colors.elevation2 }]}> + <Text style={[styles.episodeCountText, { color: currentTheme.colors.textMuted }]}> + {seasonEpisodes.length} ep{seasonEpisodes.length !== 1 ? 's' : ''} + </Text> + </View> </View> <Text style={[ @@ -241,8 +281,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ </Text> </TouchableOpacity> ); - })} - </ScrollView> + }} + keyExtractor={season => season.toString()} + /> </View> ); }; @@ -251,8 +292,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ const renderVerticalEpisodeCard = (episode: Episode) => { let episodeImage = EPISODE_PLACEHOLDER; if (episode.still_path) { - const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); - if (tmdbUrl) episodeImage = tmdbUrl; + // Check if still_path is already a full URL + if (episode.still_path.startsWith('http')) { + episodeImage = episode.still_path; + } else { + const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); + if (tmdbUrl) episodeImage = tmdbUrl; + } } else if (metadata?.poster) { episodeImage = metadata.poster; } @@ -370,8 +416,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ const renderHorizontalEpisodeCard = (episode: Episode) => { let episodeImage = EPISODE_PLACEHOLDER; if (episode.still_path) { - const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); - if (tmdbUrl) episodeImage = tmdbUrl; + // Check if still_path is already a full URL + if (episode.still_path.startsWith('http')) { + episodeImage = episode.still_path; + } else { + const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); + if (tmdbUrl) episodeImage = tmdbUrl; + } } else if (metadata?.poster) { episodeImage = metadata.poster; } @@ -535,74 +586,92 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({ return ( <View style={styles.container}> <Animated.View - entering={FadeIn.duration(500).delay(100)} + entering={FadeIn.duration(300).delay(50)} > {renderSeasonSelector()} </Animated.View> <Animated.View - entering={FadeIn.duration(500).delay(200)} + entering={FadeIn.duration(300).delay(100)} > <Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}> - {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'} + {currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'} </Text> - {settings.episodeLayoutStyle === 'horizontal' ? ( - // Horizontal Layout (Netflix-style) - <ScrollView - ref={episodeScrollViewRef} - horizontal - showsHorizontalScrollIndicator={false} - style={styles.episodeList} - contentContainerStyle={styles.episodeListContentHorizontal} - decelerationRate="fast" - snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16} - snapToAlignment="start" - > - {currentSeasonEpisodes.map((episode, index) => ( - <Animated.View - key={episode.id} - entering={FadeIn.duration(400).delay(300 + index * 50)} - style={[ - styles.episodeCardWrapperHorizontal, - isTablet && styles.episodeCardWrapperHorizontalTablet - ]} - > - {renderHorizontalEpisodeCard(episode)} - </Animated.View> - ))} - </ScrollView> - ) : ( - // Vertical Layout (Traditional) - <ScrollView - style={styles.episodeList} - contentContainerStyle={[ - styles.episodeListContentVertical, - isTablet && styles.episodeListContentVerticalTablet - ]} - > - {isTablet ? ( - <View style={styles.episodeGridVertical}> - {currentSeasonEpisodes.map((episode, index) => ( + {/* Show message when no episodes are available for selected season */} + {currentSeasonEpisodes.length === 0 && ( + <View style={styles.centeredContainer}> + <MaterialIcons name="schedule" size={48} color={currentTheme.colors.textMuted} /> + <Text style={[styles.centeredText, { color: currentTheme.colors.text }]}> + No episodes available for Season {selectedSeason} + </Text> + <Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}> + Episodes may not be released yet + </Text> + </View> + )} + + {/* Only render episode list if there are episodes */} + {currentSeasonEpisodes.length > 0 && ( + (settings?.episodeLayoutStyle === 'horizontal') ? ( + // Horizontal Layout (Netflix-style) + <FlatList + ref={episodeScrollViewRef as React.RefObject<FlatList<any>>} + data={currentSeasonEpisodes} + renderItem={({ item: episode, index }) => ( + <Animated.View + key={episode.id} + entering={FadeIn.duration(300).delay(100 + index * 30)} + style={[ + styles.episodeCardWrapperHorizontal, + isTablet && styles.episodeCardWrapperHorizontalTablet + ]} + > + {renderHorizontalEpisodeCard(episode)} + </Animated.View> + )} + keyExtractor={episode => episode.id.toString()} + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.episodeListContentHorizontal} + decelerationRate="fast" + snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16} + snapToAlignment="start" + initialNumToRender={3} + maxToRenderPerBatch={3} + windowSize={5} + /> + ) : ( + // Vertical Layout (Traditional) + <View + style={[ + styles.episodeList, + isTablet ? styles.episodeListContentVerticalTablet : styles.episodeListContentVertical + ]} + > + {isTablet ? ( + <View style={styles.episodeGridVertical}> + {currentSeasonEpisodes.map((episode, index) => ( + <Animated.View + key={episode.id} + entering={FadeIn.duration(300).delay(100 + index * 30)} + > + {renderVerticalEpisodeCard(episode)} + </Animated.View> + ))} + </View> + ) : ( + currentSeasonEpisodes.map((episode, index) => ( <Animated.View key={episode.id} - entering={FadeIn.duration(400).delay(300 + index * 50)} + entering={FadeIn.duration(300).delay(100 + index * 30)} > {renderVerticalEpisodeCard(episode)} </Animated.View> - ))} - </View> - ) : ( - currentSeasonEpisodes.map((episode, index) => ( - <Animated.View - key={episode.id} - entering={FadeIn.duration(400).delay(300 + index * 50)} - > - {renderVerticalEpisodeCard(episode)} - </Animated.View> - )) - )} - </ScrollView> + )) + )} + </View> + ) )} </Animated.View> </View> @@ -625,6 +694,12 @@ const styles = StyleSheet.create({ fontSize: 16, textAlign: 'center', }, + centeredSubText: { + marginTop: 8, + fontSize: 14, + textAlign: 'center', + opacity: 0.8, + }, sectionTitle: { fontSize: 20, fontWeight: '700', @@ -766,8 +841,8 @@ const styles = StyleSheet.create({ }, completedBadge: { position: 'absolute', - bottom: 8, - right: 8, + top: 8, + left: 8, width: 20, height: 20, borderRadius: 10, @@ -775,6 +850,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', borderWidth: 1, borderColor: 'rgba(255,255,255,0.3)', + zIndex: 2, }, // Horizontal Layout Styles @@ -902,8 +978,8 @@ const styles = StyleSheet.create({ }, completedBadgeHorizontal: { position: 'absolute', - bottom: 12, - right: 12, + top: 12, + left: 12, width: 24, height: 24, borderRadius: 12, @@ -963,4 +1039,18 @@ const styles = StyleSheet.create({ selectedSeasonButtonText: { fontWeight: '700', }, + episodeCountBadge: { + position: 'absolute', + top: 8, + right: 8, + backgroundColor: 'rgba(0,0,0,0.8)', + paddingHorizontal: 4, + paddingVertical: 2, + borderRadius: 4, + }, + episodeCountText: { + color: '#fff', + fontSize: 12, + fontWeight: '600', + }, }); \ No newline at end of file diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 13d5c96..e822bfb 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -1,5 +1,5 @@ import React, { useState, useRef, useEffect } from 'react'; -import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } from 'react-native'; +import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet } from 'react-native'; import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType } from 'react-native-video'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; import { RootStackParamList } from '../../navigation/AppNavigator'; @@ -10,7 +10,11 @@ import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; import { useTraktAutosync } from '../../hooks/useTraktAutosync'; +import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings'; +import { useMetadata } from '../../hooks/useMetadata'; +import { useSettings } from '../../hooks/useSettings'; import { DEFAULT_SUBTITLE_SIZE, @@ -25,12 +29,12 @@ import { } from './utils/playerTypes'; import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils'; import { styles } from './utils/playerStyles'; -import SubtitleModals from './modals/SubtitleModals'; -import AudioTrackModal from './modals/AudioTrackModal'; +import { SubtitleModals } from './modals/SubtitleModals'; +import { AudioTrackModal } from './modals/AudioTrackModal'; import ResumeOverlay from './modals/ResumeOverlay'; import PlayerControls from './controls/PlayerControls'; import CustomSubtitles from './subtitles/CustomSubtitles'; -import SourcesModal from './modals/SourcesModal'; +import { SourcesModal } from './modals/SourcesModal'; // Map VLC resize modes to react-native-video resize modes const getVideoResizeMode = (resizeMode: ResizeModeType) => { @@ -61,7 +65,8 @@ const AndroidVideoPlayer: React.FC = () => { type, episodeId, imdbId, - availableStreams: passedAvailableStreams + availableStreams: passedAvailableStreams, + backdrop } = route.params; // Initialize Trakt autosync @@ -79,6 +84,9 @@ const AndroidVideoPlayer: React.FC = () => { episodeId: episodeId }); + // Get the Trakt autosync settings to use the user-configured sync frequency + const { settings: traktSettings } = useTraktAutosyncSettings(); + safeDebugLog("Android Component mounted with props", { uri, title, season, episode, episodeTitle, quality, year, streamProvider, id, type, episodeId, imdbId @@ -95,7 +103,7 @@ const AndroidVideoPlayer: React.FC = () => { const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null); const [textTracks, setTextTracks] = useState<TextTrack[]>([]); const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1); - const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch'); + const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain'); const [buffered, setBuffered] = useState(0); const [seekTime, setSeekTime] = useState<number | null>(null); const videoRef = useRef<VideoRef>(null); @@ -106,8 +114,7 @@ const AndroidVideoPlayer: React.FC = () => { const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); const [showResumeOverlay, setShowResumeOverlay] = useState(false); const [resumePosition, setResumePosition] = useState<number | null>(null); - const [rememberChoice, setRememberChoice] = useState(false); - const [resumePreference, setResumePreference] = useState<string | null>(null); + const [savedDuration, setSavedDuration] = useState<number | null>(null); const fadeAnim = useRef(new Animated.Value(1)).current; const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); const openingFadeAnim = useRef(new Animated.Value(0)).current; @@ -138,6 +145,7 @@ const AndroidVideoPlayer: React.FC = () => { const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]); const [currentSubtitle, setCurrentSubtitle] = useState<string>(''); const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE); + const [subtitleBackground, setSubtitleBackground] = useState<boolean>(true); const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false); const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false); const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]); @@ -152,6 +160,36 @@ const AndroidVideoPlayer: React.FC = () => { const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider); const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName); const isMounted = useRef(true); + const controlsTimeout = useRef<NodeJS.Timeout | null>(null); + const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); + // Get metadata to access logo (only if we have a valid id) + const shouldLoadMetadata = Boolean(id && type); + const metadataResult = useMetadata({ + id: id || 'placeholder', + type: type || 'movie' + }); + const { metadata, loading: metadataLoading } = shouldLoadMetadata ? metadataResult : { metadata: null, loading: false }; + const { settings } = useSettings(); + + // Logo animation values + const logoScaleAnim = useRef(new Animated.Value(0.8)).current; + const logoOpacityAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(1)).current; + + // Check if we have a logo to show + const hasLogo = metadata && metadata.logo && !metadataLoading; + + // Small offset (in seconds) used to avoid seeking to the *exact* end of the + // file which triggers the `onEnd` callback and causes playback to restart. + const END_EPSILON = 0.3; + + const hideControls = () => { + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(() => setShowControls(false)); + }; const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { return { @@ -226,7 +264,51 @@ const AndroidVideoPlayer: React.FC = () => { }, []); const startOpeningAnimation = () => { - // Animation logic here + // Logo entrance animation + Animated.parallel([ + Animated.timing(logoOpacityAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }), + Animated.spring(logoScaleAnim, { + toValue: 1, + tension: 50, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + // Continuous pulse animation for the logo + const createPulseAnimation = () => { + return Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.05, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + ]); + }; + + const loopPulse = () => { + createPulseAnimation().start(() => { + if (!isOpeningAnimationComplete) { + loopPulse(); + } + }); + }; + + // Start pulsing after a short delay + setTimeout(() => { + if (!isOpeningAnimationComplete) { + loopPulse(); + } + }, 800); }; const completeOpeningAnimation = () => { @@ -270,21 +352,10 @@ const AndroidVideoPlayer: React.FC = () => { if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); - logger.log(`[AndroidVideoPlayer] Set resume position to: ${savedProgress.currentTime}`); - - const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - logger.log(`[AndroidVideoPlayer] Resume preference: ${pref}`); - - if (pref === RESUME_PREF.ALWAYS_RESUME) { - setInitialPosition(savedProgress.currentTime); - logger.log(`[AndroidVideoPlayer] Auto-resuming due to preference`); - } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { - setInitialPosition(0); - logger.log(`[AndroidVideoPlayer] Auto-starting over due to preference`); - } else { - setShowResumeOverlay(true); - logger.log(`[AndroidVideoPlayer] Showing resume overlay`); - } + setSavedDuration(savedProgress.duration); + logger.log(`[AndroidVideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`); + setShowResumeOverlay(true); + logger.log(`[AndroidVideoPlayer] Showing resume overlay`); } else { logger.log(`[AndroidVideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`); } @@ -324,16 +395,24 @@ const AndroidVideoPlayer: React.FC = () => { if (progressSaveInterval) { clearInterval(progressSaveInterval); } + + // Use the user's configured sync frequency with increased minimum to reduce heating + // Minimum interval increased from 5s to 30s to reduce CPU usage + const syncInterval = Math.max(30000, traktSettings.syncFrequency); + const interval = setInterval(() => { saveWatchProgress(); - }, 5000); + }, syncInterval); + + logger.log(`[AndroidVideoPlayer] Watch progress save interval set to ${syncInterval}ms`); + setProgressSaveInterval(interval); return () => { clearInterval(interval); setProgressSaveInterval(null); }; } - }, [id, type, paused, currentTime, duration]); + }, [id, type, paused, currentTime, duration, traktSettings.syncFrequency]); useEffect(() => { return () => { @@ -345,25 +424,30 @@ const AndroidVideoPlayer: React.FC = () => { }; }, [id, type, currentTime, duration]); - const seekToTime = (timeInSeconds: number) => { + const seekToTime = (rawSeconds: number) => { + // Clamp to just before the end of the media. + const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds)); if (videoRef.current && duration > 0 && !isSeeking.current) { if (DEBUG_MODE) { - logger.log(`[AndroidVideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s`); + logger.log(`[AndroidVideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`); } isSeeking.current = true; setSeekTime(timeInSeconds); - // Clear seek state after seek + // Clear seek state after seek with longer timeout setTimeout(() => { if (isMounted.current) { setSeekTime(null); isSeeking.current = false; + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Seek completed to ${timeInSeconds.toFixed(2)}s`); } - }, 100); + } + }, 500); } else { if (DEBUG_MODE) { - logger.error('[AndroidVideoPlayer] Seek failed: Player not ready, duration is zero, or already seeking.'); + logger.error(`[AndroidVideoPlayer] Seek failed: videoRef=${!!videoRef.current}, duration=${duration}, seeking=${isSeeking.current}`); } } }; @@ -402,8 +486,8 @@ const AndroidVideoPlayer: React.FC = () => { const processProgressTouch = (locationX: number, isDragging = false) => { progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { - const percentage = Math.max(0, Math.min(locationX / width, 1)); - const seekTime = percentage * duration; + const percentage = Math.max(0, Math.min(locationX / width, 0.999)); + const seekTime = Math.min(percentage * duration, duration - END_EPSILON); progressAnim.setValue(percentage); if (isDragging) { pendingSeekValue.current = seekTime; @@ -419,13 +503,13 @@ const AndroidVideoPlayer: React.FC = () => { const currentTimeInSeconds = data.currentTime; - // Only update if there's a significant change to avoid unnecessary updates - if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { + // Update time more frequently for subtitle synchronization (0.1s threshold) + if (Math.abs(currentTimeInSeconds - currentTime) > 0.1) { safeSetState(() => setCurrentTime(currentTimeInSeconds)); const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0; Animated.timing(progressAnim, { toValue: progressPercent, - duration: 250, + duration: 100, useNativeDriver: false, }).start(); const bufferedTime = data.playableDuration || currentTimeInSeconds; @@ -441,6 +525,17 @@ const AndroidVideoPlayer: React.FC = () => { const videoDuration = data.duration; if (data.duration > 0) { setDuration(videoDuration); + + // Store the actual duration for future reference and update existing progress + if (id && type) { + storageService.setContentDuration(id, type, videoDuration, episodeId); + storageService.updateProgressDuration(id, type, videoDuration, episodeId); + + // Update the saved duration for resume overlay if it was using an estimate + if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) { + setSavedDuration(videoDuration); + } + } } // Set aspect ratio from video dimensions @@ -489,24 +584,26 @@ const AndroidVideoPlayer: React.FC = () => { }, 1000); } completeOpeningAnimation(); + controlsTimeout.current = setTimeout(hideControls, 5000); } }; const skip = (seconds: number) => { if (videoRef.current) { - const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); + const newTime = Math.max(0, Math.min(currentTime + seconds, duration - END_EPSILON)); seekToTime(newTime); } }; const cycleAspectRatio = () => { - const newZoom = zoomScale === 1.1 ? 1 : 1.1; - setZoomScale(newZoom); - setZoomTranslateX(0); - setZoomTranslateY(0); - setLastZoomScale(newZoom); - setLastTranslateX(0); - setLastTranslateY(0); + // Android: cycle through native resize modes + const resizeModes: ResizeModeType[] = ['contain', 'cover', 'fill', 'none']; + const currentIndex = resizeModes.indexOf(resizeMode); + const nextIndex = (currentIndex + 1) % resizeModes.length; + setResizeMode(resizeModes[nextIndex]); + if (DEBUG_MODE) { + logger.log(`[AndroidVideoPlayer] Resize mode changed to: ${resizeModes[nextIndex]}`); + } }; const enableImmersiveMode = () => { @@ -532,136 +629,119 @@ const AndroidVideoPlayer: React.FC = () => { } }; - const handleClose = () => { + const handleClose = async () => { logger.log('[AndroidVideoPlayer] Close button pressed - syncing to Trakt before closing'); - logger.log(`[AndroidVideoPlayer] Current progress: ${currentTime}/${duration} (${duration > 0 ? ((currentTime / duration) * 100).toFixed(1) : 0}%)`); - // Sync progress to Trakt before closing - traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount'); + // Set syncing state to prevent multiple close attempts + setIsSyncingBeforeClose(true); - // Start exit animation - Animated.parallel([ - Animated.timing(fadeAnim, { - toValue: 0, - duration: 150, - useNativeDriver: true, - }), - Animated.timing(openingFadeAnim, { - toValue: 0, - duration: 150, - useNativeDriver: true, - }), - ]).start(); - - // Small delay to allow animation to start, then unlock orientation and navigate - setTimeout(() => { - ScreenOrientation.unlockAsync().then(() => { - disableImmersiveMode(); - navigation.goBack(); - }).catch(() => { - // Fallback: navigate even if orientation unlock fails - disableImmersiveMode(); - navigation.goBack(); - }); - }, 100); - }; + // Make sure we have the most accurate current time + const actualCurrentTime = currentTime; + const progressPercent = duration > 0 ? (actualCurrentTime / duration) * 100 : 0; + + logger.log(`[AndroidVideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`); - useEffect(() => { - const loadResumePreference = async () => { - try { - logger.log(`[AndroidVideoPlayer] Loading resume preference, resumePosition=${resumePosition}`); - const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - logger.log(`[AndroidVideoPlayer] Resume preference loaded: ${pref}`); - - if (pref) { - setResumePreference(pref); - if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { - logger.log(`[AndroidVideoPlayer] Auto-resuming due to preference`); - setShowResumeOverlay(false); - setInitialPosition(resumePosition); - } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { - logger.log(`[AndroidVideoPlayer] Auto-starting over due to preference`); - setShowResumeOverlay(false); - setInitialPosition(0); - } - // Don't override overlay if no specific preference or preference doesn't match - } else { - logger.log(`[AndroidVideoPlayer] No resume preference found, keeping overlay state`); - } - } catch (error) { - logger.error('[AndroidVideoPlayer] Error loading resume preference:', error); - } - }; - loadResumePreference(); - }, [resumePosition]); - - const resetResumePreference = async () => { try { - await AsyncStorage.removeItem(RESUME_PREF_KEY); - setResumePreference(null); + // Force one last progress update (scrobble/pause) with the exact time + await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true); + + // Sync progress to Trakt before closing + await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount'); + + // Start exit animation + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + Animated.timing(openingFadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(); + + // Longer delay to ensure Trakt sync completes + setTimeout(() => { + ScreenOrientation.unlockAsync().then(() => { + disableImmersiveMode(); + navigation.goBack(); + }).catch(() => { + // Fallback: navigate even if orientation unlock fails + disableImmersiveMode(); + navigation.goBack(); + }); + }, 500); // Increased from 100ms to 500ms } catch (error) { - logger.error('[AndroidVideoPlayer] Error resetting resume preference:', error); + logger.error('[AndroidVideoPlayer] Error syncing to Trakt before closing:', error); + // Navigate anyway even if sync fails + disableImmersiveMode(); + navigation.goBack(); } }; const handleResume = async () => { - if (resumePosition !== null && videoRef.current) { - if (rememberChoice) { - try { - await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); - } catch (error) { - logger.error('[AndroidVideoPlayer] Error saving resume preference:', error); - } - } - setInitialPosition(resumePosition); - setShowResumeOverlay(false); - setTimeout(() => { - if (videoRef.current) { - seekToTime(resumePosition); - } - }, 500); + if (resumePosition) { + seekToTime(resumePosition); } + setShowResumeOverlay(false); }; const handleStartFromBeginning = async () => { - if (rememberChoice) { - try { - await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER); - } catch (error) { - logger.error('[AndroidVideoPlayer] Error saving resume preference:', error); - } - } + seekToTime(0); setShowResumeOverlay(false); - setInitialPosition(0); - if (videoRef.current) { - seekToTime(0); - setCurrentTime(0); - } }; const toggleControls = () => { - setShowControls(previousState => !previousState); + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + controlsTimeout.current = null; + } + + setShowControls(prevShowControls => { + const newShowControls = !prevShowControls; + Animated.timing(fadeAnim, { + toValue: newShowControls ? 1 : 0, + duration: 300, + useNativeDriver: true, + }).start(); + if (newShowControls) { + controlsTimeout.current = setTimeout(hideControls, 5000); + } + return newShowControls; + }); }; - useEffect(() => { - Animated.timing(fadeAnim, { - toValue: showControls ? 1 : 0, - duration: 300, - useNativeDriver: true, - }).start(); - }, [showControls]); - const handleError = (error: any) => { - logger.error('[AndroidVideoPlayer] Playback Error:', error); + logger.error('AndroidVideoPlayer error: ', error); }; const onBuffer = (data: any) => { setIsBuffering(data.isBuffering); }; - const onEnd = () => { - // Sync final progress to Trakt - traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended'); + const onEnd = async () => { + // Make sure we report 100% progress to Trakt + const finalTime = duration; + setCurrentTime(finalTime); + + try { + // Force one last progress update (scrobble/pause) with the exact final time + logger.log('[AndroidVideoPlayer] Video ended naturally, sending final progress update with 100%'); + await traktAutosync.handleProgressUpdate(finalTime, duration, true); + + // Small delay to ensure the progress update is processed + await new Promise(resolve => setTimeout(resolve, 300)); + + // Now send the stop call + logger.log('[AndroidVideoPlayer] Sending final stop call after natural end'); + await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended'); + + logger.log('[AndroidVideoPlayer] Completed video end sync to Trakt'); + } catch (error) { + logger.error('[AndroidVideoPlayer] Error syncing to Trakt on video end:', error); + } }; const selectAudioTrack = (trackId: number) => { @@ -761,7 +841,14 @@ const AndroidVideoPlayer: React.FC = () => { const togglePlayback = () => { if (videoRef.current) { - setPaused(!paused); + const newPausedState = !paused; + setPaused(newPausedState); + + // Send a forced pause update to Trakt immediately when user pauses + if (newPausedState && duration > 0) { + traktAutosync.handleProgressUpdate(currentTime, duration, true); + logger.log('[AndroidVideoPlayer] Sent forced pause update to Trakt'); + } } }; @@ -809,6 +896,10 @@ const AndroidVideoPlayer: React.FC = () => { saveSubtitleSize(newSize); }; + const toggleSubtitleBackground = () => { + setSubtitleBackground(!subtitleBackground); + }; + useEffect(() => { if (pendingSeek && isPlayerReady && isVideoLoaded && duration > 0) { logger.log(`[AndroidVideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`); @@ -931,6 +1022,25 @@ const AndroidVideoPlayer: React.FC = () => { ]} pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} > + {backdrop && ( + <Image + source={{ uri: backdrop }} + style={[StyleSheet.absoluteFill, { width: screenDimensions.width, height: screenDimensions.height }]} + resizeMode="cover" + blurRadius={0} + /> + )} + <LinearGradient + colors={[ + 'rgba(0,0,0,0.3)', + 'rgba(0,0,0,0.6)', + 'rgba(0,0,0,0.8)', + 'rgba(0,0,0,0.9)' + ]} + locations={[0, 0.3, 0.7, 1]} + style={StyleSheet.absoluteFill} + /> + <TouchableOpacity style={styles.loadingCloseButton} onPress={handleClose} @@ -940,8 +1050,28 @@ const AndroidVideoPlayer: React.FC = () => { </TouchableOpacity> <View style={styles.openingContent}> + {hasLogo ? ( + <Animated.View style={{ + transform: [ + { scale: Animated.multiply(logoScaleAnim, pulseAnim) } + ], + opacity: logoOpacityAnim, + alignItems: 'center', + }}> + <Image + source={{ uri: metadata.logo }} + style={{ + width: 300, + height: 180, + resizeMode: 'contain', + }} + /> + </Animated.View> + ) : ( + <> <ActivityIndicator size="large" color="#E50914" /> - <Text style={styles.openingText}>Loading video...</Text> + </> + )} </View> </Animated.View> @@ -1025,7 +1155,7 @@ const AndroidVideoPlayer: React.FC = () => { playWhenInactive={false} ignoreSilentSwitch="ignore" mixWithOthers="inherit" - progressUpdateInterval={1000} + progressUpdateInterval={250} /> </TouchableOpacity> </View> @@ -1046,6 +1176,7 @@ const AndroidVideoPlayer: React.FC = () => { currentTime={currentTime} duration={duration} zoomScale={zoomScale} + currentResizeMode={resizeMode} vlcAudioTracks={rnVideoAudioTracks} selectedAudioTrack={selectedAudioTrack} availableStreams={availableStreams} @@ -1070,19 +1201,17 @@ const AndroidVideoPlayer: React.FC = () => { useCustomSubtitles={useCustomSubtitles} currentSubtitle={currentSubtitle} subtitleSize={subtitleSize} + subtitleBackground={subtitleBackground} + zoomScale={zoomScale} /> <ResumeOverlay showResumeOverlay={showResumeOverlay} resumePosition={resumePosition} - duration={duration} - title={title} + duration={savedDuration || duration} + title={episodeTitle || title} season={season} episode={episode} - rememberChoice={rememberChoice} - setRememberChoice={setRememberChoice} - resumePreference={resumePreference} - resetResumePreference={resetResumePreference} handleResume={handleResume} handleStartFromBeginning={handleStartFromBeginning} /> @@ -1109,11 +1238,13 @@ const AndroidVideoPlayer: React.FC = () => { selectedTextTrack={selectedTextTrack} useCustomSubtitles={useCustomSubtitles} subtitleSize={subtitleSize} + subtitleBackground={subtitleBackground} fetchAvailableSubtitles={fetchAvailableSubtitles} loadWyzieSubtitle={loadWyzieSubtitle} selectTextTrack={selectTextTrack} increaseSubtitleSize={increaseSubtitleSize} decreaseSubtitleSize={decreaseSubtitleSize} + toggleSubtitleBackground={toggleSubtitleBackground} /> <SourcesModal diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index f5d2857..9c1e078 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -1,8 +1,8 @@ import React, { useState, useRef, useEffect } from 'react'; -import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } from 'react-native'; +import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, Image, StyleSheet } from 'react-native'; import { VLCPlayer } from 'react-native-vlc-media-player'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { RootStackParamList } from '../../navigation/AppNavigator'; +import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator'; import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler'; import RNImmersiveMode from 'react-native-immersive-mode'; import * as ScreenOrientation from 'expo-screen-orientation'; @@ -10,15 +10,19 @@ import { storageService } from '../../services/storageService'; import { logger } from '../../utils/logger'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { MaterialIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; import AndroidVideoPlayer from './AndroidVideoPlayer'; import { useTraktAutosync } from '../../hooks/useTraktAutosync'; +import { useTraktAutosyncSettings } from '../../hooks/useTraktAutosyncSettings'; +import { useMetadata } from '../../hooks/useMetadata'; +import { useSettings } from '../../hooks/useSettings'; -import { - DEFAULT_SUBTITLE_SIZE, +import { + DEFAULT_SUBTITLE_SIZE, AudioTrack, TextTrack, - ResizeModeType, - WyzieSubtitle, + ResizeModeType, + WyzieSubtitle, SubtitleCue, RESUME_PREF_KEY, RESUME_PREF, @@ -26,12 +30,12 @@ import { } from './utils/playerTypes'; import { safeDebugLog, parseSRT, DEBUG_MODE, formatTime } from './utils/playerUtils'; import { styles } from './utils/playerStyles'; -import SubtitleModals from './modals/SubtitleModals'; -import AudioTrackModal from './modals/AudioTrackModal'; +import { SubtitleModals } from './modals/SubtitleModals'; +import { AudioTrackModal } from './modals/AudioTrackModal'; import ResumeOverlay from './modals/ResumeOverlay'; import PlayerControls from './controls/PlayerControls'; import CustomSubtitles from './subtitles/CustomSubtitles'; -import SourcesModal from './modals/SourcesModal'; +import { SourcesModal } from './modals/SourcesModal'; const VideoPlayer: React.FC = () => { // If on Android, use the AndroidVideoPlayer component @@ -39,9 +43,9 @@ const VideoPlayer: React.FC = () => { return <AndroidVideoPlayer />; } - const navigation = useNavigation(); + const navigation = useNavigation<RootStackNavigationProp>(); const route = useRoute<RouteProp<RootStackParamList, 'Player'>>(); - + const { uri, title = 'Episode Name', @@ -56,7 +60,8 @@ const VideoPlayer: React.FC = () => { type, episodeId, imdbId, - availableStreams: passedAvailableStreams + availableStreams: passedAvailableStreams, + backdrop } = route.params; // Initialize Trakt autosync @@ -74,6 +79,9 @@ const VideoPlayer: React.FC = () => { episodeId: episodeId }); + // Get the Trakt autosync settings to use the user-configured sync frequency + const { settings: traktSettings } = useTraktAutosyncSettings(); + safeDebugLog("Component mounted with props", { uri, title, season, episode, episodeTitle, quality, year, streamProvider, id, type, episodeId, imdbId @@ -82,6 +90,14 @@ const VideoPlayer: React.FC = () => { const screenData = Dimensions.get('screen'); const [screenDimensions, setScreenDimensions] = useState(screenData); + // iPad-specific fullscreen handling + const isIPad = Platform.OS === 'ios' && (screenData.width > 1000 || screenData.height > 1000); + const shouldUseFullscreen = isIPad; + + // Use window dimensions for iPad instead of screen dimensions + const windowData = Dimensions.get('window'); + const effectiveDimensions = shouldUseFullscreen ? windowData : screenData; + const [paused, setPaused] = useState(false); const [currentTime, setCurrentTime] = useState(0); const [duration, setDuration] = useState(0); @@ -101,16 +117,15 @@ const VideoPlayer: React.FC = () => { const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false); const [showResumeOverlay, setShowResumeOverlay] = useState(false); const [resumePosition, setResumePosition] = useState<number | null>(null); - const [rememberChoice, setRememberChoice] = useState(false); - const [resumePreference, setResumePreference] = useState<string | null>(null); + const [savedDuration, setSavedDuration] = useState<number | null>(null); const fadeAnim = useRef(new Animated.Value(1)).current; const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false); const openingFadeAnim = useRef(new Animated.Value(0)).current; const openingScaleAnim = useRef(new Animated.Value(0.8)).current; const backgroundFadeAnim = useRef(new Animated.Value(1)).current; const [isBuffering, setIsBuffering] = useState(false); - const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); - const [vlcTextTracks, setVlcTextTracks] = useState<Array<{id: number, name: string, language?: string}>>([]); + const [vlcAudioTracks, setVlcAudioTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]); + const [vlcTextTracks, setVlcTextTracks] = useState<Array<{ id: number, name: string, language?: string }>>([]); const [isPlayerReady, setIsPlayerReady] = useState(false); const progressAnim = useRef(new Animated.Value(0)).current; const progressBarRef = useRef<View>(null); @@ -133,6 +148,7 @@ const VideoPlayer: React.FC = () => { const [customSubtitles, setCustomSubtitles] = useState<SubtitleCue[]>([]); const [currentSubtitle, setCurrentSubtitle] = useState<string>(''); const [subtitleSize, setSubtitleSize] = useState<number>(DEFAULT_SUBTITLE_SIZE); + const [subtitleBackground, setSubtitleBackground] = useState<boolean>(true); const [useCustomSubtitles, setUseCustomSubtitles] = useState<boolean>(false); const [isLoadingSubtitles, setIsLoadingSubtitles] = useState<boolean>(false); const [availableSubtitles, setAvailableSubtitles] = useState<WyzieSubtitle[]>([]); @@ -147,6 +163,37 @@ const VideoPlayer: React.FC = () => { const [currentStreamProvider, setCurrentStreamProvider] = useState<string | undefined>(streamProvider); const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName); const isMounted = useRef(true); + const controlsTimeout = useRef<NodeJS.Timeout | null>(null); + const [isSyncingBeforeClose, setIsSyncingBeforeClose] = useState(false); + + // Get metadata to access logo (only if we have a valid id) + const shouldLoadMetadata = Boolean(id && type); + const metadataResult = useMetadata({ + id: id || 'placeholder', + type: type || 'movie' + }); + const { metadata, loading: metadataLoading } = shouldLoadMetadata ? metadataResult : { metadata: null, loading: false }; + const { settings } = useSettings(); + + // Logo animation values + const logoScaleAnim = useRef(new Animated.Value(0.8)).current; + const logoOpacityAnim = useRef(new Animated.Value(0)).current; + const pulseAnim = useRef(new Animated.Value(1)).current; + + // Check if we have a logo to show + const hasLogo = metadata && metadata.logo && !metadataLoading; + + // Small offset (in seconds) used to avoid seeking to the *exact* end of the + // file which triggers the `onEnd` callback and causes playback to restart. + const END_EPSILON = 0.3; + + const hideControls = () => { + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + useNativeDriver: true, + }).start(() => setShowControls(false)); + }; const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => { return { @@ -186,25 +233,47 @@ const VideoPlayer: React.FC = () => { }; useEffect(() => { - if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) { + if (videoAspectRatio && effectiveDimensions.width > 0 && effectiveDimensions.height > 0) { const styles = calculateVideoStyles( videoAspectRatio * 1000, 1000, - screenDimensions.width, - screenDimensions.height + effectiveDimensions.width, + effectiveDimensions.height ); setCustomVideoStyles(styles); if (DEBUG_MODE) { logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles); } } - }, [screenDimensions, videoAspectRatio]); + }, [effectiveDimensions, videoAspectRatio]); + + // Force landscape orientation immediately when component mounts + useEffect(() => { + const lockOrientation = async () => { + try { + await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); + logger.log('[VideoPlayer] Locked to landscape orientation'); + } catch (error) { + logger.warn('[VideoPlayer] Failed to lock orientation:', error); + } + }; + + // Lock orientation immediately + lockOrientation(); + + return () => { + // Unlock orientation when component unmounts + ScreenOrientation.unlockAsync().catch(() => { + // Ignore unlock errors + }); + }; + }, []); useEffect(() => { const subscription = Dimensions.addEventListener('change', ({ screen }) => { setScreenDimensions(screen); }); - const initializePlayer = () => { + const initializePlayer = async () => { StatusBar.setHidden(true, 'none'); enableImmersiveMode(); startOpeningAnimation(); @@ -212,16 +281,56 @@ const VideoPlayer: React.FC = () => { initializePlayer(); return () => { subscription?.remove(); - const unlockOrientation = async () => { - await ScreenOrientation.unlockAsync(); - }; - unlockOrientation(); disableImmersiveMode(); }; }, []); const startOpeningAnimation = () => { - // Animation logic here + // Logo entrance animation + Animated.parallel([ + Animated.timing(logoOpacityAnim, { + toValue: 1, + duration: 600, + useNativeDriver: true, + }), + Animated.spring(logoScaleAnim, { + toValue: 1, + tension: 50, + friction: 8, + useNativeDriver: true, + }), + ]).start(); + + // Continuous pulse animation for the logo + const createPulseAnimation = () => { + return Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1.05, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + ]); + }; + + const loopPulse = () => { + createPulseAnimation().start(() => { + if (!isOpeningAnimationComplete) { + loopPulse(); + } + }); + }; + + // Start pulsing after a short delay + setTimeout(() => { + if (!isOpeningAnimationComplete) { + loopPulse(); + } + }, 800); }; const completeOpeningAnimation = () => { @@ -258,34 +367,17 @@ const VideoPlayer: React.FC = () => { logger.log(`[VideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`); const savedProgress = await storageService.getWatchProgress(id, type, episodeId); logger.log(`[VideoPlayer] Saved progress:`, savedProgress); - + if (savedProgress) { const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; logger.log(`[VideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`); - + if (progressPercent < 85) { setResumePosition(savedProgress.currentTime); - logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime}`); - - const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - logger.log(`[VideoPlayer] Resume preference: ${pref}`); - - // TEMPORARY: Clear the preference to test overlay - if (pref) { - await AsyncStorage.removeItem(RESUME_PREF_KEY); - logger.log(`[VideoPlayer] CLEARED resume preference for testing`); - setShowResumeOverlay(true); - logger.log(`[VideoPlayer] Showing resume overlay after clearing preference`); - } else if (pref === RESUME_PREF.ALWAYS_RESUME) { - setInitialPosition(savedProgress.currentTime); - logger.log(`[VideoPlayer] Auto-resuming due to preference`); - } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { - setInitialPosition(0); - logger.log(`[VideoPlayer] Auto-starting over due to preference`); - } else { - setShowResumeOverlay(true); - logger.log(`[VideoPlayer] Showing resume overlay`); - } + setSavedDuration(savedProgress.duration); + logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`); + setShowResumeOverlay(true); + logger.log(`[VideoPlayer] Showing resume overlay`); } else { logger.log(`[VideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`); } @@ -311,7 +403,7 @@ const VideoPlayer: React.FC = () => { }; try { await storageService.setWatchProgress(id, type, progress, episodeId); - + // Sync to Trakt if authenticated await traktAutosync.handleProgressUpdate(currentTime, duration); } catch (error) { @@ -319,22 +411,30 @@ const VideoPlayer: React.FC = () => { } } }; - + useEffect(() => { if (id && type && !paused && duration > 0) { if (progressSaveInterval) { clearInterval(progressSaveInterval); } + + // Use the user's configured sync frequency with increased minimum to reduce heating + // Minimum interval increased from 5s to 30s to reduce CPU usage + const syncInterval = Math.max(30000, traktSettings.syncFrequency); + const interval = setInterval(() => { saveWatchProgress(); - }, 5000); + }, syncInterval); + + logger.log(`[VideoPlayer] Watch progress save interval set to ${syncInterval}ms`); + setProgressSaveInterval(interval); return () => { clearInterval(interval); setProgressSaveInterval(null); }; } - }, [id, type, paused, currentTime, duration]); + }, [id, type, paused, currentTime, duration, traktSettings.syncFrequency]); useEffect(() => { return () => { @@ -345,59 +445,76 @@ const VideoPlayer: React.FC = () => { } }; }, [id, type, currentTime, duration]); - + const onPlaying = () => { if (isMounted.current && !isSeeking.current) { setPaused(false); - - // Start Trakt watching session only if duration is loaded - if (duration > 0) { - traktAutosync.handlePlaybackStart(currentTime, duration); - } + + // Note: handlePlaybackStart is already called in onLoad + // We don't need to call it again here to avoid duplicate calls } }; const onPaused = () => { if (isMounted.current) { setPaused(true); + + // Send a forced pause update to Trakt immediately when user pauses + if (duration > 0) { + traktAutosync.handleProgressUpdate(currentTime, duration, true); + logger.log('[VideoPlayer] Sent forced pause update to Trakt'); + } } }; - const seekToTime = (timeInSeconds: number) => { + const seekToTime = (rawSeconds: number) => { + // Clamp to just before the end to avoid triggering onEnd. + const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds)); if (vlcRef.current && duration > 0 && !isSeeking.current) { if (DEBUG_MODE) { - logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s`); + logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`); } - + isSeeking.current = true; - + // For Android, use direct seeking on VLC player ref instead of seek prop if (Platform.OS === 'android' && vlcRef.current.seek) { // Calculate position as fraction const position = timeInSeconds / duration; vlcRef.current.seek(position); - // Clear seek state after Android seek setTimeout(() => { if (isMounted.current) { isSeeking.current = false; + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] Android seek completed to ${timeInSeconds.toFixed(2)}s`); + } } - }, 300); + }, 500); } else { - // iOS fallback - use seek prop - const position = timeInSeconds / duration; - setSeekPosition(position); - + // iOS (and other platforms) – prefer direct seek on the ref to avoid re-mounts caused by the `seek` prop + const position = timeInSeconds / duration; // VLC expects a 0-1 fraction + if (vlcRef.current && typeof vlcRef.current.seek === 'function') { + vlcRef.current.seek(position); + } else { + // Fallback to legacy behaviour only if direct seek is unavailable + setSeekPosition(position); + } + setTimeout(() => { if (isMounted.current) { + // Reset temporary seek state setSeekPosition(null); isSeeking.current = false; + if (DEBUG_MODE) { + logger.log(`[VideoPlayer] iOS seek completed to ${timeInSeconds.toFixed(2)}s`); + } } }, 500); } } else { if (DEBUG_MODE) { - logger.error('[VideoPlayer] Seek failed: Player not ready, duration is zero, or already seeking.'); + logger.error(`[VideoPlayer] Seek failed: vlcRef=${!!vlcRef.current}, duration=${duration}, seeking=${isSeeking.current}`); } } }; @@ -408,17 +525,17 @@ const VideoPlayer: React.FC = () => { processProgressTouch(locationX); } }; - + const handleProgressBarDragStart = () => { setIsDragging(true); }; - + const handleProgressBarDragMove = (event: any) => { if (!isDragging || !duration || duration <= 0) return; const { locationX } = event.nativeEvent; processProgressTouch(locationX, true); }; - + const handleProgressBarDragEnd = () => { setIsDragging(false); if (pendingSeekValue.current !== null) { @@ -426,11 +543,11 @@ const VideoPlayer: React.FC = () => { pendingSeekValue.current = null; } }; - + const processProgressTouch = (locationX: number, isDragging = false) => { progressBarRef.current?.measure((x, y, width, height, pageX, pageY) => { - const percentage = Math.max(0, Math.min(locationX / width, 1)); - const seekTime = percentage * duration; + const percentage = Math.max(0, Math.min(locationX / width, 0.999)); + const seekTime = Math.min(percentage * duration, duration - END_EPSILON); progressAnim.setValue(percentage); if (isDragging) { pendingSeekValue.current = seekTime; @@ -443,9 +560,9 @@ const VideoPlayer: React.FC = () => { const handleProgress = (event: any) => { if (isDragging || isSeeking.current) return; - + const currentTimeInSeconds = event.currentTime / 1000; - + // Only update if there's a significant change to avoid unnecessary updates if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) { safeSetState(() => setCurrentTime(currentTimeInSeconds)); @@ -468,6 +585,17 @@ const VideoPlayer: React.FC = () => { const videoDuration = data.duration / 1000; if (data.duration > 0) { setDuration(videoDuration); + + // Store the actual duration for future reference and update existing progress + if (id && type) { + storageService.setContentDuration(id, type, videoDuration, episodeId); + storageService.updateProgressDuration(id, type, videoDuration, episodeId); + + // Update the saved duration for resume overlay if it was using an estimate + if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) { + setSavedDuration(videoDuration); + } + } } setVideoAspectRatio(data.videoSize.width / data.videoSize.height); @@ -480,12 +608,12 @@ const VideoPlayer: React.FC = () => { setIsVideoLoaded(true); setIsPlayerReady(true); - + // Start Trakt watching session when video loads with proper duration if (videoDuration > 0) { traktAutosync.handlePlaybackStart(currentTime, videoDuration); } - + if (initialPosition && !isInitialSeekComplete) { logger.log(`[VideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`); setTimeout(() => { @@ -499,12 +627,13 @@ const VideoPlayer: React.FC = () => { }, 1000); } completeOpeningAnimation(); + controlsTimeout.current = setTimeout(hideControls, 5000); } }; const skip = (seconds: number) => { if (vlcRef.current) { - const newTime = Math.max(0, Math.min(currentTime + seconds, duration)); + const newTime = Math.max(0, Math.min(currentTime + seconds, duration - END_EPSILON)); seekToTime(newTime); } }; @@ -550,124 +679,120 @@ const VideoPlayer: React.FC = () => { } }; - const handleClose = () => { + const handleClose = async () => { logger.log('[VideoPlayer] Close button pressed - syncing to Trakt before closing'); - logger.log(`[VideoPlayer] Current progress: ${currentTime}/${duration} (${duration > 0 ? ((currentTime / duration) * 100).toFixed(1) : 0}%)`); - - // Sync progress to Trakt before closing - traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount'); - - // Start exit animation - Animated.parallel([ - Animated.timing(fadeAnim, { - toValue: 0, - duration: 150, - useNativeDriver: true, - }), - Animated.timing(openingFadeAnim, { - toValue: 0, - duration: 150, - useNativeDriver: true, - }), - ]).start(); - // Small delay to allow animation to start, then unlock orientation and navigate - setTimeout(() => { - ScreenOrientation.unlockAsync().then(() => { - disableImmersiveMode(); - navigation.goBack(); - }).catch(() => { - // Fallback: navigate even if orientation unlock fails - disableImmersiveMode(); - navigation.goBack(); - }); - }, 100); - }; - - useEffect(() => { - const loadResumePreference = async () => { - try { - logger.log(`[VideoPlayer] Loading resume preference, resumePosition=${resumePosition}`); - const pref = await AsyncStorage.getItem(RESUME_PREF_KEY); - logger.log(`[VideoPlayer] Resume preference loaded: ${pref}`); - - if (pref) { - setResumePreference(pref); - if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) { - logger.log(`[VideoPlayer] Auto-resuming due to preference`); - setShowResumeOverlay(false); - setInitialPosition(resumePosition); - } else if (pref === RESUME_PREF.ALWAYS_START_OVER) { - logger.log(`[VideoPlayer] Auto-starting over due to preference`); - setShowResumeOverlay(false); - setInitialPosition(0); - } - // Don't override overlay if no specific preference or preference doesn't match - } else { - logger.log(`[VideoPlayer] No resume preference found, keeping overlay state`); - } - } catch (error) { - logger.error('[VideoPlayer] Error loading resume preference:', error); - } - }; - loadResumePreference(); - }, [resumePosition]); + // Set syncing state to prevent multiple close attempts + setIsSyncingBeforeClose(true); + + // Make sure we have the most accurate current time + const actualCurrentTime = currentTime; + const progressPercent = duration > 0 ? (actualCurrentTime / duration) * 100 : 0; + + logger.log(`[VideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`); - const resetResumePreference = async () => { try { - await AsyncStorage.removeItem(RESUME_PREF_KEY); - setResumePreference(null); + // Force one last progress update (scrobble/pause) with the exact time + await traktAutosync.handleProgressUpdate(actualCurrentTime, duration, true); + + // Sync progress to Trakt before closing + await traktAutosync.handlePlaybackEnd(actualCurrentTime, duration, 'unmount'); + + // Start exit animation + Animated.parallel([ + Animated.timing(fadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + Animated.timing(openingFadeAnim, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(); + + // Cleanup and navigate back + const cleanup = async () => { + try { + // Unlock orientation first + await ScreenOrientation.unlockAsync(); + logger.log('[VideoPlayer] Orientation unlocked'); + } catch (orientationError) { + logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError); + } + + // Disable immersive mode + disableImmersiveMode(); + + // Navigate back with proper handling for fullscreen modal + try { + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + // Fallback: navigate to main tabs if can't go back + navigation.navigate('MainTabs'); + } + logger.log('[VideoPlayer] Navigation completed'); + } catch (navError) { + logger.error('[VideoPlayer] Navigation error:', navError); + // Last resort: try to navigate to home + navigation.navigate('MainTabs'); + } + }; + + // Delay to ensure Trakt sync completes and animations finish + setTimeout(cleanup, 500); + } catch (error) { - logger.error('[VideoPlayer] Error resetting resume preference:', error); + logger.error('[VideoPlayer] Error syncing to Trakt before closing:', error); + // Navigate anyway even if sync fails + disableImmersiveMode(); + try { + await ScreenOrientation.unlockAsync(); + } catch (orientationError) { + // Ignore orientation unlock errors + } + + if (navigation.canGoBack()) { + navigation.goBack(); + } else { + navigation.navigate('MainTabs'); + } } }; const handleResume = async () => { - if (resumePosition !== null && vlcRef.current) { - if (rememberChoice) { - try { - await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME); - } catch (error) { - logger.error('[VideoPlayer] Error saving resume preference:', error); - } - } - setInitialPosition(resumePosition); - setShowResumeOverlay(false); - setTimeout(() => { - if (vlcRef.current) { - seekToTime(resumePosition); - } - }, 500); + if (resumePosition) { + seekToTime(resumePosition); } + setShowResumeOverlay(false); }; const handleStartFromBeginning = async () => { - if (rememberChoice) { - try { - await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER); - } catch (error) { - logger.error('[VideoPlayer] Error saving resume preference:', error); - } - } + seekToTime(0); setShowResumeOverlay(false); - setInitialPosition(0); - if (vlcRef.current) { - seekToTime(0); - setCurrentTime(0); - } }; const toggleControls = () => { - setShowControls(previousState => !previousState); - }; + if (controlsTimeout.current) { + clearTimeout(controlsTimeout.current); + controlsTimeout.current = null; + } - useEffect(() => { - Animated.timing(fadeAnim, { - toValue: showControls ? 1 : 0, - duration: 300, - useNativeDriver: true, - }).start(); - }, [showControls]); + setShowControls(prevShowControls => { + const newShowControls = !prevShowControls; + Animated.timing(fadeAnim, { + toValue: newShowControls ? 1 : 0, + duration: 300, + useNativeDriver: true, + }).start(); + if (newShowControls) { + controlsTimeout.current = setTimeout(hideControls, 5000); + } + return newShowControls; + }); + }; const handleError = (error: any) => { logger.error('[VideoPlayer] Playback Error:', error); @@ -677,9 +802,27 @@ const VideoPlayer: React.FC = () => { setIsBuffering(event.isBuffering); }; - const onEnd = () => { - // Sync final progress to Trakt - traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended'); + const onEnd = async () => { + // Make sure we report 100% progress to Trakt + const finalTime = duration; + setCurrentTime(finalTime); + + try { + // Force one last progress update (scrobble/pause) with the exact final time + logger.log('[VideoPlayer] Video ended naturally, sending final progress update with 100%'); + await traktAutosync.handleProgressUpdate(finalTime, duration, true); + + // Small delay to ensure the progress update is processed + await new Promise(resolve => setTimeout(resolve, 300)); + + // Now send the stop call + logger.log('[VideoPlayer] Sending final stop call after natural end'); + await traktAutosync.handlePlaybackEnd(finalTime, duration, 'ended'); + + logger.log('[VideoPlayer] Completed video end sync to Trakt'); + } catch (error) { + logger.error('[VideoPlayer] Error syncing to Trakt on video end:', error); + } }; const selectAudioTrack = (trackId: number) => { @@ -695,7 +838,7 @@ const VideoPlayer: React.FC = () => { setSelectedTextTrack(trackId); } }; - + const loadSubtitleSize = async () => { try { const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY); @@ -740,8 +883,8 @@ const VideoPlayer: React.FC = () => { uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display)); setAvailableSubtitles(uniqueSubtitles); if (autoSelectEnglish) { - const englishSubtitle = uniqueSubtitles.find(sub => - sub.language.toLowerCase() === 'eng' || + const englishSubtitle = uniqueSubtitles.find(sub => + sub.language.toLowerCase() === 'eng' || sub.language.toLowerCase() === 'en' || sub.display.toLowerCase().includes('english') ); @@ -776,10 +919,10 @@ const VideoPlayer: React.FC = () => { setIsLoadingSubtitles(false); } }; - + const togglePlayback = () => { if (vlcRef.current) { - setPaused(!paused); + setPaused(!paused); } }; @@ -792,7 +935,7 @@ const VideoPlayer: React.FC = () => { } }; }, []); - + const safeSetState = (setter: any) => { if (isMounted.current) { setter(); @@ -806,7 +949,7 @@ const VideoPlayer: React.FC = () => { } return; } - const currentCue = customSubtitles.find(cue => + const currentCue = customSubtitles.find(cue => currentTime >= cue.start && currentTime <= cue.end ); const newSubtitle = currentCue ? currentCue.text : ''; @@ -827,26 +970,30 @@ const VideoPlayer: React.FC = () => { saveSubtitleSize(newSize); }; + const toggleSubtitleBackground = () => { + setSubtitleBackground(prev => !prev); + }; + useEffect(() => { if (pendingSeek && isPlayerReady && isVideoLoaded && duration > 0) { logger.log(`[VideoPlayer] Player ready after source change, seeking to position: ${pendingSeek.position}s out of ${duration}s total`); - + if (pendingSeek.position > 0 && vlcRef.current) { const delayTime = Platform.OS === 'android' ? 1500 : 1000; - + setTimeout(() => { if (vlcRef.current && duration > 0 && pendingSeek) { logger.log(`[VideoPlayer] Executing seek to ${pendingSeek.position}s`); - + seekToTime(pendingSeek.position); - + if (pendingSeek.shouldPlay) { setTimeout(() => { logger.log('[VideoPlayer] Resuming playback after source change seek'); setPaused(false); }, 850); // Delay should be slightly more than seekToTime's internal timeout } - + setTimeout(() => { setPendingSeek(null); setIsChangingSource(false); @@ -861,7 +1008,7 @@ const VideoPlayer: React.FC = () => { setPaused(false); }, 500); } - + setTimeout(() => { setPendingSeek(null); setIsChangingSource(false); @@ -878,15 +1025,15 @@ const VideoPlayer: React.FC = () => { setIsChangingSource(true); setShowSourcesModal(false); - + try { // Save current state const savedPosition = currentTime; const wasPlaying = !paused; - + logger.log(`[VideoPlayer] Changing source from ${currentStreamUrl} to ${newStream.url}`); logger.log(`[VideoPlayer] Saved position: ${savedPosition}, was playing: ${wasPlaying}`); - + // Extract quality and provider information from the new stream let newQuality = newStream.quality; if (!newQuality && newStream.title) { @@ -894,38 +1041,38 @@ const VideoPlayer: React.FC = () => { const qualityMatch = newStream.title.match(/(\d+)p/); newQuality = qualityMatch ? qualityMatch[0] : undefined; // Use [0] to get full match like "1080p" } - + // For provider, try multiple fields const newProvider = newStream.addonName || newStream.name || newStream.addon || 'Unknown'; - + // For stream name, prioritize the stream name over title const newStreamName = newStream.name || newStream.title || 'Unknown Stream'; - + logger.log(`[VideoPlayer] Stream object:`, newStream); logger.log(`[VideoPlayer] Extracted - Quality: ${newQuality}, Provider: ${newProvider}, Stream Name: ${newStreamName}`); logger.log(`[VideoPlayer] Available fields - quality: ${newStream.quality}, title: ${newStream.title}, addonName: ${newStream.addonName}, name: ${newStream.name}, addon: ${newStream.addon}`); - + // Stop current playback if (vlcRef.current) { vlcRef.current.pause && vlcRef.current.pause(); } setPaused(true); - + // Set pending seek state setPendingSeek({ position: savedPosition, shouldPlay: wasPlaying }); - + // Update the stream URL and details immediately setCurrentStreamUrl(newStream.url); setCurrentQuality(newQuality); setCurrentStreamProvider(newProvider); setCurrentStreamName(newStreamName); - + // Reset player state for new source setCurrentTime(0); setDuration(0); setIsPlayerReady(false); setIsVideoLoaded(false); - + } catch (error) { logger.error('[VideoPlayer] Error changing source:', error); setPendingSeek(null); @@ -934,14 +1081,22 @@ const VideoPlayer: React.FC = () => { }; return ( - <View style={[styles.container, { - width: screenDimensions.width, - height: screenDimensions.height, - position: 'absolute', - top: 0, - left: 0, - }]}> - <Animated.View + <View style={[ + styles.container, + shouldUseFullscreen ? { + // iPad fullscreen: use flex layout instead of absolute positioning + flex: 1, + width: '100%', + height: '100%', + } : { + // iPhone: use absolute positioning with screen dimensions + width: screenDimensions.width, + height: screenDimensions.height, + position: 'absolute', + top: 0, + left: 0, + }]}> + <Animated.View style={[ styles.openingOverlay, { @@ -953,23 +1108,62 @@ const VideoPlayer: React.FC = () => { ]} pointerEvents={isOpeningAnimationComplete ? 'none' : 'auto'} > - <TouchableOpacity + {backdrop && ( + <Image + source={{ uri: backdrop }} + style={[StyleSheet.absoluteFill, { width: screenDimensions.width, height: screenDimensions.height }]} + resizeMode="cover" + blurRadius={0} + /> + )} + <LinearGradient + colors={[ + 'rgba(0,0,0,0.3)', + 'rgba(0,0,0,0.6)', + 'rgba(0,0,0,0.8)', + 'rgba(0,0,0,0.9)' + ]} + locations={[0, 0.3, 0.7, 1]} + style={StyleSheet.absoluteFill} + /> + + <TouchableOpacity style={styles.loadingCloseButton} onPress={handleClose} activeOpacity={0.7} > <MaterialIcons name="close" size={24} color="#ffffff" /> </TouchableOpacity> - + <View style={styles.openingContent}> - <ActivityIndicator size="large" color="#E50914" /> - <Text style={styles.openingText}>Loading video...</Text> + {hasLogo ? ( + <Animated.View style={{ + transform: [ + { scale: Animated.multiply(logoScaleAnim, pulseAnim) } + ], + opacity: logoOpacityAnim, + alignItems: 'center', + }}> + <Image + source={{ uri: metadata.logo }} + style={{ + width: 300, + height: 180, + resizeMode: 'contain', + }} + /> + </Animated.View> + ) : ( + <> + <ActivityIndicator size="large" color="#E50914" /> + </> + )} </View> </Animated.View> {/* Source Change Loading Overlay */} {isChangingSource && ( - <Animated.View + <Animated.View style={[ styles.sourceChangeOverlay, { @@ -988,7 +1182,7 @@ const VideoPlayer: React.FC = () => { </Animated.View> )} - <Animated.View + <Animated.View style={[ styles.videoPlayerContainer, { @@ -1041,7 +1235,6 @@ const VideoPlayer: React.FC = () => { resizeMode={resizeMode as any} audioTrack={selectedAudioTrack ?? undefined} textTrack={useCustomSubtitles ? -1 : (selectedTextTrack ?? undefined)} - seek={Platform.OS === 'ios' ? (seekPosition ?? undefined) : undefined} autoAspectRatio /> </TouchableOpacity> @@ -1082,28 +1275,26 @@ const VideoPlayer: React.FC = () => { buffered={buffered} formatTime={formatTime} /> - + <CustomSubtitles useCustomSubtitles={useCustomSubtitles} currentSubtitle={currentSubtitle} subtitleSize={subtitleSize} + subtitleBackground={subtitleBackground} + zoomScale={zoomScale} /> <ResumeOverlay showResumeOverlay={showResumeOverlay} resumePosition={resumePosition} - duration={duration} - title={title} + duration={savedDuration || duration} + title={episodeTitle || title} season={season} episode={episode} - rememberChoice={rememberChoice} - setRememberChoice={setRememberChoice} - resumePreference={resumePreference} - resetResumePreference={resetResumePreference} handleResume={handleResume} handleStartFromBeginning={handleStartFromBeginning} /> - </TouchableOpacity> + </TouchableOpacity> </Animated.View> <AudioTrackModal @@ -1126,13 +1317,15 @@ const VideoPlayer: React.FC = () => { selectedTextTrack={selectedTextTrack} useCustomSubtitles={useCustomSubtitles} subtitleSize={subtitleSize} + subtitleBackground={subtitleBackground} fetchAvailableSubtitles={fetchAvailableSubtitles} loadWyzieSubtitle={loadWyzieSubtitle} selectTextTrack={selectTextTrack} increaseSubtitleSize={increaseSubtitleSize} decreaseSubtitleSize={decreaseSubtitleSize} + toggleSubtitleBackground={toggleSubtitleBackground} /> - + <SourcesModal showSourcesModal={showSourcesModal} setShowSourcesModal={setShowSourcesModal} @@ -1141,8 +1334,8 @@ const VideoPlayer: React.FC = () => { onSelectStream={handleSelectStream} isChangingSource={isChangingSource} /> - </View> + </View> ); }; -export default VideoPlayer; \ No newline at end of file +export default VideoPlayer; \ No newline at end of file diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 3cc6054..6b76bf5 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -20,6 +20,7 @@ interface PlayerControlsProps { currentTime: number; duration: number; zoomScale: number; + currentResizeMode?: string; vlcAudioTracks: Array<{id: number, name: string, language?: string}>; selectedAudioTrack: number | null; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; @@ -55,6 +56,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ currentTime, duration, zoomScale, + currentResizeMode, vlcAudioTracks, selectedAudioTrack, availableStreams, @@ -113,6 +115,19 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ ]} /> </View> + + {/* Progress Thumb - Moved outside the progressBarContainer */} + <Animated.View + style={[ + styles.progressThumb, + { + left: progressAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0%', '100%'] + }) + } + ]} + /> </TouchableOpacity> </View> <View style={styles.timeDisplay}> @@ -178,7 +193,11 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({ <TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}> <Ionicons name="resize" size={20} color="white" /> <Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}> - {zoomScale === 1.1 ? 'Fill' : 'Cover'} + {currentResizeMode ? + (currentResizeMode === 'none' ? 'Original' : + currentResizeMode.charAt(0).toUpperCase() + currentResizeMode.slice(1)) : + (zoomScale === 1.1 ? 'Fill' : 'Cover') + } </Text> </TouchableOpacity> diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx index 63805e1..4a38281 100644 --- a/src/components/player/modals/AudioTrackModal.tsx +++ b/src/components/player/modals/AudioTrackModal.tsx @@ -1,29 +1,12 @@ import React from 'react'; import { View, Text, TouchableOpacity, ScrollView, Dimensions } from 'react-native'; -import { Ionicons, MaterialIcons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; +import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, - FadeOut, - SlideInDown, - SlideOutDown, - FadeInDown, - FadeInUp, - Layout, - withSpring, - withTiming, - useAnimatedStyle, - useSharedValue, - interpolate, - Easing, - withDelay, - withSequence, - runOnJS, - BounceIn, - ZoomIn + FadeOut, + SlideInRight, + SlideOutRight, } from 'react-native-reanimated'; -import { LinearGradient } from 'expo-linear-gradient'; -import { styles } from '../utils/playerStyles'; import { getTrackDisplayName } from '../utils/playerUtils'; interface AudioTrackModalProps { @@ -34,56 +17,8 @@ interface AudioTrackModalProps { selectAudioTrack: (trackId: number) => void; } -const { width, height } = Dimensions.get('window'); - -// Fixed dimensions for the modal -const MODAL_WIDTH = Math.min(width - 32, 520); -const MODAL_MAX_HEIGHT = height * 0.85; - -const AudioBadge = ({ - text, - color, - bgColor, - icon, - delay = 0 -}: { - text: string; - color: string; - bgColor: string; - icon?: string; - delay?: number; -}) => ( - <Animated.View - entering={FadeInUp.duration(200).delay(delay)} - style={{ - backgroundColor: bgColor, - borderColor: `${color}40`, - borderWidth: 1, - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - flexDirection: 'row', - alignItems: 'center', - elevation: 2, - shadowColor: color, - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.3, - shadowRadius: 2, - }} - > - {icon && ( - <MaterialIcons name={icon as any} size={12} color={color} style={{ marginRight: 4 }} /> - )} - <Text style={{ - color: color, - fontSize: 10, - fontWeight: '700', - letterSpacing: 0.3, - }}> - {text} - </Text> - </Animated.View> -); +const { width } = Dimensions.get('window'); +const MENU_WIDTH = Math.min(width * 0.85, 400); export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ showAudioModal, @@ -92,353 +27,175 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({ selectedAudioTrack, selectAudioTrack, }) => { - const modalScale = useSharedValue(0.9); - const modalOpacity = useSharedValue(0); - - React.useEffect(() => { - if (showAudioModal) { - modalScale.value = withSpring(1, { - damping: 20, - stiffness: 300, - mass: 0.8, - }); - modalOpacity.value = withTiming(1, { - duration: 200, - easing: Easing.out(Easing.quad), - }); - } - }, [showAudioModal]); - - const modalStyle = useAnimatedStyle(() => ({ - transform: [{ scale: modalScale.value }], - opacity: modalOpacity.value, - })); - const handleClose = () => { - modalScale.value = withTiming(0.9, { duration: 150 }); - modalOpacity.value = withTiming(0, { duration: 150 }); - setTimeout(() => setShowAudioModal(false), 150); + setShowAudioModal(false); }; if (!showAudioModal) return null; return ( - <Animated.View - entering={FadeIn.duration(250)} - exiting={FadeOut.duration(200)} - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.9)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 9999, - padding: 16, - }} - > + <> {/* Backdrop */} - <TouchableOpacity + <Animated.View + entering={FadeIn.duration(200)} + exiting={FadeOut.duration(150)} style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + zIndex: 9998, }} - onPress={handleClose} - activeOpacity={1} - /> - - {/* Modal Content */} - <Animated.View - style={[ - { - width: MODAL_WIDTH, - maxHeight: MODAL_MAX_HEIGHT, - minHeight: height * 0.3, - overflow: 'hidden', - elevation: 25, - shadowColor: '#000', - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.4, - shadowRadius: 25, - alignSelf: 'center', - }, - modalStyle, - ]} > - {/* Glassmorphism Background */} - <BlurView - intensity={100} - tint="dark" - style={{ - borderRadius: 28, - overflow: 'hidden', - backgroundColor: 'rgba(26, 26, 26, 0.8)', - width: '100%', - height: '100%', - }} - > - {/* Header */} - <LinearGradient - colors={[ - 'rgba(249, 115, 22, 0.95)', - 'rgba(234, 88, 12, 0.95)', - 'rgba(194, 65, 12, 0.9)' - ]} - locations={[0, 0.6, 1]} - style={{ - paddingHorizontal: 28, - paddingVertical: 24, - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.1)', - width: '100%', - }} - > - <Animated.View - entering={FadeInDown.duration(300).delay(100)} - style={{ flex: 1 }} - > - <Text style={{ - color: '#fff', - fontSize: 24, - fontWeight: '800', - letterSpacing: -0.8, - textShadowColor: 'rgba(0, 0, 0, 0.3)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }}> - Audio Tracks - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.85)', - fontSize: 14, - marginTop: 4, - fontWeight: '500', - letterSpacing: 0.2, - }}> - Choose from {vlcAudioTracks.length} available track{vlcAudioTracks.length !== 1 ? 's' : ''} - </Text> - </Animated.View> - - <Animated.View entering={BounceIn.duration(400).delay(200)}> - <TouchableOpacity - style={{ - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - justifyContent: 'center', - alignItems: 'center', - marginLeft: 16, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }} - onPress={handleClose} - activeOpacity={0.7} - > - <MaterialIcons name="close" size={20} color="#fff" /> - </TouchableOpacity> - </Animated.View> - </LinearGradient> + <TouchableOpacity + style={{ flex: 1 }} + onPress={handleClose} + activeOpacity={1} + /> + </Animated.View> - {/* Content */} - <ScrollView - style={{ - maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height - backgroundColor: 'transparent', - width: '100%', + {/* Side Menu */} + <Animated.View + entering={SlideInRight.duration(300)} + exiting={SlideOutRight.duration(250)} + style={{ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + width: MENU_WIDTH, + backgroundColor: '#1A1A1A', + zIndex: 9999, + elevation: 20, + shadowColor: '#000', + shadowOffset: { width: -5, height: 0 }, + shadowOpacity: 0.3, + shadowRadius: 10, + borderTopLeftRadius: 20, + borderBottomLeftRadius: 20, + }} + > + {/* Header */} + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 60, + paddingBottom: 20, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.08)', + }}> + <Text style={{ + color: '#FFFFFF', + fontSize: 22, + fontWeight: '700', + }}> + Audio Tracks + </Text> + <TouchableOpacity + style={{ + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', }} - showsVerticalScrollIndicator={false} - contentContainerStyle={{ - padding: 24, - paddingBottom: 32, - width: '100%', - }} - bounces={false} + onPress={handleClose} + activeOpacity={0.7} > - <View style={styles.modernTrackListContainer}> - {vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => ( - <Animated.View - key={track.id} - entering={FadeInDown.duration(300).delay(150 + (index * 50))} - layout={Layout.springify()} - style={{ - marginBottom: 16, - width: '100%', - }} - > + <MaterialIcons name="close" size={20} color="#FFFFFF" /> + </TouchableOpacity> + </View> + + <ScrollView + style={{ flex: 1 }} + contentContainerStyle={{ padding: 20, paddingBottom: 40 }} + showsVerticalScrollIndicator={false} + > + {/* Audio Tracks */} + <View> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + fontWeight: '600', + marginBottom: 15, + textTransform: 'uppercase', + letterSpacing: 0.5, + }}> + Available Tracks ({vlcAudioTracks.length}) + </Text> + + <View style={{ gap: 8 }}> + {vlcAudioTracks.map((track) => { + const isSelected = selectedAudioTrack === track.id; + return ( <TouchableOpacity + key={track.id} style={{ - backgroundColor: selectedAudioTrack === track.id - ? 'rgba(249, 115, 22, 0.08)' - : 'rgba(255, 255, 255, 0.03)', - borderRadius: 20, - padding: 20, - borderWidth: 2, - borderColor: selectedAudioTrack === track.id - ? 'rgba(249, 115, 22, 0.4)' - : 'rgba(255, 255, 255, 0.08)', - elevation: selectedAudioTrack === track.id ? 8 : 3, - shadowColor: selectedAudioTrack === track.id ? '#F97316' : '#000', - shadowOffset: { width: 0, height: selectedAudioTrack === track.id ? 4 : 2 }, - shadowOpacity: selectedAudioTrack === track.id ? 0.3 : 0.1, - shadowRadius: selectedAudioTrack === track.id ? 12 : 6, - width: '100%', + backgroundColor: isSelected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)', }} onPress={() => { selectAudioTrack(track.id); - handleClose(); }} - activeOpacity={0.85} + activeOpacity={0.7} > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - width: '100%', - }}> - <View style={{ flex: 1, marginRight: 16 }}> - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - gap: 12, + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> + <View style={{ flex: 1 }}> + <Text style={{ + color: '#FFFFFF', + fontSize: 15, + fontWeight: '500', + marginBottom: 4, }}> + {getTrackDisplayName(track)} + </Text> + {track.language && ( <Text style={{ - color: selectedAudioTrack === track.id ? '#fff' : 'rgba(255, 255, 255, 0.95)', - fontSize: 16, - fontWeight: '700', - letterSpacing: -0.2, - flex: 1, + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 13, }}> - {getTrackDisplayName(track)} + {track.language.toUpperCase()} </Text> - - {selectedAudioTrack === track.id && ( - <Animated.View - entering={BounceIn.duration(300)} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(249, 115, 22, 0.25)', - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: 14, - borderWidth: 1, - borderColor: 'rgba(249, 115, 22, 0.5)', - }} - > - <MaterialIcons name="volume-up" size={12} color="#F97316" /> - <Text style={{ - color: '#F97316', - fontSize: 10, - fontWeight: '800', - marginLeft: 3, - letterSpacing: 0.3, - }}> - ACTIVE - </Text> - </Animated.View> - )} - </View> - - <View style={{ - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - alignItems: 'center', - }}> - <AudioBadge - text="AUDIO TRACK" - color="#F97316" - bgColor="rgba(249, 115, 22, 0.15)" - icon="audiotrack" - /> - {track.language && ( - <AudioBadge - text={track.language.toUpperCase()} - color="#6B7280" - bgColor="rgba(107, 114, 128, 0.15)" - icon="language" - delay={50} - /> - )} - </View> - </View> - - <View style={{ - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: selectedAudioTrack === track.id - ? 'rgba(249, 115, 22, 0.15)' - : 'rgba(255, 255, 255, 0.05)', - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: selectedAudioTrack === track.id - ? 'rgba(249, 115, 22, 0.3)' - : 'rgba(255, 255, 255, 0.1)', - }}> - {selectedAudioTrack === track.id ? ( - <Animated.View entering={ZoomIn.duration(200)}> - <MaterialIcons name="check-circle" size={24} color="#F97316" /> - </Animated.View> - ) : ( - <MaterialIcons name="volume-up" size={24} color="rgba(255,255,255,0.6)" /> )} </View> + {isSelected && ( + <MaterialIcons name="check" size={20} color="#22C55E" /> + )} </View> </TouchableOpacity> - </Animated.View> - )) : ( - <Animated.View - entering={FadeInDown.duration(300).delay(150)} - style={{ - backgroundColor: 'rgba(255, 255, 255, 0.02)', - borderRadius: 20, - padding: 40, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.05)', - width: '100%', - }} - > - <MaterialIcons name="volume-off" size={48} color="rgba(255, 255, 255, 0.3)" /> - <Text style={{ - color: 'rgba(255, 255, 255, 0.6)', - fontSize: 18, - fontWeight: '700', - marginTop: 16, - textAlign: 'center', - letterSpacing: -0.3, - }}> - No audio tracks found - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.4)', - fontSize: 14, - marginTop: 8, - textAlign: 'center', - lineHeight: 20, - }}> - No audio tracks are available for this content.{'\n'}Try a different source or check your connection. - </Text> - </Animated.View> - )} + ); + })} </View> - </ScrollView> - </BlurView> - </Animated.View> - </Animated.View> - ); -}; -export default AudioTrackModal; \ No newline at end of file + {vlcAudioTracks.length === 0 && ( + <View style={{ + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 20, + alignItems: 'center', + }}> + <MaterialIcons name="volume-off" size={48} color="rgba(255,255,255,0.3)" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 16, + marginTop: 16, + textAlign: 'center', + }}> + No audio tracks available + </Text> + </View> + )} + </View> + </ScrollView> + </Animated.View> + </> + ); +}; \ No newline at end of file diff --git a/src/components/player/modals/ResumeOverlay.tsx b/src/components/player/modals/ResumeOverlay.tsx index 0945165..724a57f 100644 --- a/src/components/player/modals/ResumeOverlay.tsx +++ b/src/components/player/modals/ResumeOverlay.tsx @@ -13,10 +13,6 @@ interface ResumeOverlayProps { title: string; season?: number; episode?: number; - rememberChoice: boolean; - setRememberChoice: (remember: boolean) => void; - resumePreference: string | null; - resetResumePreference: () => void; handleResume: () => void; handleStartFromBeginning: () => void; } @@ -28,10 +24,6 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({ title, season, episode, - rememberChoice, - setRememberChoice, - resumePreference, - resetResumePreference, handleResume, handleStartFromBeginning, }) => { @@ -78,29 +70,6 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({ </View> </View> - {/* Remember choice checkbox */} - <TouchableOpacity - style={styles.rememberChoiceContainer} - onPress={() => setRememberChoice(!rememberChoice)} - activeOpacity={0.7} - > - <View style={styles.checkboxContainer}> - <View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}> - {rememberChoice && <Ionicons name="checkmark" size={12} color="white" />} - </View> - <Text style={styles.rememberChoiceText}>Remember my choice</Text> - </View> - - {resumePreference && ( - <TouchableOpacity - onPress={resetResumePreference} - style={styles.resetPreferenceButton} - > - <Text style={styles.resetPreferenceText}>Reset</Text> - </TouchableOpacity> - )} - </TouchableOpacity> - <View style={styles.resumeButtons}> <TouchableOpacity style={styles.resumeButton} diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx index 0c232e6..ada3bf3 100644 --- a/src/components/player/modals/SourcesModal.tsx +++ b/src/components/player/modals/SourcesModal.tsx @@ -1,31 +1,13 @@ import React from 'react'; import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native'; -import { Ionicons, MaterialIcons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; +import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, - FadeOut, - SlideInDown, - SlideOutDown, - FadeInDown, - FadeInUp, - Layout, - withSpring, - withTiming, - useAnimatedStyle, - useSharedValue, - interpolate, - Easing, - withDelay, - withSequence, - runOnJS, - BounceIn, - ZoomIn + FadeOut, + SlideInRight, + SlideOutRight, } from 'react-native-reanimated'; -import { LinearGradient } from 'expo-linear-gradient'; -import { styles } from '../utils/playerStyles'; import { Stream } from '../../../types/streams'; -import QualityBadge from '../../metadata/QualityBadge'; interface SourcesModalProps { showSourcesModal: boolean; @@ -36,13 +18,10 @@ interface SourcesModalProps { isChangingSource: boolean; } -const { width, height } = Dimensions.get('window'); +const { width } = Dimensions.get('window'); +const MENU_WIDTH = Math.min(width * 0.85, 400); -// Fixed dimensions for the modal -const MODAL_WIDTH = Math.min(width - 32, 520); -const MODAL_MAX_HEIGHT = height * 0.85; - -const QualityIndicator = ({ quality }: { quality: string | null }) => { +const QualityBadge = ({ quality }: { quality: string | null }) => { if (!quality) return null; const qualityNum = parseInt(quality); @@ -61,84 +40,31 @@ const QualityIndicator = ({ quality }: { quality: string | null }) => { } return ( - <Animated.View - entering={ZoomIn.duration(200).delay(100)} + <View style={{ backgroundColor: `${color}20`, borderColor: `${color}60`, borderWidth: 1, paddingHorizontal: 8, - paddingVertical: 3, + paddingVertical: 4, borderRadius: 8, flexDirection: 'row', alignItems: 'center', }} > - <View style={{ - width: 6, - height: 6, - borderRadius: 3, - backgroundColor: color, - marginRight: 4, - }} /> <Text style={{ color: color, - fontSize: 10, + fontSize: 12, fontWeight: '700', letterSpacing: 0.5, }}> {label} </Text> - </Animated.View> + </View> ); }; -const StreamMetaBadge = ({ - text, - color, - bgColor, - icon, - delay = 0 -}: { - text: string; - color: string; - bgColor: string; - icon?: string; - delay?: number; -}) => ( - <Animated.View - entering={FadeInUp.duration(200).delay(delay)} - style={{ - backgroundColor: bgColor, - borderColor: `${color}40`, - borderWidth: 1, - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 6, - flexDirection: 'row', - alignItems: 'center', - elevation: 2, - shadowColor: color, - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.3, - shadowRadius: 2, - }} - > - {icon && ( - <MaterialIcons name={icon as any} size={10} color={color} style={{ marginRight: 2 }} /> - )} - <Text style={{ - color: color, - fontSize: 9, - fontWeight: '800', - letterSpacing: 0.3, - }}> - {text} - </Text> - </Animated.View> -); - -const SourcesModal: React.FC<SourcesModalProps> = ({ +export const SourcesModal: React.FC<SourcesModalProps> = ({ showSourcesModal, setShowSourcesModal, availableStreams, @@ -146,36 +72,13 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ onSelectStream, isChangingSource, }) => { - const modalScale = useSharedValue(0.9); - const modalOpacity = useSharedValue(0); - - React.useEffect(() => { - if (showSourcesModal) { - modalScale.value = withSpring(1, { - damping: 20, - stiffness: 300, - mass: 0.8, - }); - modalOpacity.value = withTiming(1, { - duration: 200, - easing: Easing.out(Easing.quad), - }); - } - }, [showSourcesModal]); - - const modalStyle = useAnimatedStyle(() => ({ - transform: [{ scale: modalScale.value }], - opacity: modalOpacity.value, - })); + const handleClose = () => { + setShowSourcesModal(false); + }; if (!showSourcesModal) return null; - const sortedProviders = Object.entries(availableStreams).sort(([a], [b]) => { - // Put HDRezka first - if (a === 'hdrezka') return -1; - if (b === 'hdrezka') return 1; - return 0; - }); + const sortedProviders = Object.entries(availableStreams); const handleStreamSelect = (stream: Stream) => { if (stream.url !== currentStreamUrl && !isChangingSource) { @@ -193,465 +96,240 @@ const SourcesModal: React.FC<SourcesModalProps> = ({ return stream.url === currentStreamUrl; }; - const handleClose = () => { - modalScale.value = withTiming(0.9, { duration: 150 }); - modalOpacity.value = withTiming(0, { duration: 150 }); - setTimeout(() => setShowSourcesModal(false), 150); - }; - return ( - <Animated.View - entering={FadeIn.duration(250)} - exiting={FadeOut.duration(200)} - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.9)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 9999, - padding: 16, - }} - > + <> {/* Backdrop */} - <TouchableOpacity + <Animated.View + entering={FadeIn.duration(200)} + exiting={FadeOut.duration(150)} style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + zIndex: 9998, }} - onPress={handleClose} - activeOpacity={1} - /> - - {/* Modal Content */} - <Animated.View - style={[ - { - width: MODAL_WIDTH, - maxHeight: MODAL_MAX_HEIGHT, - minHeight: height * 0.3, - overflow: 'hidden', - elevation: 25, - shadowColor: '#000', - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.4, - shadowRadius: 25, - alignSelf: 'center', - }, - modalStyle, - ]} > - {/* Glassmorphism Background */} - <BlurView - intensity={100} - tint="dark" - style={{ - borderRadius: 28, - overflow: 'hidden', - backgroundColor: 'rgba(26, 26, 26, 0.8)', - width: '100%', - height: '100%', - }} - > - {/* Header */} - <LinearGradient - colors={[ - 'rgba(229, 9, 20, 0.95)', - 'rgba(176, 6, 16, 0.95)', - 'rgba(139, 5, 12, 0.9)' - ]} - locations={[0, 0.6, 1]} + <TouchableOpacity + style={{ flex: 1 }} + onPress={handleClose} + activeOpacity={1} + /> + </Animated.View> + + {/* Side Menu */} + <Animated.View + entering={SlideInRight.duration(300)} + exiting={SlideOutRight.duration(250)} + style={{ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + width: MENU_WIDTH, + backgroundColor: '#1A1A1A', + zIndex: 9999, + elevation: 20, + shadowColor: '#000', + shadowOffset: { width: -5, height: 0 }, + shadowOpacity: 0.3, + shadowRadius: 10, + borderTopLeftRadius: 20, + borderBottomLeftRadius: 20, + }} + > + {/* Header */} + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 60, + paddingBottom: 20, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.08)', + }}> + <Text style={{ + color: '#FFFFFF', + fontSize: 22, + fontWeight: '700', + }}> + Change Source + </Text> + <TouchableOpacity style={{ - paddingHorizontal: 28, - paddingVertical: 24, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }} + onPress={handleClose} + activeOpacity={0.7} + > + <MaterialIcons name="close" size={20} color="#FFFFFF" /> + </TouchableOpacity> + </View> + + <ScrollView + style={{ flex: 1 }} + contentContainerStyle={{ padding: 20, paddingBottom: 40 }} + showsVerticalScrollIndicator={false} + > + {isChangingSource && ( + <View style={{ + backgroundColor: 'rgba(34, 197, 94, 0.1)', + borderRadius: 16, + padding: 16, + marginBottom: 20, flexDirection: 'row', alignItems: 'center', - justifyContent: 'space-between', - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.1)', - width: '100%', - }} - > - <Animated.View - entering={FadeInDown.duration(300).delay(100)} - style={{ flex: 1 }} - > + }}> + <ActivityIndicator size="small" color="#22C55E" /> <Text style={{ - color: '#fff', - fontSize: 24, - fontWeight: '800', - letterSpacing: -0.8, - textShadowColor: 'rgba(0, 0, 0, 0.3)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }}> - Switch Source - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.85)', + color: '#22C55E', fontSize: 14, - marginTop: 4, - fontWeight: '500', - letterSpacing: 0.2, + fontWeight: '600', + marginLeft: 12, }}> - Choose from {Object.values(availableStreams).reduce((acc, curr) => acc + curr.streams.length, 0)} available streams + Switching source... </Text> - </Animated.View> - - <Animated.View entering={BounceIn.duration(400).delay(200)}> - <TouchableOpacity - style={{ - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - justifyContent: 'center', - alignItems: 'center', - marginLeft: 16, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }} - onPress={handleClose} - activeOpacity={0.7} - > - <MaterialIcons name="close" size={20} color="#fff" /> - </TouchableOpacity> - </Animated.View> - </LinearGradient> + </View> + )} - {/* Content */} - <ScrollView - style={{ - maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height - backgroundColor: 'transparent', - width: '100%', - }} - showsVerticalScrollIndicator={false} - contentContainerStyle={{ - padding: 24, - paddingBottom: 32, - width: '100%', - }} - bounces={false} - > - {sortedProviders.map(([providerId, { streams, addonName }], providerIndex) => ( - <Animated.View - key={providerId} - entering={FadeInDown.duration(400).delay(150 + (providerIndex * 80))} - layout={Layout.springify()} - style={{ - marginBottom: streams.length > 0 ? 32 : 0, - width: '100%', - }} - > - {/* Provider Header */} - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 20, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.08)', - width: '100%', + {sortedProviders.length > 0 ? ( + sortedProviders.map(([providerId, providerData]) => ( + <View key={providerId} style={{ marginBottom: 30 }}> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + fontWeight: '600', + marginBottom: 15, + textTransform: 'uppercase', + letterSpacing: 0.5, }}> - <LinearGradient - colors={providerId === 'hdrezka' ? ['#00d4aa', '#00a085'] : ['#E50914', '#B00610']} - style={{ - width: 12, - height: 12, - borderRadius: 6, - marginRight: 16, - elevation: 3, - shadowColor: providerId === 'hdrezka' ? '#00d4aa' : '#E50914', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.4, - shadowRadius: 4, - }} - /> - <View style={{ flex: 1 }}> - <Text style={{ - color: '#fff', - fontSize: 18, - fontWeight: '700', - letterSpacing: -0.3, - }}> - {addonName} - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.6)', - fontSize: 12, - marginTop: 1, - fontWeight: '500', - }}> - Provider • {streams.length} stream{streams.length !== 1 ? 's' : ''} - </Text> - </View> - - <View style={{ - backgroundColor: 'rgba(255, 255, 255, 0.08)', - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.1)', - }}> - <Text style={{ - color: 'rgba(255, 255, 255, 0.7)', - fontSize: 11, - fontWeight: '700', - letterSpacing: 0.5, - }}> - {streams.length} - </Text> - </View> - </View> + {providerData.addonName} ({providerData.streams.length}) + </Text> - {/* Streams Grid */} - <View style={{ gap: 16, width: '100%' }}> - {streams.map((stream, index) => { - const quality = getQualityFromTitle(stream.title); + <View style={{ gap: 8 }}> + {providerData.streams.map((stream, index) => { const isSelected = isStreamSelected(stream); - const isHDR = stream.title?.toLowerCase().includes('hdr'); - const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); - const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; - const isDebrid = stream.behaviorHints?.cached; - const isHDRezka = providerId === 'hdrezka'; - + const quality = getQualityFromTitle(stream.title) || stream.quality; + return ( - <Animated.View - key={`${stream.url}-${index}`} - entering={FadeInDown.duration(300).delay((providerIndex * 80) + (index * 40))} - layout={Layout.springify()} - style={{ width: '100%' }} + <TouchableOpacity + key={`${providerId}-${index}`} + style={{ + backgroundColor: isSelected ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: isSelected ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255, 255, 255, 0.1)', + opacity: isChangingSource && !isSelected ? 0.6 : 1, + }} + onPress={() => handleStreamSelect(stream)} + activeOpacity={0.7} + disabled={isChangingSource} > - <TouchableOpacity - style={{ - backgroundColor: isSelected - ? 'rgba(229, 9, 20, 0.08)' - : 'rgba(255, 255, 255, 0.03)', - borderRadius: 20, - padding: 20, - borderWidth: 2, - borderColor: isSelected - ? 'rgba(229, 9, 20, 0.4)' - : 'rgba(255, 255, 255, 0.08)', - elevation: isSelected ? 8 : 3, - shadowColor: isSelected ? '#E50914' : '#000', - shadowOffset: { width: 0, height: isSelected ? 4 : 2 }, - shadowOpacity: isSelected ? 0.3 : 0.1, - shadowRadius: isSelected ? 12 : 6, - transform: [{ scale: isSelected ? 1.02 : 1 }], - width: '100%', - }} - onPress={() => handleStreamSelect(stream)} - disabled={isChangingSource || isSelected} - activeOpacity={0.85} - > - <View style={{ - flexDirection: 'row', - alignItems: 'flex-start', - justifyContent: 'space-between', - width: '100%', - }}> - {/* Stream Info */} - <View style={{ flex: 1, marginRight: 16 }}> - {/* Title Row */} - <View style={{ - flexDirection: 'row', - alignItems: 'flex-start', - marginBottom: 12, - flexWrap: 'wrap', - gap: 8, + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> + <View style={{ flex: 1 }}> + <View style={{ + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + gap: 8, + }}> + <Text style={{ + color: '#FFFFFF', + fontSize: 15, + fontWeight: '500', + flex: 1, }}> - <Text style={{ - color: isSelected ? '#fff' : 'rgba(255, 255, 255, 0.95)', - fontSize: 16, - fontWeight: '700', - letterSpacing: -0.2, - flex: 1, - lineHeight: 22, - }}> - {isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')} - </Text> - - {isSelected && ( - <Animated.View - entering={BounceIn.duration(300)} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(229, 9, 20, 0.25)', - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: 14, - borderWidth: 1, - borderColor: 'rgba(229, 9, 20, 0.5)', - elevation: 4, - shadowColor: '#E50914', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.3, - shadowRadius: 4, - }} - > - <MaterialIcons name="play-circle-filled" size={12} color="#E50914" /> + {stream.title || stream.name || `Stream ${index + 1}`} + </Text> + {quality && <QualityBadge quality={quality} />} + </View> + + {(stream.size || stream.lang) && ( + <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12 }}> + {stream.size && ( + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + <MaterialIcons name="storage" size={14} color="rgba(107, 114, 128, 0.8)" /> <Text style={{ - color: '#E50914', - fontSize: 10, - fontWeight: '800', - marginLeft: 3, - letterSpacing: 0.3, - }}> - PLAYING - </Text> - </Animated.View> - )} - - {isChangingSource && isSelected && ( - <Animated.View - entering={FadeIn.duration(200)} - style={{ - backgroundColor: 'rgba(229, 9, 20, 0.2)', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 12, - flexDirection: 'row', - alignItems: 'center', - }} - > - <ActivityIndicator size="small" color="#E50914" /> - <Text style={{ - color: '#E50914', - fontSize: 10, + color: 'rgba(107, 114, 128, 0.8)', + fontSize: 12, fontWeight: '600', marginLeft: 4, }}> - Switching... + {(stream.size / (1024 * 1024 * 1024)).toFixed(1)} GB </Text> - </Animated.View> + </View> + )} + {stream.lang && ( + <View style={{ flexDirection: 'row', alignItems: 'center' }}> + <MaterialIcons name="language" size={14} color="rgba(59, 130, 246, 0.8)" /> + <Text style={{ + color: 'rgba(59, 130, 246, 0.8)', + fontSize: 12, + fontWeight: '600', + marginLeft: 4, + }}> + {stream.lang.toUpperCase()} + </Text> + </View> )} </View> - - {/* Subtitle */} - {!isHDRezka && stream.title && stream.title !== stream.name && ( - <Text style={{ - color: 'rgba(255, 255, 255, 0.65)', - fontSize: 13, - marginBottom: 12, - lineHeight: 18, - fontWeight: '400', - }}> - {stream.title} - </Text> - )} - - {/* Enhanced Meta Info */} - <View style={{ - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - alignItems: 'center', - }}> - <QualityIndicator quality={quality} /> - - {isDolby && ( - <StreamMetaBadge - text="DOLBY" - color="#8B5CF6" - bgColor="rgba(139, 92, 246, 0.15)" - icon="hd" - delay={100} - /> - )} - - {isHDR && ( - <StreamMetaBadge - text="HDR" - color="#F59E0B" - bgColor="rgba(245, 158, 11, 0.15)" - icon="brightness-high" - delay={120} - /> - )} - - {size && ( - <StreamMetaBadge - text={size} - color="#6B7280" - bgColor="rgba(107, 114, 128, 0.15)" - icon="storage" - delay={140} - /> - )} - - {isDebrid && ( - <StreamMetaBadge - text="DEBRID" - color="#00d4aa" - bgColor="rgba(0, 212, 170, 0.15)" - icon="flash-on" - delay={160} - /> - )} - - {isHDRezka && ( - <StreamMetaBadge - text="HDREZKA" - color="#00d4aa" - bgColor="rgba(0, 212, 170, 0.15)" - icon="verified" - delay={180} - /> - )} - </View> - </View> - - {/* Enhanced Action Icon */} - <View style={{ - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: isSelected - ? 'rgba(229, 9, 20, 0.15)' - : 'rgba(255, 255, 255, 0.05)', - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: isSelected - ? 'rgba(229, 9, 20, 0.3)' - : 'rgba(255, 255, 255, 0.1)', - elevation: 4, - shadowColor: isSelected ? '#E50914' : '#fff', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: isSelected ? 0.2 : 0.05, - shadowRadius: 4, - }}> - {isSelected ? ( - <Animated.View entering={ZoomIn.duration(200)}> - <MaterialIcons name="check-circle" size={24} color="#E50914" /> - </Animated.View> - ) : ( - <MaterialIcons name="play-arrow" size={24} color="rgba(255,255,255,0.6)" /> - )} - </View> + )} </View> - </TouchableOpacity> - </Animated.View> + + <View style={{ + marginLeft: 12, + alignItems: 'center', + }}> + {isSelected ? ( + <MaterialIcons name="check" size={20} color="#3B82F6" /> + ) : ( + <MaterialIcons name="play-arrow" size={20} color="rgba(255,255,255,0.4)" /> + )} + </View> + </View> + </TouchableOpacity> ); })} </View> - </Animated.View> - ))} - </ScrollView> - </BlurView> + </View> + )) + ) : ( + <View style={{ + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 20, + alignItems: 'center', + }}> + <MaterialIcons name="error-outline" size={48} color="rgba(255,255,255,0.3)" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 16, + marginTop: 16, + textAlign: 'center', + }}> + No sources available + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.4)', + fontSize: 14, + marginTop: 8, + textAlign: 'center', + }}> + Try searching for different content + </Text> + </View> + )} + </ScrollView> </Animated.View> - </Animated.View> + </> ); -}; - -export default SourcesModal; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx index cf5e247..c2aa2ba 100644 --- a/src/components/player/modals/SubtitleModals.tsx +++ b/src/components/player/modals/SubtitleModals.tsx @@ -1,28 +1,12 @@ import React from 'react'; -import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Image, Dimensions } from 'react-native'; -import { Ionicons, MaterialIcons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; +import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; import Animated, { FadeIn, - FadeOut, - SlideInDown, - SlideOutDown, - FadeInDown, - FadeInUp, - Layout, - withSpring, - withTiming, - useAnimatedStyle, - useSharedValue, - interpolate, - Easing, - withDelay, - withSequence, - runOnJS, - BounceIn, - ZoomIn + FadeOut, + SlideInRight, + SlideOutRight, } from 'react-native-reanimated'; -import { LinearGradient } from 'expo-linear-gradient'; import { styles } from '../utils/playerStyles'; import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes'; import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils'; @@ -40,63 +24,17 @@ interface SubtitleModalsProps { selectedTextTrack: number; useCustomSubtitles: boolean; subtitleSize: number; + subtitleBackground: boolean; fetchAvailableSubtitles: () => void; loadWyzieSubtitle: (subtitle: WyzieSubtitle) => void; selectTextTrack: (trackId: number) => void; increaseSubtitleSize: () => void; decreaseSubtitleSize: () => void; + toggleSubtitleBackground: () => void; } const { width, height } = Dimensions.get('window'); - -// Fixed dimensions for the modals -const MODAL_WIDTH = Math.min(width - 32, 520); -const MODAL_MAX_HEIGHT = height * 0.85; - -const SubtitleBadge = ({ - text, - color, - bgColor, - icon, - delay = 0 -}: { - text: string; - color: string; - bgColor: string; - icon?: string; - delay?: number; -}) => ( - <Animated.View - entering={FadeInUp.duration(200).delay(delay)} - style={{ - backgroundColor: bgColor, - borderColor: `${color}40`, - borderWidth: 1, - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - flexDirection: 'row', - alignItems: 'center', - elevation: 2, - shadowColor: color, - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.3, - shadowRadius: 2, - }} - > - {icon && ( - <MaterialIcons name={icon as any} size={12} color={color} style={{ marginRight: 4 }} /> - )} - <Text style={{ - color: color, - fontSize: 10, - fontWeight: '700', - letterSpacing: 0.3, - }}> - {text} - </Text> - </Animated.View> -); +const MENU_WIDTH = Math.min(width * 0.85, 400); export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ showSubtitleModal, @@ -111,1036 +49,492 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({ selectedTextTrack, useCustomSubtitles, subtitleSize, + subtitleBackground, fetchAvailableSubtitles, loadWyzieSubtitle, selectTextTrack, increaseSubtitleSize, decreaseSubtitleSize, + toggleSubtitleBackground, }) => { - const modalScale = useSharedValue(0.9); - const modalOpacity = useSharedValue(0); - const languageModalScale = useSharedValue(0.9); - const languageModalOpacity = useSharedValue(0); - + // Track which specific online subtitle is currently loaded + const [selectedOnlineSubtitleId, setSelectedOnlineSubtitleId] = React.useState<string | null>(null); + React.useEffect(() => { - if (showSubtitleModal) { - modalScale.value = withSpring(1, { - damping: 20, - stiffness: 300, - mass: 0.8, - }); - modalOpacity.value = withTiming(1, { - duration: 200, - easing: Easing.out(Easing.quad), - }); + if (showSubtitleModal && !isLoadingSubtitleList && availableSubtitles.length === 0) { + fetchAvailableSubtitles(); } }, [showSubtitleModal]); + // Reset selected online subtitle when switching to built-in tracks React.useEffect(() => { - if (showSubtitleLanguageModal) { - languageModalScale.value = withSpring(1, { - damping: 20, - stiffness: 300, - mass: 0.8, - }); - languageModalOpacity.value = withTiming(1, { - duration: 200, - easing: Easing.out(Easing.quad), - }); + if (!useCustomSubtitles) { + setSelectedOnlineSubtitleId(null); } - }, [showSubtitleLanguageModal]); - - const modalStyle = useAnimatedStyle(() => ({ - transform: [{ scale: modalScale.value }], - opacity: modalOpacity.value, - })); - - const languageModalStyle = useAnimatedStyle(() => ({ - transform: [{ scale: languageModalScale.value }], - opacity: languageModalOpacity.value, - })); + }, [useCustomSubtitles]); const handleClose = () => { - modalScale.value = withTiming(0.9, { duration: 150 }); - modalOpacity.value = withTiming(0, { duration: 150 }); - setTimeout(() => setShowSubtitleModal(false), 150); + setShowSubtitleModal(false); }; - const handleLanguageClose = () => { - languageModalScale.value = withTiming(0.9, { duration: 150 }); - languageModalOpacity.value = withTiming(0, { duration: 150 }); - setTimeout(() => setShowSubtitleLanguageModal(false), 150); + const handleLoadWyzieSubtitle = (subtitle: WyzieSubtitle) => { + setSelectedOnlineSubtitleId(subtitle.id); + loadWyzieSubtitle(subtitle); }; - // Render subtitle settings modal - const renderSubtitleModal = () => { + // Main subtitle menu + const renderSubtitleMenu = () => { if (!showSubtitleModal) return null; return ( - <Animated.View - entering={FadeIn.duration(250)} - exiting={FadeOut.duration(200)} - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.9)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 9999, - padding: 16, - }} - > + <> {/* Backdrop */} - <TouchableOpacity + <Animated.View + entering={FadeIn.duration(200)} + exiting={FadeOut.duration(150)} style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + zIndex: 9998, }} - onPress={handleClose} - activeOpacity={1} - /> - - {/* Modal Content */} - <Animated.View - style={[ - { - width: MODAL_WIDTH, - maxHeight: MODAL_MAX_HEIGHT, - minHeight: height * 0.3, - overflow: 'hidden', - elevation: 25, - shadowColor: '#000', - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.4, - shadowRadius: 25, - alignSelf: 'center', - }, - modalStyle, - ]} > - {/* Glassmorphism Background */} - <BlurView - intensity={100} - tint="dark" - style={{ - borderRadius: 28, - overflow: 'hidden', - backgroundColor: 'rgba(26, 26, 26, 0.8)', - width: '100%', - height: '100%', - }} - > - {/* Header */} - <LinearGradient - colors={[ - 'rgba(139, 92, 246, 0.95)', - 'rgba(124, 58, 237, 0.95)', - 'rgba(109, 40, 217, 0.9)' - ]} - locations={[0, 0.6, 1]} + <TouchableOpacity + style={{ flex: 1 }} + onPress={handleClose} + activeOpacity={1} + /> + </Animated.View> + + {/* Side Menu */} + <Animated.View + entering={SlideInRight.duration(300)} + exiting={SlideOutRight.duration(250)} + style={{ + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + width: MENU_WIDTH, + backgroundColor: '#1A1A1A', + zIndex: 9999, + elevation: 20, + shadowColor: '#000', + shadowOffset: { width: -5, height: 0 }, + shadowOpacity: 0.3, + shadowRadius: 10, + borderTopLeftRadius: 20, + borderBottomLeftRadius: 20, + }} + > + {/* Header */} + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 20, + paddingTop: 60, + paddingBottom: 20, + borderBottomWidth: 1, + borderBottomColor: 'rgba(255, 255, 255, 0.08)', + }}> + <Text style={{ + color: '#FFFFFF', + fontSize: 22, + fontWeight: '700', + }}> + Subtitles + </Text> + <TouchableOpacity style={{ - paddingHorizontal: 28, - paddingVertical: 24, + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }} + onPress={handleClose} + activeOpacity={0.7} + > + <MaterialIcons name="close" size={20} color="#FFFFFF" /> + </TouchableOpacity> + </View> + + <ScrollView + style={{ flex: 1 }} + contentContainerStyle={{ padding: 20, paddingBottom: 40 }} + showsVerticalScrollIndicator={false} + > + {/* Font Size Section - Only show for custom subtitles */} + {useCustomSubtitles && ( + <View style={{ marginBottom: 30 }}> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + fontWeight: '600', + marginBottom: 15, + textTransform: 'uppercase', + letterSpacing: 0.5, + }}> + Font Size + </Text> + + <View style={{ + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 16, + }}> + <TouchableOpacity + style={{ + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }} + onPress={decreaseSubtitleSize} + > + <MaterialIcons name="remove" size={20} color="#FFFFFF" /> + </TouchableOpacity> + + <Text style={{ + color: '#FFFFFF', + fontSize: 18, + fontWeight: '600', + }}> + {subtitleSize} + </Text> + + <TouchableOpacity + style={{ + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: 'rgba(255, 255, 255, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }} + onPress={increaseSubtitleSize} + > + <MaterialIcons name="add" size={20} color="#FFFFFF" /> + </TouchableOpacity> + </View> + </View> + )} + + {/* Background Toggle Section - Only show for custom subtitles */} + {useCustomSubtitles && ( + <View style={{ marginBottom: 30 }}> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + fontWeight: '600', + marginBottom: 15, + textTransform: 'uppercase', + letterSpacing: 0.5, + }}> + Background + </Text> + + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.1)', - width: '100%', - }} - > - <Animated.View - entering={FadeInDown.duration(300).delay(100)} - style={{ flex: 1 }} - > + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 16, + }}> <Text style={{ - color: '#fff', - fontSize: 24, - fontWeight: '800', - letterSpacing: -0.8, - textShadowColor: 'rgba(0, 0, 0, 0.3)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }}> - Subtitle Settings - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.85)', - fontSize: 14, - marginTop: 4, + color: 'white', + fontSize: 16, fontWeight: '500', - letterSpacing: 0.2, }}> - Configure subtitles and language options + Show Background </Text> - </Animated.View> - - <Animated.View entering={BounceIn.duration(400).delay(200)}> - <TouchableOpacity - style={{ - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - justifyContent: 'center', - alignItems: 'center', - marginLeft: 16, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', - }} - onPress={handleClose} - activeOpacity={0.7} - > - <MaterialIcons name="close" size={20} color="#fff" /> - </TouchableOpacity> - </Animated.View> - </LinearGradient> - - {/* Content */} - <ScrollView - style={{ - maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height - backgroundColor: 'transparent', - width: '100%', - }} - showsVerticalScrollIndicator={false} - contentContainerStyle={{ - padding: 24, - paddingBottom: 32, - width: '100%', - }} - bounces={false} - > - <View style={styles.modernTrackListContainer}> - {/* External Subtitles Section */} - <Animated.View - entering={FadeInDown.duration(400).delay(150)} - layout={Layout.springify()} + <TouchableOpacity style={{ - marginBottom: 32, + width: 50, + height: 28, + backgroundColor: subtitleBackground ? '#007AFF' : 'rgba(255, 255, 255, 0.2)', + borderRadius: 14, + justifyContent: 'center', + alignItems: subtitleBackground ? 'flex-end' : 'flex-start', + paddingHorizontal: 2, }} + onPress={toggleSubtitleBackground} > <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 20, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.08)', - }}> - <LinearGradient - colors={['#4CAF50', '#388E3C']} - style={{ - width: 12, - height: 12, - borderRadius: 6, - marginRight: 16, - elevation: 3, - shadowColor: '#4CAF50', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.4, - shadowRadius: 4, - }} - /> - <View style={{ flex: 1 }}> - <Text style={{ - color: '#fff', - fontSize: 18, - fontWeight: '700', - letterSpacing: -0.3, - }}> - External Subtitles - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.6)', - fontSize: 12, - marginTop: 1, - fontWeight: '500', - }}> - High quality with size control - </Text> - </View> - </View> + width: 24, + height: 24, + backgroundColor: 'white', + borderRadius: 12, + }} /> + </TouchableOpacity> + </View> + </View> + )} - {/* Custom subtitles option */} - {customSubtitles.length > 0 && ( - <Animated.View - entering={FadeInDown.duration(300).delay(200)} - layout={Layout.springify()} - style={{ marginBottom: 16 }} - > + {/* Built-in Subtitles */} + {vlcTextTracks.length > 0 && ( + <View style={{ marginBottom: 30 }}> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + fontWeight: '600', + marginBottom: 15, + textTransform: 'uppercase', + letterSpacing: 0.5, + }}> + Built-in Subtitles + </Text> + + <View style={{ gap: 8 }}> + {vlcTextTracks.map((track) => { + const isSelected = selectedTextTrack === track.id && !useCustomSubtitles; + return ( <TouchableOpacity + key={track.id} style={{ - backgroundColor: useCustomSubtitles - ? 'rgba(76, 175, 80, 0.08)' - : 'rgba(255, 255, 255, 0.03)', - borderRadius: 20, - padding: 20, - borderWidth: 2, - borderColor: useCustomSubtitles - ? 'rgba(76, 175, 80, 0.4)' - : 'rgba(255, 255, 255, 0.08)', - elevation: useCustomSubtitles ? 8 : 3, - shadowColor: useCustomSubtitles ? '#4CAF50' : '#000', - shadowOffset: { width: 0, height: useCustomSubtitles ? 4 : 2 }, - shadowOpacity: useCustomSubtitles ? 0.3 : 0.1, - shadowRadius: useCustomSubtitles ? 12 : 6, + backgroundColor: isSelected ? 'rgba(59, 130, 246, 0.15)' : 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: isSelected ? 'rgba(59, 130, 246, 0.3)' : 'rgba(255, 255, 255, 0.1)', }} onPress={() => { - selectTextTrack(-999); - setShowSubtitleModal(false); + selectTextTrack(track.id); + setSelectedOnlineSubtitleId(null); }} - activeOpacity={0.85} + activeOpacity={0.7} > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }}> - <View style={{ flex: 1, marginRight: 16 }}> - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - gap: 12, - }}> - <Text style={{ - color: useCustomSubtitles ? '#fff' : 'rgba(255, 255, 255, 0.95)', - fontSize: 16, - fontWeight: '700', - letterSpacing: -0.2, - flex: 1, - }}> - Custom Subtitles - </Text> - - {useCustomSubtitles && ( - <Animated.View - entering={BounceIn.duration(300)} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(76, 175, 80, 0.25)', - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: 14, - borderWidth: 1, - borderColor: 'rgba(76, 175, 80, 0.5)', - }} - > - <MaterialIcons name="subtitles" size={12} color="#4CAF50" /> - <Text style={{ - color: '#4CAF50', - fontSize: 10, - fontWeight: '800', - marginLeft: 3, - letterSpacing: 0.3, - }}> - ACTIVE - </Text> - </Animated.View> - )} - </View> - - <View style={{ - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - alignItems: 'center', - }}> - <SubtitleBadge - text={`${customSubtitles.length} CUES`} - color="#4CAF50" - bgColor="rgba(76, 175, 80, 0.15)" - icon="format-quote-close" - /> - <SubtitleBadge - text="SIZE CONTROL" - color="#8B5CF6" - bgColor="rgba(139, 92, 246, 0.15)" - icon="format-size" - delay={50} - /> - </View> - </View> - - <View style={{ - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: useCustomSubtitles - ? 'rgba(76, 175, 80, 0.15)' - : 'rgba(255, 255, 255, 0.05)', - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: useCustomSubtitles - ? 'rgba(76, 175, 80, 0.3)' - : 'rgba(255, 255, 255, 0.1)', - }}> - {useCustomSubtitles ? ( - <Animated.View entering={ZoomIn.duration(200)}> - <MaterialIcons name="check-circle" size={24} color="#4CAF50" /> - </Animated.View> - ) : ( - <MaterialIcons name="subtitles" size={24} color="rgba(255,255,255,0.6)" /> - )} - </View> - </View> - </TouchableOpacity> - </Animated.View> - )} - - {/* Search for external subtitles */} - <Animated.View - entering={FadeInDown.duration(300).delay(250)} - layout={Layout.springify()} - > - <TouchableOpacity - style={{ - backgroundColor: 'rgba(33, 150, 243, 0.08)', - borderRadius: 20, - padding: 20, - borderWidth: 2, - borderColor: 'rgba(33, 150, 243, 0.2)', - elevation: 3, - shadowColor: '#2196F3', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 6, - }} - onPress={() => { - handleClose(); - fetchAvailableSubtitles(); - }} - disabled={isLoadingSubtitleList} - activeOpacity={0.85} - > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - }}> - {isLoadingSubtitleList ? ( - <ActivityIndicator size="small" color="#2196F3" style={{ marginRight: 12 }} /> - ) : ( - <MaterialIcons name="search" size={20} color="#2196F3" style={{ marginRight: 12 }} /> - )} - <Text style={{ - color: '#2196F3', - fontSize: 16, - fontWeight: '700', - letterSpacing: -0.2, - }}> - {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'} - </Text> - </View> - </TouchableOpacity> - </Animated.View> - </Animated.View> - - {/* Subtitle Size Controls */} - {useCustomSubtitles && ( - <Animated.View - entering={FadeInDown.duration(400).delay(200)} - layout={Layout.springify()} - style={{ - marginBottom: 32, - }} - > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 20, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.08)', - }}> - <LinearGradient - colors={['#8B5CF6', '#7C3AED']} - style={{ - width: 12, - height: 12, - borderRadius: 6, - marginRight: 16, - elevation: 3, - shadowColor: '#8B5CF6', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.4, - shadowRadius: 4, - }} - /> - <View style={{ flex: 1 }}> - <Text style={{ - color: '#fff', - fontSize: 18, - fontWeight: '700', - letterSpacing: -0.3, - }}> - Size Control - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.6)', - fontSize: 12, - marginTop: 1, - fontWeight: '500', - }}> - Adjust font size for better readability - </Text> - </View> - </View> - - <Animated.View - entering={FadeInDown.duration(300).delay(300)} - style={{ - backgroundColor: 'rgba(255, 255, 255, 0.03)', - borderRadius: 20, - padding: 24, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.08)', - elevation: 3, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 6, - }} - > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }}> - <TouchableOpacity - style={{ - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: 'rgba(139, 92, 246, 0.15)', - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: 'rgba(139, 92, 246, 0.3)', - elevation: 4, - shadowColor: '#8B5CF6', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 4, - }} - onPress={decreaseSubtitleSize} - activeOpacity={0.7} - > - <MaterialIcons name="remove" size={24} color="#8B5CF6" /> - </TouchableOpacity> - - <View style={{ - alignItems: 'center', - backgroundColor: 'rgba(139, 92, 246, 0.08)', - paddingHorizontal: 24, - paddingVertical: 16, - borderRadius: 16, - borderWidth: 1, - borderColor: 'rgba(139, 92, 246, 0.2)', - minWidth: 120, - }}> + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> <Text style={{ - color: '#8B5CF6', - fontSize: 24, - fontWeight: '800', - letterSpacing: -0.5, + color: '#FFFFFF', + fontSize: 15, + fontWeight: '500', + flex: 1, }}> - {subtitleSize}px + {getTrackDisplayName(track)} </Text> - <Text style={{ - color: 'rgba(139, 92, 246, 0.7)', - fontSize: 12, - fontWeight: '600', - marginTop: 2, - letterSpacing: 0.3, - }}> - Font Size - </Text> - </View> - - <TouchableOpacity - style={{ - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: 'rgba(139, 92, 246, 0.15)', - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: 'rgba(139, 92, 246, 0.3)', - elevation: 4, - shadowColor: '#8B5CF6', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 4, - }} - onPress={increaseSubtitleSize} - activeOpacity={0.7} - > - <MaterialIcons name="add" size={24} color="#8B5CF6" /> - </TouchableOpacity> - </View> - </Animated.View> - </Animated.View> - )} - - {/* Available built-in subtitle tracks */} - {vlcTextTracks.length > 0 ? vlcTextTracks.map((track, index) => ( - <Animated.View - key={track.id} - entering={FadeInDown.duration(300).delay(400 + (index * 50))} - layout={Layout.springify()} - style={{ marginBottom: 16 }} - > - <TouchableOpacity - style={{ - backgroundColor: (selectedTextTrack === track.id && !useCustomSubtitles) - ? 'rgba(255, 152, 0, 0.08)' - : 'rgba(255, 255, 255, 0.03)', - borderRadius: 20, - padding: 20, - borderWidth: 2, - borderColor: (selectedTextTrack === track.id && !useCustomSubtitles) - ? 'rgba(255, 152, 0, 0.4)' - : 'rgba(255, 255, 255, 0.08)', - elevation: (selectedTextTrack === track.id && !useCustomSubtitles) ? 8 : 3, - shadowColor: (selectedTextTrack === track.id && !useCustomSubtitles) ? '#FF9800' : '#000', - shadowOffset: { width: 0, height: (selectedTextTrack === track.id && !useCustomSubtitles) ? 4 : 2 }, - shadowOpacity: (selectedTextTrack === track.id && !useCustomSubtitles) ? 0.3 : 0.1, - shadowRadius: (selectedTextTrack === track.id && !useCustomSubtitles) ? 12 : 6, - }} - onPress={() => { - selectTextTrack(track.id); - handleClose(); - }} - activeOpacity={0.85} - > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }}> - <View style={{ flex: 1, marginRight: 16 }}> - <View style={{ - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - gap: 12, - }}> - <Text style={{ - color: (selectedTextTrack === track.id && !useCustomSubtitles) ? '#fff' : 'rgba(255, 255, 255, 0.95)', - fontSize: 16, - fontWeight: '700', - letterSpacing: -0.2, - flex: 1, - }}> - {getTrackDisplayName(track)} - </Text> - - {(selectedTextTrack === track.id && !useCustomSubtitles) && ( - <Animated.View - entering={BounceIn.duration(300)} - style={{ - flexDirection: 'row', - alignItems: 'center', - backgroundColor: 'rgba(255, 152, 0, 0.25)', - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: 14, - borderWidth: 1, - borderColor: 'rgba(255, 152, 0, 0.5)', - }} - > - <MaterialIcons name="subtitles" size={12} color="#FF9800" /> - <Text style={{ - color: '#FF9800', - fontSize: 10, - fontWeight: '800', - marginLeft: 3, - letterSpacing: 0.3, - }}> - ACTIVE - </Text> - </Animated.View> - )} - </View> - - <View style={{ - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - alignItems: 'center', - }}> - <SubtitleBadge - text="BUILT-IN" - color="#FF9800" - bgColor="rgba(255, 152, 0, 0.15)" - icon="settings" - /> - <SubtitleBadge - text="SYSTEM SIZE" - color="#6B7280" - bgColor="rgba(107, 114, 128, 0.15)" - icon="format-size" - delay={50} - /> - </View> - </View> - - <View style={{ - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: (selectedTextTrack === track.id && !useCustomSubtitles) - ? 'rgba(255, 152, 0, 0.15)' - : 'rgba(255, 255, 255, 0.05)', - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: (selectedTextTrack === track.id && !useCustomSubtitles) - ? 'rgba(255, 152, 0, 0.3)' - : 'rgba(255, 255, 255, 0.1)', - }}> - {(selectedTextTrack === track.id && !useCustomSubtitles) ? ( - <Animated.View entering={ZoomIn.duration(200)}> - <MaterialIcons name="check-circle" size={24} color="#FF9800" /> - </Animated.View> - ) : ( - <MaterialIcons name="text-fields" size={24} color="rgba(255,255,255,0.6)" /> + {isSelected && ( + <MaterialIcons name="check" size={20} color="#3B82F6" /> )} </View> - </View> - </TouchableOpacity> - </Animated.View> - )) : ( - <Animated.View - entering={FadeInDown.duration(300).delay(400)} - style={{ - backgroundColor: 'rgba(255, 255, 255, 0.02)', - borderRadius: 16, - padding: 32, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.05)', - }} - > - <MaterialIcons name="info-outline" size={32} color="rgba(255, 255, 255, 0.4)" /> - <Text style={{ - color: 'rgba(255, 255, 255, 0.6)', - fontSize: 16, - fontWeight: '600', - marginTop: 12, - textAlign: 'center', - letterSpacing: -0.2, - }}> - No built-in subtitles available - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.4)', - fontSize: 13, - marginTop: 4, - textAlign: 'center', - }}> - Try searching for external subtitles - </Text> - </Animated.View> - )} + </TouchableOpacity> + ); + })} + </View> </View> - </ScrollView> - </BlurView> - </Animated.View> - </Animated.View> - ); - }; + )} - // Render subtitle language selection modal - const renderSubtitleLanguageModal = () => { - if (!showSubtitleLanguageModal) return null; - - return ( - <Animated.View - entering={FadeIn.duration(250)} - exiting={FadeOut.duration(200)} - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - backgroundColor: 'rgba(0, 0, 0, 0.9)', - justifyContent: 'center', - alignItems: 'center', - zIndex: 9999, - padding: 16, - }} - > - {/* Backdrop */} - <TouchableOpacity - style={{ - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - }} - onPress={handleLanguageClose} - activeOpacity={1} - /> - - {/* Modal Content */} - <Animated.View - style={[ - { - width: MODAL_WIDTH, - maxHeight: MODAL_MAX_HEIGHT, - minHeight: height * 0.3, - overflow: 'hidden', - elevation: 25, - shadowColor: '#000', - shadowOffset: { width: 0, height: 12 }, - shadowOpacity: 0.4, - shadowRadius: 25, - alignSelf: 'center', - }, - languageModalStyle, - ]} - > - {/* Glassmorphism Background */} - <BlurView - intensity={100} - tint="dark" - style={{ - borderRadius: 28, - overflow: 'hidden', - backgroundColor: 'rgba(26, 26, 26, 0.8)', - width: '100%', - height: '100%', - }} - > - {/* Header */} - <LinearGradient - colors={[ - 'rgba(33, 150, 243, 0.95)', - 'rgba(30, 136, 229, 0.95)', - 'rgba(25, 118, 210, 0.9)' - ]} - locations={[0, 0.6, 1]} - style={{ - paddingHorizontal: 28, - paddingVertical: 24, + {/* Online Subtitles */} + <View style={{ marginBottom: 30 }}> + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', - borderBottomWidth: 1, - borderBottomColor: 'rgba(255, 255, 255, 0.1)', - width: '100%', - }} - > - <Animated.View - entering={FadeInDown.duration(300).delay(100)} - style={{ flex: 1 }} - > + marginBottom: 15, + }}> <Text style={{ - color: '#fff', - fontSize: 24, - fontWeight: '800', - letterSpacing: -0.8, - textShadowColor: 'rgba(0, 0, 0, 0.3)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 2, - }}> - Select Language - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.85)', + color: 'rgba(255, 255, 255, 0.7)', fontSize: 14, - marginTop: 4, - fontWeight: '500', - letterSpacing: 0.2, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, }}> - Choose from {availableSubtitles.length} available languages + Online Subtitles </Text> - </Animated.View> - - <Animated.View entering={BounceIn.duration(400).delay(200)}> - <TouchableOpacity + <TouchableOpacity style={{ - width: 44, - height: 44, - borderRadius: 22, - backgroundColor: 'rgba(255, 255, 255, 0.15)', - justifyContent: 'center', + backgroundColor: 'rgba(34, 197, 94, 0.15)', + borderRadius: 12, + paddingHorizontal: 12, + paddingVertical: 6, + flexDirection: 'row', alignItems: 'center', - marginLeft: 16, - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.2)', }} - onPress={handleLanguageClose} + onPress={() => fetchAvailableSubtitles()} + disabled={isLoadingSubtitleList} + > + {isLoadingSubtitleList ? ( + <ActivityIndicator size="small" color="#22C55E" /> + ) : ( + <MaterialIcons name="refresh" size={16} color="#22C55E" /> + )} + <Text style={{ + color: '#22C55E', + fontSize: 12, + fontWeight: '600', + marginLeft: 6, + }}> + {isLoadingSubtitleList ? 'Searching' : 'Refresh'} + </Text> + </TouchableOpacity> + </View> + + {availableSubtitles.length > 0 ? ( + <View style={{ gap: 8 }}> + {availableSubtitles.map((sub) => { + const isSelected = useCustomSubtitles && selectedOnlineSubtitleId === sub.id; + return ( + <TouchableOpacity + key={sub.id} + style={{ + backgroundColor: isSelected ? 'rgba(34, 197, 94, 0.15)' : 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: isSelected ? 'rgba(34, 197, 94, 0.3)' : 'rgba(255, 255, 255, 0.1)', + }} + onPress={() => { + handleLoadWyzieSubtitle(sub); + }} + activeOpacity={0.7} + disabled={isLoadingSubtitles} + > + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> + <View style={{ flex: 1 }}> + <Text style={{ + color: '#FFFFFF', + fontSize: 15, + fontWeight: '500', + marginBottom: 4, + }}> + {sub.display} + </Text> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 13, + }}> + {formatLanguage(sub.language)} + </Text> + </View> + {isLoadingSubtitles ? ( + <ActivityIndicator size="small" color="#22C55E" /> + ) : isSelected ? ( + <MaterialIcons name="check" size={20} color="#22C55E" /> + ) : ( + <MaterialIcons name="download" size={20} color="rgba(255,255,255,0.4)" /> + )} + </View> + </TouchableOpacity> + ); + })} + </View> + ) : !isLoadingSubtitleList ? ( + <TouchableOpacity + style={{ + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 20, + alignItems: 'center', + borderWidth: 1, + borderColor: 'rgba(255, 255, 255, 0.1)', + borderStyle: 'dashed', + }} + onPress={() => fetchAvailableSubtitles()} activeOpacity={0.7} > - <MaterialIcons name="close" size={20} color="#fff" /> - </TouchableOpacity> - </Animated.View> - </LinearGradient> - - {/* Content */} - <ScrollView - style={{ - maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height - backgroundColor: 'transparent', - width: '100%', - }} - showsVerticalScrollIndicator={false} - contentContainerStyle={{ - padding: 24, - paddingBottom: 32, - width: '100%', - }} - bounces={false} - > - {availableSubtitles.length > 0 ? availableSubtitles.map((subtitle, index) => ( - <Animated.View - key={subtitle.id} - entering={FadeInDown.duration(300).delay(150 + (index * 50))} - layout={Layout.springify()} - style={{ marginBottom: 16 }} - > - <TouchableOpacity - style={{ - backgroundColor: 'rgba(255, 255, 255, 0.03)', - borderRadius: 20, - padding: 20, - borderWidth: 2, - borderColor: 'rgba(255, 255, 255, 0.08)', - elevation: 3, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 6, - }} - onPress={() => loadWyzieSubtitle(subtitle)} - disabled={isLoadingSubtitles} - activeOpacity={0.85} - > - <View style={{ - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }}> - <View style={{ - flexDirection: 'row', - alignItems: 'center', - flex: 1, - marginRight: 16, - }}> - <Image - source={{ uri: subtitle.flagUrl }} - style={{ - width: 32, - height: 24, - borderRadius: 4, - marginRight: 16, - }} - resizeMode="cover" - /> - <View style={{ flex: 1 }}> - <Text style={{ - color: 'rgba(255, 255, 255, 0.95)', - fontSize: 16, - fontWeight: '700', - letterSpacing: -0.2, - marginBottom: 4, - }}> - {formatLanguage(subtitle.language)} - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.6)', - fontSize: 13, - fontWeight: '500', - }}> - {subtitle.display} - </Text> - </View> - </View> - - <View style={{ - width: 48, - height: 48, - borderRadius: 24, - backgroundColor: isLoadingSubtitles - ? 'rgba(33, 150, 243, 0.15)' - : 'rgba(255, 255, 255, 0.05)', - justifyContent: 'center', - alignItems: 'center', - borderWidth: 2, - borderColor: isLoadingSubtitles - ? 'rgba(33, 150, 243, 0.3)' - : 'rgba(255, 255, 255, 0.1)', - }}> - {isLoadingSubtitles ? ( - <ActivityIndicator size="small" color="#2196F3" /> - ) : ( - <MaterialIcons name="download" size={24} color="rgba(255,255,255,0.6)" /> - )} - </View> - </View> - </TouchableOpacity> - </Animated.View> - )) : ( - <Animated.View - entering={FadeInDown.duration(300).delay(150)} - style={{ - backgroundColor: 'rgba(255, 255, 255, 0.02)', - borderRadius: 20, - padding: 40, - alignItems: 'center', - borderWidth: 1, - borderColor: 'rgba(255, 255, 255, 0.05)', - }} - > - <MaterialIcons name="translate" size={48} color="rgba(255, 255, 255, 0.3)" /> + <MaterialIcons name="cloud-download" size={24} color="rgba(255,255,255,0.4)" /> <Text style={{ color: 'rgba(255, 255, 255, 0.6)', - fontSize: 18, - fontWeight: '700', - marginTop: 16, - textAlign: 'center', - letterSpacing: -0.3, - }}> - No subtitles found - </Text> - <Text style={{ - color: 'rgba(255, 255, 255, 0.4)', fontSize: 14, marginTop: 8, textAlign: 'center', - lineHeight: 20, }}> - No subtitles are available for this content.{'\n'}Try searching again or check back later. + Tap to search online </Text> - </Animated.View> + </TouchableOpacity> + ) : ( + <View style={{ + backgroundColor: 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 20, + alignItems: 'center', + }}> + <ActivityIndicator size="large" color="#22C55E" /> + <Text style={{ + color: 'rgba(255, 255, 255, 0.6)', + fontSize: 14, + marginTop: 12, + }}> + Searching... + </Text> + </View> )} - </ScrollView> - </BlurView> + </View> + + {/* Turn Off Subtitles */} + <View> + <Text style={{ + color: 'rgba(255, 255, 255, 0.7)', + fontSize: 14, + fontWeight: '600', + marginBottom: 15, + textTransform: 'uppercase', + letterSpacing: 0.5, + }}> + Options + </Text> + + <TouchableOpacity + style={{ + backgroundColor: selectedTextTrack === -1 && !useCustomSubtitles + ? 'rgba(239, 68, 68, 0.15)' + : 'rgba(255, 255, 255, 0.05)', + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: selectedTextTrack === -1 && !useCustomSubtitles + ? 'rgba(239, 68, 68, 0.3)' + : 'rgba(255, 255, 255, 0.1)', + }} + onPress={() => { + selectTextTrack(-1); + setSelectedOnlineSubtitleId(null); + }} + activeOpacity={0.7} + > + <View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}> + <View style={{ flexDirection: 'row', alignItems: 'center', flex: 1 }}> + <MaterialIcons + name="visibility-off" + size={20} + color={selectedTextTrack === -1 && !useCustomSubtitles ? "#EF4444" : "rgba(255,255,255,0.6)"} + style={{ marginRight: 12 }} + /> + <Text style={{ + color: '#FFFFFF', + fontSize: 15, + fontWeight: '500', + }}> + Turn Off Subtitles + </Text> + </View> + {selectedTextTrack === -1 && !useCustomSubtitles && ( + <MaterialIcons name="check" size={20} color="#EF4444" /> + )} + </View> + </TouchableOpacity> + </View> + </ScrollView> </Animated.View> - </Animated.View> + </> ); }; return ( <> - {renderSubtitleModal()} - {renderSubtitleLanguageModal()} + {renderSubtitleMenu()} </> ); }; diff --git a/src/components/player/subtitles/CustomSubtitles.tsx b/src/components/player/subtitles/CustomSubtitles.tsx index 66bbedf..3833876 100644 --- a/src/components/player/subtitles/CustomSubtitles.tsx +++ b/src/components/player/subtitles/CustomSubtitles.tsx @@ -6,19 +6,38 @@ interface CustomSubtitlesProps { useCustomSubtitles: boolean; currentSubtitle: string; subtitleSize: number; + subtitleBackground: boolean; + zoomScale?: number; // current video zoom scale; defaults to 1 } export const CustomSubtitles: React.FC<CustomSubtitlesProps> = ({ useCustomSubtitles, currentSubtitle, subtitleSize, + subtitleBackground, + zoomScale = 1, }) => { if (!useCustomSubtitles || !currentSubtitle) return null; + const inverseScale = 1 / zoomScale; return ( - <View style={styles.customSubtitleContainer} pointerEvents="none"> - <View style={styles.customSubtitleWrapper}> - <Text style={[styles.customSubtitleText, { fontSize: subtitleSize }]}> + <View + style={styles.customSubtitleContainer} + pointerEvents="none" + > + <View style={[ + styles.customSubtitleWrapper, + { + backgroundColor: subtitleBackground ? 'rgba(0, 0, 0, 0.7)' : 'transparent', + } + ]}> + <Text style={[ + styles.customSubtitleText, + { + fontSize: subtitleSize * inverseScale, + transform: [{ scale: inverseScale }], + } + ]}> {currentSubtitle} </Text> </View> diff --git a/src/components/player/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts index 84cea74..6b7b8cc 100644 --- a/src/components/player/utils/playerStyles.ts +++ b/src/components/player/utils/playerStyles.ts @@ -3,6 +3,7 @@ import { StyleSheet } from 'react-native'; export const styles = StyleSheet.create({ container: { backgroundColor: '#000', + flex: 1, position: 'absolute', top: 0, left: 0, @@ -134,7 +135,7 @@ export const styles = StyleSheet.create({ zIndex: 1000, }, progressTouchArea: { - height: 30, + height: 40, // Increased from 30 to give more space for the thumb justifyContent: 'center', width: '100%', }, @@ -161,6 +162,21 @@ export const styles = StyleSheet.create({ backgroundColor: '#E50914', height: '100%', }, + progressThumb: { + position: 'absolute', + width: 16, + height: 16, + borderRadius: 8, + backgroundColor: '#E50914', + top: -6, // Position to center on the progress bar + marginLeft: -8, // Center the thumb horizontally + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.3, + shadowRadius: 3, + elevation: 4, + zIndex: 10, // Ensure it appears above the progress bar + }, timeDisplay: { flexDirection: 'row', justifyContent: 'space-between', @@ -474,7 +490,6 @@ export const styles = StyleSheet.create({ }, openingContent: { padding: 20, - backgroundColor: 'rgba(0,0,0,0.85)', borderRadius: 10, justifyContent: 'center', alignItems: 'center', @@ -531,14 +546,13 @@ export const styles = StyleSheet.create({ }, customSubtitleContainer: { position: 'absolute', - bottom: 40, // Position above controls and progress bar + bottom: 20, // Position lower, closer to bottom left: 20, right: 20, alignItems: 'center', zIndex: 1500, // Higher z-index to appear above other elements }, customSubtitleWrapper: { - backgroundColor: 'rgba(0, 0, 0, 0.7)', padding: 10, borderRadius: 5, }, diff --git a/src/constants/discover.ts b/src/constants/discover.ts deleted file mode 100644 index 1af9de2..0000000 --- a/src/constants/discover.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { MaterialIcons } from '@expo/vector-icons'; -import { StreamingContent } from '../services/catalogService'; - -export interface Category { - id: string; - name: string; - type: 'movie' | 'series' | 'channel' | 'tv'; - icon: keyof typeof MaterialIcons.glyphMap; -} - -export interface GenreCatalog { - genre: string; - items: StreamingContent[]; -} - -export const CATEGORIES: Category[] = [ - { id: 'movie', name: 'Movies', type: 'movie', icon: 'local-movies' }, - { id: 'series', name: 'TV Shows', type: 'series', icon: 'live-tv' } -]; - -// Common genres for movies and TV shows -export const COMMON_GENRES = [ - 'All', - 'Action', - 'Adventure', - 'Animation', - 'Comedy', - 'Crime', - 'Documentary', - 'Drama', - 'Family', - 'Fantasy', - 'History', - 'Horror', - 'Music', - 'Mystery', - 'Romance', - 'Science Fiction', - 'Thriller', - 'War', - 'Western' -]; \ No newline at end of file diff --git a/src/hooks/useCalendarData.ts b/src/hooks/useCalendarData.ts new file mode 100644 index 0000000..b13e9fc --- /dev/null +++ b/src/hooks/useCalendarData.ts @@ -0,0 +1,261 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { useLibrary } from './useLibrary'; +import { useTraktContext } from '../contexts/TraktContext'; +import { robustCalendarCache } from '../services/robustCalendarCache'; +import { stremioService } from '../services/stremioService'; +import { tmdbService } from '../services/tmdbService'; +import { logger } from '../utils/logger'; +import { parseISO, isBefore, isAfter, startOfToday, addWeeks, isThisWeek } from 'date-fns'; +import { StreamingContent } from '../services/catalogService'; + +interface CalendarEpisode { + id: string; + seriesId: string; + title: string; + seriesName: string; + poster: string; + releaseDate: string; + season: number; + episode: number; + overview: string; + vote_average: number; + still_path: string | null; + season_poster_path: string | null; + } + + interface CalendarSection { + title: string; + data: CalendarEpisode[]; + } + +interface UseCalendarDataReturn { + calendarData: CalendarSection[]; + loading: boolean; + refresh: (force?: boolean) => void; +} + +export const useCalendarData = (): UseCalendarDataReturn => { + const [calendarData, setCalendarData] = useState<CalendarSection[]>([]); + const [loading, setLoading] = useState(true); + + const { libraryItems, loading: libraryLoading } = useLibrary(); + const { + isAuthenticated: traktAuthenticated, + isLoading: traktLoading, + watchedShows, + watchlistShows, + continueWatching, + loadAllCollections, + } = useTraktContext(); + + const fetchCalendarData = useCallback(async (forceRefresh = false) => { + logger.log("[CalendarData] Starting to fetch calendar data"); + setLoading(true); + + try { + if (!forceRefresh) { + const cachedData = await robustCalendarCache.getCachedCalendarData( + libraryItems, + { + watchlist: watchlistShows, + continueWatching: continueWatching, + watched: watchedShows, + } + ); + + if (cachedData) { + logger.log(`[CalendarData] Using cached data with ${cachedData.length} sections`); + setCalendarData(cachedData); + setLoading(false); + return; + } + } + + logger.log("[CalendarData] Fetching fresh data from APIs"); + + const librarySeries = libraryItems.filter(item => item.type === 'series'); + let allSeries: StreamingContent[] = [...librarySeries]; + + if (traktAuthenticated) { + const traktSeriesIds = new Set(); + + if (watchlistShows) { + for (const item of watchlistShows) { + if (item.show && item.show.ids.imdb) { + const imdbId = item.show.ids.imdb; + if (!librarySeries.some(s => s.id === imdbId)) { + traktSeriesIds.add(imdbId); + allSeries.push({ + id: imdbId, + name: item.show.title, + type: 'series', + poster: '', + year: item.show.year, + traktSource: 'watchlist' + }); + } + } + } + } + + if (continueWatching) { + for (const item of continueWatching) { + if (item.type === 'episode' && item.show && item.show.ids.imdb) { + const imdbId = item.show.ids.imdb; + if (!librarySeries.some(s => s.id === imdbId) && !traktSeriesIds.has(imdbId)) { + traktSeriesIds.add(imdbId); + allSeries.push({ + id: imdbId, + name: item.show.title, + type: 'series', + poster: '', + year: item.show.year, + traktSource: 'continue-watching' + }); + } + } + } + } + + if (watchedShows) { + const recentWatched = watchedShows.slice(0, 20); + for (const item of recentWatched) { + if (item.show && item.show.ids.imdb) { + const imdbId = item.show.ids.imdb; + if (!librarySeries.some(s => s.id === imdbId) && !traktSeriesIds.has(imdbId)) { + traktSeriesIds.add(imdbId); + allSeries.push({ + id: imdbId, + name: item.show.title, + type: 'series', + poster: '', + year: item.show.year, + traktSource: 'watched' + }); + } + } + } + } + } + + logger.log(`[CalendarData] Total series to check: ${allSeries.length} (Library: ${librarySeries.length}, Trakt: ${allSeries.length - librarySeries.length})`); + + let allEpisodes: CalendarEpisode[] = []; + let seriesWithoutEpisodes: CalendarEpisode[] = []; + + for (const series of allSeries) { + try { + const metadata = await stremioService.getMetaDetails(series.type, series.id); + + if (metadata?.videos && metadata.videos.length > 0) { + const today = startOfToday(); + const fourWeeksLater = addWeeks(today, 4); + const twoWeeksAgo = addWeeks(today, -2); + + const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id); + let tmdbEpisodes: { [key: string]: any } = {}; + + if (tmdbId) { + const allTMDBEpisodes = await tmdbService.getAllEpisodes(tmdbId); + Object.values(allTMDBEpisodes).forEach(seasonEpisodes => { + seasonEpisodes.forEach(episode => { + const key = `${episode.season_number}:${episode.episode_number}`; + tmdbEpisodes[key] = episode; + }); + }); + } + + const upcomingEpisodes = metadata.videos + .filter(video => { + if (!video.released) return false; + const releaseDate = parseISO(video.released); + return isBefore(releaseDate, fourWeeksLater) && isAfter(releaseDate, twoWeeksAgo); + }) + .map(video => { + const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {}; + return { + id: video.id, + seriesId: series.id, + title: tmdbEpisode.name || video.title || `Episode ${video.episode}`, + seriesName: series.name || metadata.name, + poster: series.poster || metadata.poster || '', + releaseDate: video.released, + season: video.season || 0, + episode: video.episode || 0, + overview: tmdbEpisode.overview || '', + vote_average: tmdbEpisode.vote_average || 0, + still_path: tmdbEpisode.still_path || null, + season_poster_path: tmdbEpisode.season_poster_path || null + }; + }); + + if (upcomingEpisodes.length > 0) { + allEpisodes = [...allEpisodes, ...upcomingEpisodes]; + } else { + seriesWithoutEpisodes.push({ id: series.id, seriesId: series.id, title: 'No upcoming episodes', seriesName: series.name || (metadata?.name || ''), poster: series.poster || (metadata?.poster || ''), releaseDate: '', season: 0, episode: 0, overview: '', vote_average: 0, still_path: null, season_poster_path: null }); + } + } else { + seriesWithoutEpisodes.push({ id: series.id, seriesId: series.id, title: 'No upcoming episodes', seriesName: series.name || (metadata?.name || ''), poster: series.poster || (metadata?.poster || ''), releaseDate: '', season: 0, episode: 0, overview: '', vote_average: 0, still_path: null, season_poster_path: null }); + } + } catch (error) { + logger.error(`Error fetching episodes for ${series.name}:`, error); + } + } + + allEpisodes.sort((a, b) => new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime()); + + const thisWeekEpisodes = allEpisodes.filter(ep => isThisWeek(parseISO(ep.releaseDate))); + const upcomingEpisodes = allEpisodes.filter(ep => isAfter(parseISO(ep.releaseDate), new Date()) && !isThisWeek(parseISO(ep.releaseDate))); + const recentEpisodes = allEpisodes.filter(ep => isBefore(parseISO(ep.releaseDate), new Date()) && !isThisWeek(parseISO(ep.releaseDate))); + + const sections: CalendarSection[] = []; + if (thisWeekEpisodes.length > 0) sections.push({ title: 'This Week', data: thisWeekEpisodes }); + if (upcomingEpisodes.length > 0) sections.push({ title: 'Upcoming', data: upcomingEpisodes }); + if (recentEpisodes.length > 0) sections.push({ title: 'Recently Released', data: recentEpisodes }); + if (seriesWithoutEpisodes.length > 0) sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes }); + + setCalendarData(sections); + + await robustCalendarCache.setCachedCalendarData( + sections, + libraryItems, + { watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows } + ); + + } catch (error) { + logger.error('Error fetching calendar data:', error); + await robustCalendarCache.setCachedCalendarData( + [], + libraryItems, + { watchlist: watchlistShows, continueWatching: continueWatching, watched: watchedShows }, + true + ); + } finally { + setLoading(false); + } + }, [libraryItems, traktAuthenticated, watchlistShows, continueWatching, watchedShows]); + + useEffect(() => { + if (!libraryLoading && !traktLoading) { + if (traktAuthenticated && (!watchlistShows || !continueWatching || !watchedShows)) { + loadAllCollections(); + } else { + fetchCalendarData(); + } + } else if (!libraryLoading && !traktAuthenticated) { + fetchCalendarData(); + } + }, [libraryItems, libraryLoading, traktLoading, traktAuthenticated, watchlistShows, continueWatching, watchedShows, fetchCalendarData, loadAllCollections]); + + const refresh = useCallback((force = false) => { + fetchCalendarData(force); + }, [fetchCalendarData]); + + return { + calendarData, + loading, + refresh, + }; +}; + + \ No newline at end of file diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 249fefe..a013082 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -140,24 +140,29 @@ export function useFeaturedContent() { if (signal.aborted) return; - // Filter catalogs based on user selection if any catalogs are selected - const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0 - ? catalogs.filter(catalog => { - const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`; - return selectedCatalogs.includes(catalogId); - }) - : catalogs; // Use all catalogs if none specifically selected + // If no catalogs are installed, stop loading and return. + if (catalogs.length === 0) { + formattedContent = []; + } else { + // Filter catalogs based on user selection if any catalogs are selected + const filteredCatalogs = selectedCatalogs && selectedCatalogs.length > 0 + ? catalogs.filter(catalog => { + const catalogId = `${catalog.addon}:${catalog.type}:${catalog.id}`; + return selectedCatalogs.includes(catalogId); + }) + : catalogs; // Use all catalogs if none specifically selected - // Flatten all catalog items into a single array, filter out items without posters - const allItems = filteredCatalogs.flatMap(catalog => catalog.items) - .filter(item => item.poster) - .filter((item, index, self) => - // Remove duplicates based on ID - index === self.findIndex(t => t.id === item.id) - ); + // Flatten all catalog items into a single array, filter out items without posters + const allItems = filteredCatalogs.flatMap(catalog => catalog.items) + .filter(item => item.poster) + .filter((item, index, self) => + // Remove duplicates based on ID + index === self.findIndex(t => t.id === item.id) + ); - // Sort by popular, newest, etc. (possibly enhanced later) - formattedContent = allItems.sort(() => Math.random() - 0.5).slice(0, 10); + // Sort by popular, newest, etc. (possibly enhanced later) + formattedContent = allItems.sort(() => Math.random() - 0.5).slice(0, 10); + } } if (signal.aborted) return; @@ -299,7 +304,8 @@ export function useFeaturedContent() { } }; - const intervalId = setInterval(rotateContent, 15000); + // Increased rotation interval from 15s to 45s to reduce heating + const intervalId = setInterval(rotateContent, 45000); return () => clearInterval(intervalId); }, [allFeaturedContent]); diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts index 0f8e656..5164359 100644 --- a/src/hooks/useLibrary.ts +++ b/src/hooks/useLibrary.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { StreamingContent } from '../types/metadata'; +import { catalogService } from '../services/catalogService'; const LIBRARY_STORAGE_KEY = 'stremio-library'; @@ -83,6 +84,17 @@ export const useLibrary = () => { loadLibraryItems(); }, [loadLibraryItems]); + // Subscribe to catalogService library updates + useEffect(() => { + const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { + console.log('[useLibrary] Received library update from catalogService:', items.length, 'items'); + setLibraryItems(items); + setLoading(false); + }); + + return () => unsubscribe(); + }, []); + return { libraryItems, loading, diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index 0c796c4..1696b4d 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -3,8 +3,8 @@ import { StreamingContent } from '../services/catalogService'; import { catalogService } from '../services/catalogService'; import { stremioService } from '../services/stremioService'; import { tmdbService } from '../services/tmdbService'; -import { hdrezkaService } from '../services/hdrezkaService'; import { cacheService } from '../services/cacheService'; +import { localScraperService, ScraperInfo } from '../services/localScraperService'; import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata'; import { TMDBService } from '../services/tmdbService'; import { logger } from '../utils/logger'; @@ -63,6 +63,16 @@ interface UseMetadataProps { addonId?: string; } +interface ScraperStatus { + id: string; + name: string; + isLoading: boolean; + hasCompleted: boolean; + error: string | null; + startTime: number; + endTime: number | null; +} + interface UseMetadataReturn { metadata: StreamingContent | null; loading: boolean; @@ -93,6 +103,8 @@ interface UseMetadataReturn { loadingRecommendations: boolean; setMetadata: React.Dispatch<React.SetStateAction<StreamingContent | null>>; imdbId: string | null; + scraperStatuses: ScraperStatus[]; + activeFetchingScrapers: string[]; } export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => { @@ -120,6 +132,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const [imdbId, setImdbId] = useState<string | null>(null); const [isLoading, setIsLoading] = useState(false); const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({}); + const [scraperStatuses, setScraperStatuses] = useState<ScraperStatus[]>([]); + const [activeFetchingScrapers, setActiveFetchingScrapers] = useState<string[]>([]); // Add hook for persistent seasons const { getSeason, saveSeason } = usePersistentSeasons(); @@ -135,10 +149,36 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat await stremioService.getStreams(type, id, (streams, addonId, addonName, error) => { const processTime = Date.now() - sourceStartTime; + + // Update scraper status when we get a callback + if (addonId && addonName) { + setScraperStatuses(prevStatuses => { + const existingIndex = prevStatuses.findIndex(s => s.id === addonId); + const newStatus: ScraperStatus = { + id: addonId, + name: addonName, + isLoading: false, + hasCompleted: true, + error: error ? error.message : null, + startTime: sourceStartTime, + endTime: Date.now() + }; + + if (existingIndex >= 0) { + const updated = [...prevStatuses]; + updated[existingIndex] = newStatus; + return updated; + } else { + return [...prevStatuses, newStatus]; + } + }); + + // Remove from active fetching list + setActiveFetchingScrapers(prev => prev.filter(name => name !== addonName)); + } + if (error) { logger.error(`❌ [${logPrefix}:${sourceName}] Error for addon ${addonName} (${addonId}):`, error); - // Optionally update state to show error for this specific addon? - // For now, just log the error. } else if (streams && addonId && addonName) { logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams from ${addonName} (${addonId}) after ${processTime}ms`); @@ -184,97 +224,24 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Loading indicators should probably be managed based on callbacks completing. }; - const processHDRezkaSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => { - const sourceStartTime = Date.now(); - const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; - const sourceName = 'hdrezka'; - - logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`); - - try { - const streams = await hdrezkaService.getStreams( - id, - type, - season, - episode - ); - - const processTime = Date.now() - sourceStartTime; - - if (streams && streams.length > 0) { - logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`); - - // Format response similar to Stremio format for the UI - return { - 'hdrezka': { - addonName: 'HDRezka', - streams - } - }; - } else { - logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`); - return {}; - } - } catch (error) { - logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error); - return {}; - } - }; - - const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => { - try { - const startTime = Date.now(); - const result = await promise; - const processingTime = Date.now() - startTime; - - if (result && Object.keys(result).length > 0) { - // Update the appropriate state based on whether this is for an episode or not - const updateState = (prevState: GroupedStreams) => { - const newState = { ...prevState }; - - // Merge in the new streams - Object.entries(result).forEach(([provider, data]: [string, any]) => { - newState[provider] = data; - }); - - return newState; - }; - - if (isEpisode) { - setEpisodeStreams(updateState); - } else { - setGroupedStreams(updateState); - } - - console.log(`✅ [processExternalSource:${sourceType}] Processed in ${processingTime}ms, found streams:`, - Object.values(result).reduce((acc: number, curr: any) => acc + (curr.streams?.length || 0), 0) - ); - - // Return the result for the promise chain - return result; - } else { - console.log(`⚠️ [processExternalSource:${sourceType}] No streams found after ${processingTime}ms`); - return {}; - } - } catch (error) { - console.error(`❌ [processExternalSource:${sourceType}] Error:`, error); - return {}; - } - }; - const loadCast = async () => { + logger.log('[loadCast] Starting cast fetch for:', id); setLoadingCast(true); try { + // Check cache first + const cachedCast = cacheService.getCast(id, type); + if (cachedCast) { + logger.log('[loadCast] Using cached cast data'); + setCast(cachedCast); + setLoadingCast(false); + return; + } + // Handle TMDB IDs - let metadataId = id; - let metadataType = type; - if (id.startsWith('tmdb:')) { - const extractedTmdbId = id.split(':')[1]; - logger.log('[loadCast] Using extracted TMDB ID:', extractedTmdbId); - - // For TMDB IDs, we'll use the TMDB API directly - const castData = await tmdbService.getCredits(parseInt(extractedTmdbId), type); + const tmdbId = id.split(':')[1]; + logger.log('[loadCast] Using TMDB ID directly:', tmdbId); + const castData = await tmdbService.getCredits(parseInt(tmdbId), type); if (castData && castData.cast) { const formattedCast = castData.cast.map((actor: any) => ({ id: actor.id, @@ -282,49 +249,41 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat character: actor.character, profile_path: actor.profile_path })); + logger.log(`[loadCast] Found ${formattedCast.length} cast members from TMDB`); setCast(formattedCast); + cacheService.setCast(id, type, formattedCast); setLoadingCast(false); - return formattedCast; + return; } - setLoadingCast(false); - return []; - } - - // Continue with the existing logic for non-TMDB IDs - const cachedCast = cacheService.getCast(id, type); - if (cachedCast) { - setCast(cachedCast); - setLoadingCast(false); - return; } - // Load cast in parallel with a fallback to empty array - const castLoadingPromise = loadWithFallback(async () => { - const tmdbId = await withTimeout( - tmdbService.findTMDBIdByIMDB(id), - API_TIMEOUT - ); - - if (tmdbId) { - const castData = await withTimeout( - tmdbService.getCredits(tmdbId, type), - API_TIMEOUT, - { cast: [], crew: [] } - ); - - if (castData.cast && castData.cast.length > 0) { - setCast(castData.cast); - cacheService.setCast(id, type, castData.cast); - return castData.cast; - } - } - return []; - }, []); + // Handle IMDb IDs or convert to TMDB ID + let tmdbId; + if (id.startsWith('tt')) { + logger.log('[loadCast] Converting IMDb ID to TMDB ID'); + tmdbId = await tmdbService.findTMDBIdByIMDB(id); + } - await castLoadingPromise; + if (tmdbId) { + logger.log('[loadCast] Fetching cast using TMDB ID:', tmdbId); + const castData = await tmdbService.getCredits(tmdbId, type); + if (castData && castData.cast) { + const formattedCast = castData.cast.map((actor: any) => ({ + id: actor.id, + name: actor.name, + character: actor.character, + profile_path: actor.profile_path + })); + logger.log(`[loadCast] Found ${formattedCast.length} cast members`); + setCast(formattedCast); + cacheService.setCast(id, type, formattedCast); + } + } else { + logger.warn('[loadCast] Could not find TMDB ID for cast fetch'); + } } catch (error) { - console.error('Failed to load cast:', error); - setCast([]); + logger.error('[loadCast] Failed to load cast:', error); + // Don't clear existing cast data on error } finally { setLoadingCast(false); } @@ -539,13 +498,6 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setMetadata(content.value); // Update cache cacheService.setMetadata(id, type, content.value); - - if (type === 'series') { - // Load series data after the enhanced metadata is processed - setTimeout(() => { - loadSeriesData().catch(console.error); - }, 100); - } } else { throw new Error('Content not found'); } @@ -576,7 +528,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const groupedAddonEpisodes: GroupedEpisodes = {}; addonVideos.forEach((video: any) => { - const seasonNumber = video.season || 1; + const seasonNumber = video.season; + if (!seasonNumber || seasonNumber < 1) { + return; // Skip season 0, which often contains extras + } const episodeNumber = video.episode || video.number || 1; if (!groupedAddonEpisodes[seasonNumber]) { @@ -591,7 +546,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat season_number: seasonNumber, episode_number: episodeNumber, air_date: video.released ? video.released.split('T')[0] : video.firstAired ? video.firstAired.split('T')[0] : '', - still_path: video.thumbnail ? video.thumbnail.replace('https://image.tmdb.org/t/p/w500', '') : null, + still_path: video.thumbnail, vote_average: parseFloat(video.rating) || 0, runtime: undefined, episodeString: `S${seasonNumber.toString().padStart(2, '0')}E${episodeNumber.toString().padStart(2, '0')}`, @@ -608,6 +563,32 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat }); logger.log(`📺 Processed addon episodes into ${Object.keys(groupedAddonEpisodes).length} seasons`); + + // Fetch season posters from TMDB + try { + const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null); + if (tmdbIdToUse) { + if (!tmdbId) setTmdbId(tmdbIdToUse); + const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse); + if (showDetails?.seasons) { + Object.keys(groupedAddonEpisodes).forEach(seasonStr => { + const seasonNum = parseInt(seasonStr, 10); + const seasonInfo = showDetails.seasons.find(s => s.season_number === seasonNum); + const seasonPosterPath = seasonInfo?.poster_path; + if (seasonPosterPath) { + groupedAddonEpisodes[seasonNum] = groupedAddonEpisodes[seasonNum].map(ep => ({ + ...ep, + season_poster_path: seasonPosterPath, + })); + } + }); + logger.log('🖼️ Successfully fetched and attached TMDB season posters to addon episodes.'); + } + } + } catch (error) { + logger.error('Failed to fetch TMDB season posters for addon episodes:', error); + } + setGroupedEpisodes(groupedAddonEpisodes); // Set the first available season @@ -638,11 +619,16 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat ]); const transformedEpisodes: GroupedEpisodes = {}; - Object.entries(allEpisodes).forEach(([season, episodes]) => { - const seasonInfo = showDetails?.seasons?.find(s => s.season_number === parseInt(season)); + Object.entries(allEpisodes).forEach(([seasonStr, episodes]) => { + const seasonNum = parseInt(seasonStr, 10); + if (seasonNum < 1) { + return; // Skip season 0, which often contains extras + } + + const seasonInfo = showDetails?.seasons?.find(s => s.season_number === seasonNum); const seasonPosterPath = seasonInfo?.poster_path; - transformedEpisodes[parseInt(season)] = episodes.map(episode => ({ + transformedEpisodes[seasonNum] = episodes.map(episode => ({ ...episode, episodeString: `S${episode.season_number.toString().padStart(2, '0')}E${episode.episode_number.toString().padStart(2, '0')}`, season_poster_path: seasonPosterPath || null @@ -740,6 +726,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat try { console.log('🚀 [loadStreams] START - Loading streams for:', id); updateLoadingState(); + + // Reset scraper tracking + setScraperStatuses([]); + setActiveFetchingScrapers([]); // Get TMDB ID for external sources and determine the correct ID for Stremio addons console.log('🔍 [loadStreams] Getting TMDB ID for:', id); @@ -790,48 +780,84 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat console.log('ℹ️ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId); } + // Initialize scraper tracking + try { + const allStremioAddons = await stremioService.getInstalledAddons(); + const localScrapers = await localScraperService.getInstalledScrapers(); + + // Filter Stremio addons to only include those that provide streams for this content type + const streamAddons = allStremioAddons.filter(addon => { + if (!addon.resources || !Array.isArray(addon.resources)) { + return false; + } + + let hasStreamResource = false; + + for (const resource of addon.resources) { + // Check if the current element is a ResourceObject + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as any; + if (typedResource.name === 'stream' && + Array.isArray(typedResource.types) && + typedResource.types.includes(type)) { + hasStreamResource = true; + break; + } + } + // Check if the element is the simple string "stream" AND the addon has a top-level types array + else if (typeof resource === 'string' && resource === 'stream' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes(type)) { + hasStreamResource = true; + break; + } + } + } + + return hasStreamResource; + }); + + // Initialize scraper statuses for tracking + const initialStatuses: ScraperStatus[] = []; + const initialActiveFetching: string[] = []; + + // Add stream-capable Stremio addons only + streamAddons.forEach(addon => { + initialStatuses.push({ + id: addon.id, + name: addon.name, + isLoading: true, + hasCompleted: false, + error: null, + startTime: Date.now(), + endTime: null + }); + initialActiveFetching.push(addon.name); + }); + + // Add local scrapers if enabled + localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => { + initialStatuses.push({ + id: scraper.id, + name: scraper.name, + isLoading: true, + hasCompleted: false, + error: null, + startTime: Date.now(), + endTime: null + }); + initialActiveFetching.push(scraper.name); + }); + + setScraperStatuses(initialStatuses); + setActiveFetchingScrapers(initialActiveFetching); + } catch (error) { + console.error('Failed to initialize scraper tracking:', error); + } + // Start Stremio request using the converted ID format console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId); processStremioSource(type, stremioId, false); - // Add HDRezka source - const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false); - - // Include HDRezka in fetchPromises array - const fetchPromises: Promise<any>[] = [hdrezkaPromise]; - - // Wait only for external promises now - const results = await Promise.allSettled(fetchPromises); - const totalTime = Date.now() - startTime; - console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - - const sourceTypes: string[] = ['hdrezka']; - results.forEach((result, index) => { - const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; - console.log(`📊 [loadStreams:${source}] Status: ${result.status}`); - if (result.status === 'rejected') { - console.error(`❌ [loadStreams:${source}] Error:`, result.reason); - } - }); - - console.log('🧮 [loadStreams] Summary:'); - console.log(' Total time for external sources:', totalTime + 'ms'); - - // Log the final states - this might not include all Stremio addons yet - console.log('📦 [loadStreams] Current combined streams count:', - Object.keys(groupedStreams).length > 0 ? - Object.values(groupedStreams).reduce((acc, group: any) => acc + group.streams.length, 0) : - 0 - ); - - // Cache the final streams state - Note: This might be incomplete if Stremio addons are slow - setGroupedStreams(prev => { - // We might want to reconsider when exactly to cache or mark loading as fully complete - // cacheService.setStreams(id, type, prev); // Maybe cache incrementally in callback? - setPreloadedStreams(prev); - return prev; - }); - // Add a delay before marking loading as complete to give Stremio addons more time setTimeout(() => { setLoadingStreams(false); @@ -849,6 +875,84 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat try { console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId); updateEpisodeLoadingState(); + + // Reset scraper tracking for episodes + setScraperStatuses([]); + setActiveFetchingScrapers([]); + + // Initialize scraper tracking for episodes + try { + const allStremioAddons = await stremioService.getInstalledAddons(); + const localScrapers = await localScraperService.getInstalledScrapers(); + + // Filter Stremio addons to only include those that provide streams for series content + const streamAddons = allStremioAddons.filter(addon => { + if (!addon.resources || !Array.isArray(addon.resources)) { + return false; + } + + let hasStreamResource = false; + + for (const resource of addon.resources) { + // Check if the current element is a ResourceObject + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as any; + if (typedResource.name === 'stream' && + Array.isArray(typedResource.types) && + typedResource.types.includes('series')) { + hasStreamResource = true; + break; + } + } + // Check if the element is the simple string "stream" AND the addon has a top-level types array + else if (typeof resource === 'string' && resource === 'stream' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes('series')) { + hasStreamResource = true; + break; + } + } + } + + return hasStreamResource; + }); + + // Initialize scraper statuses for tracking + const initialStatuses: ScraperStatus[] = []; + const initialActiveFetching: string[] = []; + + // Add stream-capable Stremio addons only + streamAddons.forEach(addon => { + initialStatuses.push({ + id: addon.id, + name: addon.name, + isLoading: true, + hasCompleted: false, + error: null, + startTime: Date.now(), + endTime: null + }); + initialActiveFetching.push(addon.name); + }); + + // Add local scrapers if enabled + localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => { + initialStatuses.push({ + id: scraper.id, + name: scraper.name, + isLoading: true, + hasCompleted: false, + error: null, + startTime: Date.now(), + endTime: null + }); + initialActiveFetching.push(scraper.name); + }); + + setScraperStatuses(initialStatuses); + setActiveFetchingScrapers(initialActiveFetching); + } catch (error) { + console.error('Failed to initialize episode scraper tracking:', error); + } // Get TMDB ID for external sources and determine the correct ID for Stremio addons console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id); @@ -906,40 +1010,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId); processStremioSource('series', stremioEpisodeId, true); - // Add HDRezka source for episodes - const hdrezkaEpisodePromise = processExternalSource('hdrezka', - processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true), - true - ); - - const fetchPromises: Promise<any>[] = [hdrezkaEpisodePromise]; - - // Wait only for external promises now - const results = await Promise.allSettled(fetchPromises); - const totalTime = Date.now() - startTime; - console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); - - const sourceTypes: string[] = ['hdrezka']; - results.forEach((result, index) => { - const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; - console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`); - if (result.status === 'rejected') { - console.error(`❌ [loadEpisodeStreams:${source}] Error:`, result.reason); - } - }); - - console.log('🧮 [loadEpisodeStreams] Summary:'); - console.log(' Total time for external sources:', totalTime + 'ms'); - - // Update preloaded episode streams for future use - if (Object.keys(episodeStreams).length > 0) { - setPreloadedEpisodeStreams(prev => ({ - ...prev, - [episodeId]: { ...episodeStreams } - })); - } - - // Add a delay before marking loading as complete to give addons more time + // Add a delay before marking loading as complete to give Stremio addons more time setTimeout(() => { setLoadingEpisodeStreams(false); }, 10000); // 10 second delay to allow streams to load @@ -1113,5 +1184,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat loadingRecommendations, setMetadata, imdbId, + scraperStatuses, + activeFetchingScrapers, }; -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts index 89f0d6d..671e400 100644 --- a/src/hooks/useMetadataAnimations.ts +++ b/src/hooks/useMetadataAnimations.ts @@ -7,6 +7,7 @@ import { Easing, useAnimatedScrollHandler, runOnUI, + cancelAnimation, } from 'react-native-reanimated'; const { width, height } = Dimensions.get('window'); @@ -57,10 +58,11 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) = // Ultra-fast entrance sequence - batch animations for better performance useEffect(() => { - // Batch all entrance animations to run simultaneously + // Batch all entrance animations to run simultaneously with safety const enterAnimations = () => { 'worklet'; + try { // Start with slightly reduced values and animate to full visibility screenOpacity.value = withTiming(1, { duration: 250, @@ -85,32 +87,70 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) = duration: 350, easing: easings.fast }); + } catch (error) { + // Silently handle any animation errors + console.warn('Animation error in enterAnimations:', error); + } }; - // Use runOnUI for better performance + // Use runOnUI for better performance with error handling + try { runOnUI(enterAnimations)(); + } catch (error) { + console.warn('Failed to run enter animations:', error); + } }, []); - // Optimized watch progress animation + // Optimized watch progress animation with safety useEffect(() => { const hasProgress = watchProgress && watchProgress.duration > 0; const updateProgress = () => { 'worklet'; + + try { progressOpacity.value = withTiming(hasProgress ? 1 : 0, { duration: hasProgress ? 200 : 150, easing: easings.fast }); + } catch (error) { + console.warn('Animation error in updateProgress:', error); + } }; + try { runOnUI(updateProgress)(); + } catch (error) { + console.warn('Failed to run progress animation:', error); + } }, [watchProgress]); - // Ultra-optimized scroll handler with minimal calculations + // Cleanup function to cancel animations + useEffect(() => { + return () => { + try { + cancelAnimation(screenOpacity); + cancelAnimation(contentOpacity); + cancelAnimation(heroOpacity); + cancelAnimation(heroScale); + cancelAnimation(uiElementsOpacity); + cancelAnimation(uiElementsTranslateY); + cancelAnimation(progressOpacity); + cancelAnimation(scrollY); + cancelAnimation(headerProgress); + cancelAnimation(staticHeaderElementsY); + } catch (error) { + console.warn('Error canceling animations:', error); + } + }; + }, []); + + // Ultra-optimized scroll handler with minimal calculations and safety const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { 'worklet'; + try { const rawScrollY = event.contentOffset.y; scrollY.value = rawScrollY; @@ -124,6 +164,9 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) = duration: progress ? 200 : 150, easing: easings.ultraFast }); + } + } catch (error) { + console.warn('Animation error in scroll handler:', error); } }, }); diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts index b52cc65..7918c51 100644 --- a/src/hooks/useMetadataAssets.ts +++ b/src/hooks/useMetadataAssets.ts @@ -1,7 +1,50 @@ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { logger } from '../utils/logger'; import { TMDBService } from '../services/tmdbService'; import { isMetahubUrl, isTmdbUrl } from '../utils/logoUtils'; +import { Image } from 'expo-image'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +// Cache for image availability checks +const imageAvailabilityCache: Record<string, boolean> = {}; + +// Helper function to check image availability with caching +const checkImageAvailability = async (url: string): Promise<boolean> => { + // Check memory cache first + if (imageAvailabilityCache[url] !== undefined) { + return imageAvailabilityCache[url]; + } + + // Check AsyncStorage cache + try { + const cachedResult = await AsyncStorage.getItem(`image_available:${url}`); + if (cachedResult !== null) { + const isAvailable = cachedResult === 'true'; + imageAvailabilityCache[url] = isAvailable; + return isAvailable; + } + } catch (error) { + // Ignore AsyncStorage errors + } + + // Perform actual check + try { + const response = await fetch(url, { method: 'HEAD' }); + const isAvailable = response.ok; + + // Update caches + imageAvailabilityCache[url] = isAvailable; + try { + await AsyncStorage.setItem(`image_available:${url}`, isAvailable ? 'true' : 'false'); + } catch (error) { + // Ignore AsyncStorage errors + } + + return isAvailable; + } catch (error) { + return false; + } +}; export const useMetadataAssets = ( metadata: any, @@ -45,12 +88,10 @@ export const useMetadataAssets = ( const preferenceIsMetahub = settings.logoSourcePreference === 'metahub'; // Always clear logo on preference change to force proper refresh - setMetadata((prevMetadata: any) => ({ - ...prevMetadata!, - logo: undefined - })); - - logger.log(`[useMetadataAssets] Preference changed to ${settings.logoSourcePreference}, forcing refresh of all assets`); + setMetadata((prevMetadata: any) => ({ + ...prevMetadata!, + logo: undefined + })); } }, [settings.logoSourcePreference, setMetadata]); @@ -59,7 +100,7 @@ export const useMetadataAssets = ( setLogoLoadError(false); }, [metadata?.logo]); - // Fetch logo immediately for TMDB content - with guard against recursive updates + // Optimized logo fetching useEffect(() => { const logoPreference = settings.logoSourcePreference || 'metahub'; const currentLogoUrl = metadata?.logo; @@ -67,124 +108,89 @@ export const useMetadataAssets = ( // Determine if we need to fetch a new logo if (!currentLogoUrl) { - logger.log(`[useMetadataAssets:Logo] Condition check: No current logo exists. Proceeding with fetch.`); shouldFetchLogo = true; } else { const isCurrentLogoMetahub = isMetahubUrl(currentLogoUrl); const isCurrentLogoTmdb = isTmdbUrl(currentLogoUrl); if (logoPreference === 'tmdb' && !isCurrentLogoTmdb) { - logger.log(`[useMetadataAssets:Logo] Condition check: Preference is TMDB, but current logo is not TMDB (${currentLogoUrl}). Proceeding with fetch.`); shouldFetchLogo = true; } else if (logoPreference === 'metahub' && !isCurrentLogoMetahub) { - logger.log(`[useMetadataAssets:Logo] Condition check: Preference is Metahub, but current logo is not Metahub (${currentLogoUrl}). Proceeding with fetch.`); shouldFetchLogo = true; - } else { - logger.log(`[useMetadataAssets:Logo] Condition check: Skipping fetch. Preference (${logoPreference}) matches existing logo source. Current logo: ${currentLogoUrl}`); } } // Guard against infinite loops by checking if we're already fetching if (shouldFetchLogo && !logoFetchInProgress.current) { - logger.log(`[useMetadataAssets:Logo] Starting logo fetch. Current metadata logo: ${currentLogoUrl}`); logoFetchInProgress.current = true; const fetchLogo = async () => { // Clear existing logo before fetching new one to avoid briefly showing wrong logo - // Only do this if we decided to fetch because of a mismatch or non-existence if (shouldFetchLogo) { - logger.log(`[useMetadataAssets:Logo] Clearing existing logo in metadata state before fetch.`); - setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined })); } try { - // Get logo source preference from settings - // const logoPreference = settings.logoSourcePreference || 'metahub'; // Already defined above const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - logger.log(`[useMetadataAssets:Logo] Fetching logo. Preference: ${logoPreference}, Language: ${preferredLanguage}, IMDB ID: ${imdbId}`); - if (logoPreference === 'metahub' && imdbId) { - // Metahub path - direct fetch without HEAD request for speed - logger.log(`[useMetadataAssets:Logo] Preference is Metahub. Attempting Metahub fetch for ${imdbId}.`); + // Metahub path - with cached availability check const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; - try { - // Verify Metahub image exists to prevent showing broken images - logger.log(`[useMetadataAssets:Logo] Checking Metahub logo existence: ${metahubUrl}`); - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - // Update metadata with Metahub logo - logger.log(`[useMetadataAssets:Logo] Metahub logo found. Updating metadata state.`); - setMetadata((prevMetadata: any) => { - logger.log(`[useMetadataAssets:Logo] setMetadata called with Metahub logo: ${metahubUrl}`); - return { ...prevMetadata!, logo: metahubUrl }; - }); - } else { - logger.warn(`[useMetadataAssets:Logo] Metahub logo HEAD request failed with status ${response.status} for ${imdbId}`); - } - } catch (error) { - logger.error(`[useMetadataAssets:Logo] Error checking Metahub logo:`, error); + const isAvailable = await checkImageAvailability(metahubUrl); + if (isAvailable) { + // Preload the image + await Image.prefetch(metahubUrl); + + // Update metadata with Metahub logo + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: metahubUrl })); } } else if (logoPreference === 'tmdb') { // TMDB path - optimized flow - logger.log(`[useMetadataAssets:Logo] Preference is TMDB. Attempting TMDB fetch.`); let tmdbId: string | null = null; let contentType = type === 'series' ? 'tv' : 'movie'; // Extract or find TMDB ID in one step if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; - logger.log(`[useMetadataAssets:Logo] Extracted TMDB ID from route ID: ${tmdbId}`); } else if (imdbId) { - logger.log(`[useMetadataAssets:Logo] Attempting to find TMDB ID from IMDB ID: ${imdbId}`); - // Only look up TMDB ID if we don't already have it try { const tmdbService = TMDBService.getInstance(); const foundId = await tmdbService.findTMDBIdByIMDB(imdbId); if (foundId) { tmdbId = String(foundId); setFoundTmdbId(tmdbId); // Save for banner fetching - logger.log(`[useMetadataAssets:Logo] Found TMDB ID: ${tmdbId}`); - } else { - logger.warn(`[useMetadataAssets:Logo] Could not find TMDB ID for IMDB ID: ${imdbId}`); } } catch (error) { - logger.error(`[useMetadataAssets:Logo] Error finding TMDB ID:`, error); + // Handle error silently } } else { - logger.warn(`[useMetadataAssets:Logo] Cannot attempt TMDB fetch: No TMDB ID in route and no IMDB ID provided.`); + const parsedId = parseInt(id, 10); + if (!isNaN(parsedId)) { + tmdbId = String(parsedId); + } } if (tmdbId) { try { // Direct fetch - avoid multiple service calls - logger.log(`[useMetadataAssets:Logo] Fetching TMDB logo for ${contentType} ID: ${tmdbId}, Language: ${preferredLanguage}`); const tmdbService = TMDBService.getInstance(); const logoUrl = await tmdbService.getContentLogo(contentType as 'tv' | 'movie', tmdbId, preferredLanguage); if (logoUrl) { - logger.log(`[useMetadataAssets:Logo] TMDB logo found. Updating metadata state.`); - setMetadata((prevMetadata: any) => { - logger.log(`[useMetadataAssets:Logo] setMetadata called with TMDB logo: ${logoUrl}`); - return { ...prevMetadata!, logo: logoUrl }; - }); - } else { - logger.warn(`[useMetadataAssets:Logo] No TMDB logo found for ${contentType}/${tmdbId}.`); + // Preload the image + await Image.prefetch(logoUrl); + + setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: logoUrl })); } } catch (error) { - logger.error(`[useMetadataAssets:Logo] Error fetching TMDB logo:`, error); + // Handle error silently } - } else { - logger.warn(`[useMetadataAssets:Logo] Skipping TMDB logo fetch as no TMDB ID was determined.`); } - } else { - logger.log(`[useMetadataAssets:Logo] Preference not Metahub and no IMDB ID, or preference not TMDB. No logo fetched.`); } } catch (error) { - logger.error(`[useMetadataAssets:Logo] Error in outer fetchLogo try block:`, error); + // Handle error silently } finally { - logger.log(`[useMetadataAssets:Logo] Finished logo fetch attempt.`); logoFetchInProgress.current = false; } }; @@ -192,58 +198,40 @@ export const useMetadataAssets = ( // Execute fetch without awaiting fetchLogo(); } - // Add logging for when fetch is skipped due to already fetching - else if (shouldFetchLogo && logoFetchInProgress.current) { - logger.log(`[useMetadataAssets:Logo] Skipping logo fetch because logoFetchInProgress is true.`); - } }, [ id, type, imdbId, - metadata?.logo, // Depend on the logo value itself, not the whole object + metadata?.logo, settings.logoSourcePreference, settings.tmdbLanguagePreference, - setMetadata // Keep setMetadata, but ensure it's memoized in parent + setMetadata ]); - // Fetch banner image based on logo source preference - optimized version - useEffect(() => { - // Skip if no metadata or already completed with the correct source - if (!metadata) { - logger.log(`[useMetadataAssets:Banner] Skipping banner fetch: No metadata.`); - return; + // Optimized banner fetching + const fetchBanner = useCallback(async () => { + if (!metadata) return; + + setLoadingBanner(true); + + // Show fallback banner immediately to prevent blank state + const fallbackBanner = metadata?.banner || metadata?.poster || null; + if (fallbackBanner && !bannerImage) { + setBannerImage(fallbackBanner); + setBannerSource('default'); } - // Check if we need to refresh the banner based on source - const currentPreference = settings.logoSourcePreference || 'metahub'; - logger.log(`[useMetadataAssets:Banner] Checking banner fetch. Preference: ${currentPreference}, Current Banner Source: ${bannerSource}, Forced Refresh Done: ${forcedBannerRefreshDone.current}`); - - if (bannerSource === currentPreference && forcedBannerRefreshDone.current) { - logger.log(`[useMetadataAssets:Banner] Skipping fetch: Banner already loaded with correct source (${currentPreference}).`); - return; // Already have the correct source, no need to refresh - } - - const fetchBanner = async () => { - logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`); - setLoadingBanner(true); - - // Show fallback banner immediately to prevent blank state - const fallbackBanner = metadata?.banner || metadata?.poster || null; - if (fallbackBanner && !bannerImage) { - setBannerImage(fallbackBanner); - setBannerSource('default'); - logger.log(`[useMetadataAssets:Banner] Setting immediate fallback banner: ${fallbackBanner}`); - } + try { + const currentPreference = settings.logoSourcePreference || 'metahub'; + const preferredLanguage = settings.tmdbLanguagePreference || 'en'; + const contentType = type === 'series' ? 'tv' : 'movie'; + // Try to get a banner from the preferred source let finalBanner: string | null = null; let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default'; - - try { - // Extract all possible IDs at once - const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - const contentType = type === 'series' ? 'tv' : 'movie'; - - // Get TMDB ID once + + // TMDB path + if (currentPreference === 'tmdb') { let tmdbId = null; if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; @@ -252,213 +240,160 @@ export const useMetadataAssets = ( } else if ((metadata as any).tmdbId) { tmdbId = (metadata as any).tmdbId; } else if (imdbId) { - // Last attempt: Look up TMDB ID if we haven't yet - logger.log(`[useMetadataAssets:Banner] Attempting TMDB ID lookup from IMDB ID: ${imdbId} for banner fetch.`); try { const tmdbService = TMDBService.getInstance(); const foundId = await tmdbService.findTMDBIdByIMDB(imdbId); if (foundId) { - tmdbId = String(foundId); - logger.log(`[useMetadataAssets:Banner] Found TMDB ID: ${tmdbId}`); - } else { - logger.warn(`[useMetadataAssets:Banner] Could not find TMDB ID for IMDB ID: ${imdbId}`); - } - } catch (lookupError) { - logger.error(`[useMetadataAssets:Banner] Error looking up TMDB ID:`, lookupError); - } - } - - logger.log(`[useMetadataAssets:Banner] Determined TMDB ID for banner fetch: ${tmdbId}`); - - // Default fallback to use if nothing else works - - if (currentPreference === 'tmdb' && tmdbId) { - // TMDB direct path - logger.log(`[useMetadataAssets:Banner] Preference is TMDB. Attempting TMDB banner fetch for ${contentType}/${tmdbId}.`); - const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - - try { - // Use TMDBService instead of direct fetch with hardcoded API key - const tmdbService = TMDBService.getInstance(); - logger.log(`[useMetadataAssets:Banner] Fetching TMDB details for ${endpoint}/${tmdbId}`); - - try { - // Get details with backdrop path using TMDBService - let details; - let images = null; - - // Step 1: Get basic details - if (endpoint === 'movie') { - details = await tmdbService.getMovieDetails(tmdbId); - logger.log(`[useMetadataAssets:Banner] TMDB getMovieDetails result:`, details ? `Found backdrop: ${!!details.backdrop_path}, Found poster: ${!!details.poster_path}` : 'null'); - - // Step 2: Get images separately if details succeeded (This call might not be needed for banner) - // if (details) { - // try { - // await tmdbService.getMovieImages(tmdbId, preferredLanguage); - // logger.log(`[useMetadataAssets:Banner] Got movie images for ${tmdbId}`); - // } catch (imageError) { - // logger.warn(`[useMetadataAssets:Banner] Could not get movie images: ${imageError}`); - // } - //} - } else { // TV Show - details = await tmdbService.getTVShowDetails(Number(tmdbId)); - logger.log(`[useMetadataAssets:Banner] TMDB getTVShowDetails result:`, details ? `Found backdrop: ${!!details.backdrop_path}, Found poster: ${!!details.poster_path}` : 'null'); - - // Step 2: Get images separately if details succeeded (This call might not be needed for banner) - // if (details) { - // try { - // await tmdbService.getTvShowImages(tmdbId, preferredLanguage); - // logger.log(`[useMetadataAssets:Banner] Got TV images for ${tmdbId}`); - // } catch (imageError) { - // logger.warn(`[useMetadataAssets:Banner] Could not get TV images: ${imageError}`); - // } - // } - } - - // Check if we have a backdrop path from details - if (details && details.backdrop_path) { - finalBanner = tmdbService.getImageUrl(details.backdrop_path); - bannerSourceType = 'tmdb'; - logger.log(`[useMetadataAssets:Banner] Using TMDB backdrop from details: ${finalBanner}`); - } - // If no backdrop, try poster as fallback - else if (details && details.poster_path) { - logger.warn(`[useMetadataAssets:Banner] No TMDB backdrop available, using poster as fallback.`); - finalBanner = tmdbService.getImageUrl(details.poster_path); - bannerSourceType = 'tmdb'; - } - else { - logger.warn(`[useMetadataAssets:Banner] No TMDB backdrop or poster found for ${endpoint}/${tmdbId}. TMDB path failed.`); - // Explicitly set finalBanner to null if TMDB fails - finalBanner = null; - } - } catch (innerErr) { - logger.error(`[useMetadataAssets:Banner] Error fetching TMDB details/images:`, innerErr); - finalBanner = null; // Ensure failure case nullifies banner - } - } catch (err) { - logger.error(`[useMetadataAssets:Banner] TMDB service initialization error:`, err); - finalBanner = null; // Ensure failure case nullifies banner - } - } else if (currentPreference === 'metahub' && imdbId) { - // Metahub path - verify it exists to prevent broken images - logger.log(`[useMetadataAssets:Banner] Preference is Metahub. Attempting Metahub banner fetch for ${imdbId}.`); - const metahubUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; - - try { - logger.log(`[useMetadataAssets:Banner] Checking Metahub banner existence: ${metahubUrl}`); - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - finalBanner = metahubUrl; - bannerSourceType = 'metahub'; - logger.log(`[useMetadataAssets:Banner] Metahub banner found: ${finalBanner}`); - } else { - logger.warn(`[useMetadataAssets:Banner] Metahub banner HEAD request failed with status ${response.status}, using default.`); - finalBanner = null; // Ensure fallback if Metahub fails + tmdbId = String(foundId); } } catch (error) { - logger.error(`[useMetadataAssets:Banner] Error checking Metahub banner:`, error); - finalBanner = null; // Ensure fallback if Metahub errors + // Handle error silently } - } else { - // This case handles: - // 1. Preference is TMDB but no tmdbId could be found. - // 2. Preference is Metahub but no imdbId was provided. - logger.log(`[useMetadataAssets:Banner] Skipping direct fetch: Preference=${currentPreference}, tmdbId=${tmdbId}, imdbId=${imdbId}. Will rely on default/fallback.`); - finalBanner = null; // Explicitly nullify banner if preference conditions aren't met - } - - // Fallback logic if preferred source failed or wasn't attempted - if (!finalBanner) { - logger.log(`[useMetadataAssets:Banner] Preferred source (${currentPreference}) did not yield a banner. Checking fallbacks.`); - // Fallback 1: Try the *other* source if the preferred one failed - if (currentPreference === 'tmdb' && imdbId) { // If preferred was TMDB, try Metahub - logger.log(`[useMetadataAssets:Banner] Fallback: Trying Metahub for ${imdbId}.`); - const metahubUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; - try { - const response = await fetch(metahubUrl, { method: 'HEAD' }); - if (response.ok) { - finalBanner = metahubUrl; - bannerSourceType = 'metahub'; - logger.log(`[useMetadataAssets:Banner] Fallback Metahub banner found: ${finalBanner}`); - } else { - logger.warn(`[useMetadataAssets:Banner] Fallback Metahub HEAD failed: ${response.status}`); - } - } catch (fallbackError) { - logger.error(`[useMetadataAssets:Banner] Fallback Metahub check error:`, fallbackError); - } - } else if (currentPreference === 'metahub' && tmdbId) { // If preferred was Metahub, try TMDB - logger.log(`[useMetadataAssets:Banner] Fallback: Trying TMDB for ${contentType}/${tmdbId}.`); - const endpoint = contentType === 'tv' ? 'tv' : 'movie'; - try { - const tmdbService = TMDBService.getInstance(); - let details = endpoint === 'movie' ? await tmdbService.getMovieDetails(tmdbId) : await tmdbService.getTVShowDetails(Number(tmdbId)); - if (details?.backdrop_path) { - finalBanner = tmdbService.getImageUrl(details.backdrop_path); - bannerSourceType = 'tmdb'; - logger.log(`[useMetadataAssets:Banner] Fallback TMDB banner found (backdrop): ${finalBanner}`); - } else if (details?.poster_path) { - finalBanner = tmdbService.getImageUrl(details.poster_path); - bannerSourceType = 'tmdb'; - logger.log(`[useMetadataAssets:Banner] Fallback TMDB banner found (poster): ${finalBanner}`); - } else { - logger.warn(`[useMetadataAssets:Banner] Fallback TMDB fetch found no backdrop or poster.`); - } - } catch (fallbackError) { - logger.error(`[useMetadataAssets:Banner] Fallback TMDB check error:`, fallbackError); - } - } - - // Fallback 2: Use metadata banner/poster if other source also failed - if (!finalBanner) { - logger.log(`[useMetadataAssets:Banner] Fallback source also failed or not applicable. Using metadata.banner or metadata.poster.`); - finalBanner = metadata?.banner || metadata?.poster || null; - bannerSourceType = 'default'; - if (finalBanner) { - logger.log(`[useMetadataAssets:Banner] Using default banner from metadata: ${finalBanner}`); - } else { - logger.warn(`[useMetadataAssets:Banner] No default banner found in metadata either.`); - } - } } - // Set the final state - logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`); - - // Only update if the banner actually changed to avoid unnecessary re-renders - if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) { - setBannerImage(finalBanner); - setBannerSource(bannerSourceType); // Track the source of the final image - logger.log(`[useMetadataAssets:Banner] Banner updated from ${bannerImage} to ${finalBanner}`); - } else { - logger.log(`[useMetadataAssets:Banner] Banner unchanged, skipping update`); + if (tmdbId) { + try { + const tmdbService = TMDBService.getInstance(); + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + + const details = endpoint === 'movie' + ? await tmdbService.getMovieDetails(tmdbId) + : await tmdbService.getTVShowDetails(Number(tmdbId)); + + if (details?.backdrop_path) { + finalBanner = tmdbService.getImageUrl(details.backdrop_path); + bannerSourceType = 'tmdb'; + + // Preload the image + if (finalBanner) { + await Image.prefetch(finalBanner); + } + } + else if (details?.poster_path) { + finalBanner = tmdbService.getImageUrl(details.poster_path); + bannerSourceType = 'tmdb'; + + // Preload the image + if (finalBanner) { + await Image.prefetch(finalBanner); + } + } + } catch (error) { + // Handle error silently + } } + } + // Metahub path + else if (currentPreference === 'metahub' && imdbId) { + const metahubUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; - forcedBannerRefreshDone.current = true; // Mark this cycle as complete - - } catch (error) { - logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error); - // Ensure fallback to default even on outer error - const defaultBanner = metadata?.banner || metadata?.poster || null; - - // Only set if it's different from current banner - if (defaultBanner !== bannerImage) { - setBannerImage(defaultBanner); - setBannerSource('default'); - logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`); - } else { - logger.log(`[useMetadataAssets:Banner] Default banner already set, skipping update`); + const isAvailable = await checkImageAvailability(metahubUrl); + if (isAvailable) { + finalBanner = metahubUrl; + bannerSourceType = 'metahub'; + + // Preload the image + if (finalBanner) { + await Image.prefetch(finalBanner); + } } - } finally { - logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`); - setLoadingBanner(false); } - }; - - fetchBanner(); + + // If preferred source failed, try fallback + if (!finalBanner) { + // Try the other source + if (currentPreference === 'tmdb' && imdbId) { + const metahubUrl = `https://images.metahub.space/background/medium/${imdbId}/img`; + + const isAvailable = await checkImageAvailability(metahubUrl); + if (isAvailable) { + finalBanner = metahubUrl; + bannerSourceType = 'metahub'; + + // Preload the image + if (finalBanner) { + await Image.prefetch(finalBanner); + } + } + } + else if (currentPreference === 'metahub') { + // Try TMDB as fallback + let tmdbId = null; + if (id.startsWith('tmdb:')) { + tmdbId = id.split(':')[1]; + } else if (foundTmdbId) { + tmdbId = foundTmdbId; + } else if ((metadata as any).tmdbId) { + tmdbId = (metadata as any).tmdbId; + } + + if (tmdbId) { + try { + const tmdbService = TMDBService.getInstance(); + const endpoint = contentType === 'tv' ? 'tv' : 'movie'; + + const details = endpoint === 'movie' + ? await tmdbService.getMovieDetails(tmdbId) + : await tmdbService.getTVShowDetails(Number(tmdbId)); + + if (details?.backdrop_path) { + finalBanner = tmdbService.getImageUrl(details.backdrop_path); + bannerSourceType = 'tmdb'; + + // Preload the image + if (finalBanner) { + await Image.prefetch(finalBanner); + } + } + else if (details?.poster_path) { + finalBanner = tmdbService.getImageUrl(details.poster_path); + bannerSourceType = 'tmdb'; + + // Preload the image + if (finalBanner) { + await Image.prefetch(finalBanner); + } + } + } catch (error) { + // Handle error silently + } + } + } + + // Final fallback to metadata + if (!finalBanner) { + finalBanner = metadata?.banner || metadata?.poster || null; + bannerSourceType = 'default'; + } + } + + // Update state if the banner changed + if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) { + setBannerImage(finalBanner); + setBannerSource(bannerSourceType); + } + + forcedBannerRefreshDone.current = true; + } catch (error) { + // Use default banner on error + const defaultBanner = metadata?.banner || metadata?.poster || null; + if (defaultBanner !== bannerImage) { + setBannerImage(defaultBanner); + setBannerSource('default'); + } + } finally { + setLoadingBanner(false); + } + }, [metadata, id, type, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference, foundTmdbId, bannerImage, bannerSource]); - }, [metadata, id, type, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference, setMetadata, foundTmdbId, bannerSource]); // Added bannerSource dependency to re-evaluate if it changes unexpectedly + // Fetch banner when needed + useEffect(() => { + const currentPreference = settings.logoSourcePreference || 'metahub'; + + if (bannerSource !== currentPreference && !forcedBannerRefreshDone.current) { + fetchBanner(); + } + }, [fetchBanner, bannerSource, settings.logoSourcePreference]); return { bannerImage, @@ -467,6 +402,6 @@ export const useMetadataAssets = ( foundTmdbId, setLogoLoadError, setBannerImage, - bannerSource, // Export banner source for debugging + bannerSource, }; }; \ No newline at end of file diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index ec3b664..5a4738c 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -34,9 +34,13 @@ export interface AppSettings { selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) - enableInternalProviders: boolean; // Toggle for internal providers like HDRezka episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards autoplayBestStream: boolean; // Automatically play the best available stream + // Local scraper settings + scraperRepositoryUrl: string; // URL to the scraper repository + enableLocalScrapers: boolean; // Enable/disable local scraper functionality + scraperTimeout: number; // Timeout for scraper execution in seconds + enableScraperUrlValidation: boolean; // Enable/disable URL validation for scrapers } export const DEFAULT_SETTINGS: AppSettings = { @@ -49,13 +53,17 @@ export const DEFAULT_SETTINGS: AppSettings = { useExternalPlayer: false, preferredPlayer: 'internal', showHeroSection: true, - featuredContentSource: 'tmdb', + featuredContentSource: 'catalogs', selectedHeroCatalogs: [], // Empty array means all catalogs are selected logoSourcePreference: 'metahub', // Default to Metahub as first source tmdbLanguagePreference: 'en', // Default to English - enableInternalProviders: true, // Enable internal providers by default episodeLayoutStyle: 'horizontal', // Default to the new horizontal layout autoplayBestStream: false, // Disabled by default for user choice + // Local scraper defaults + scraperRepositoryUrl: '', + enableLocalScrapers: true, + scraperTimeout: 60, // 60 seconds timeout + enableScraperUrlValidation: true, // Enable URL validation by default }; const SETTINGS_STORAGE_KEY = 'app_settings'; @@ -78,10 +86,14 @@ export const useSettings = () => { try { const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY); if (storedSettings) { - setSettings(JSON.parse(storedSettings)); + const parsedSettings = JSON.parse(storedSettings); + // Merge with defaults to ensure all properties exist + setSettings({ ...DEFAULT_SETTINGS, ...parsedSettings }); } } catch (error) { console.error('Failed to load settings:', error); + // Fallback to default settings on error + setSettings(DEFAULT_SETTINGS); } }; diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts index 299efd4..9e0239b 100644 --- a/src/hooks/useTraktAutosync.ts +++ b/src/hooks/useTraktAutosync.ts @@ -186,7 +186,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { options.type, true, progressPercent, - options.episodeId + options.episodeId, + currentTime ); logger.log(`[TraktAutosync] Synced progress ${progressPercent.toFixed(1)}%: ${contentData.title}`); @@ -215,6 +216,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // ENHANCED DEDUPLICATION: Check if we've already stopped this session // However, allow updates if the new progress is significantly higher (>5% improvement) + let isSignificantUpdate = false; if (hasStopped.current) { const currentProgressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; const progressImprovement = currentProgressPercent - lastSyncProgress.current; @@ -223,6 +225,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { logger.log(`[TraktAutosync] Session already stopped, but progress improved significantly by ${progressImprovement.toFixed(1)}% (${lastSyncProgress.current.toFixed(1)}% → ${currentProgressPercent.toFixed(1)}%), allowing update`); // Reset stopped flag to allow this significant update hasStopped.current = false; + isSignificantUpdate = true; } else { logger.log(`[TraktAutosync] Already stopped this session, skipping duplicate call (reason: ${reason})`); return; @@ -230,7 +233,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } // ENHANCED DEDUPLICATION: Prevent rapid successive calls (within 5 seconds) - if (now - lastStopCall.current < 5000) { + // Bypass for significant updates + if (!isSignificantUpdate && now - lastStopCall.current < 5000) { logger.log(`[TraktAutosync] Ignoring rapid successive stop call within 5 seconds (reason: ${reason})`); return; } @@ -255,13 +259,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { maxProgress = lastSyncProgress.current; } - // Also check local storage for the highest recorded progress - try { - const savedProgress = await storageService.getWatchProgress( - options.id, - options.type, - options.episodeId - ); + // Also check local storage for the highest recorded progress + try { + const savedProgress = await storageService.getWatchProgress( + options.id, + options.type, + options.episodeId + ); if (savedProgress && savedProgress.duration > 0) { const savedProgressPercent = (savedProgress.currentTime / savedProgress.duration) * 100; @@ -292,13 +296,19 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { } } - // Only stop if we have meaningful progress (>= 1%) or it's a natural video end - // Skip unmount calls with very low progress unless video actually ended - if (reason === 'unmount' && progressPercent < 1) { + // Only stop if we have meaningful progress (>= 0.5%) or it's a natural video end + // Lower threshold for unmount calls to catch more edge cases + if (reason === 'unmount' && progressPercent < 0.5) { logger.log(`[TraktAutosync] Skipping unmount stop for ${options.title} - too early (${progressPercent.toFixed(1)}%)`); return; } + // For natural end events, always set progress to at least 90% + if (reason === 'ended' && progressPercent < 90) { + logger.log(`[TraktAutosync] Natural end detected but progress is low (${progressPercent.toFixed(1)}%), boosting to 90%`); + progressPercent = 90; + } + // Mark stop attempt and update timestamp lastStopCall.current = now; hasStopped.current = true; @@ -315,7 +325,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { options.type, true, progressPercent, - options.episodeId + options.episodeId, + currentTime ); // Mark session as complete if high progress (scrobbled) @@ -344,7 +355,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) { // Reset stop flag on error so we can try again hasStopped.current = false; } - }, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]); + }, [isAuthenticated, autosyncSettings.enabled, stopWatching, startWatching, buildContentData, options]); // Reset state (useful when switching content) const resetState = useCallback(() => { diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts index 3b61d44..1e818a0 100644 --- a/src/hooks/useTraktIntegration.ts +++ b/src/hooks/useTraktIntegration.ts @@ -324,22 +324,21 @@ export function useTraktIntegration() { // Fetch and merge Trakt progress with local progress const fetchAndMergeTraktProgress = useCallback(async (): Promise<boolean> => { - logger.log(`[useTraktIntegration] fetchAndMergeTraktProgress called - isAuthenticated: ${isAuthenticated}`); - if (!isAuthenticated) { - logger.log('[useTraktIntegration] Not authenticated, skipping Trakt progress fetch'); return false; } try { // Fetch both playback progress and recently watched movies - logger.log('[useTraktIntegration] Fetching Trakt playback progress and watched movies...'); const [traktProgress, watchedMovies] = await Promise.all([ getTraktPlaybackProgress(), traktService.getWatchedMovies() ]); - logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items, ${watchedMovies.length} watched movies`); + logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} progress items, ${watchedMovies.length} watched movies`); + + // Batch process all updates to reduce storage notifications + const updatePromises: Promise<void>[] = []; // Process playback progress (in-progress items) for (const item of traktProgress) { @@ -351,27 +350,35 @@ export function useTraktIntegration() { if (item.type === 'movie' && item.movie) { id = item.movie.ids.imdb; type = 'movie'; - logger.log(`[useTraktIntegration] Processing Trakt movie progress: ${item.movie.title} (${id}) - ${item.progress}%`); } else if (item.type === 'episode' && item.show && item.episode) { id = item.show.ids.imdb; type = 'series'; episodeId = `${id}:${item.episode.season}:${item.episode.number}`; - logger.log(`[useTraktIntegration] Processing Trakt episode progress: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`); } else { - logger.warn(`[useTraktIntegration] Skipping invalid Trakt progress item:`, item); continue; } - logger.log(`[useTraktIntegration] Merging progress for ${type} ${id}: ${item.progress}% from ${item.paused_at}`); - await storageService.mergeWithTraktProgress( + // Try to calculate exact time if we have stored duration + const exactTime = await (async () => { + const storedDuration = await storageService.getContentDuration(id, type, episodeId); + if (storedDuration && storedDuration > 0) { + return (item.progress / 100) * storedDuration; + } + return undefined; + })(); + + updatePromises.push( + storageService.mergeWithTraktProgress( id, type, item.progress, item.paused_at, - episodeId + episodeId, + exactTime + ) ); } catch (error) { - logger.error('[useTraktIntegration] Error merging individual Trakt progress:', error); + logger.error('[useTraktIntegration] Error preparing Trakt progress update:', error); } } @@ -381,21 +388,25 @@ export function useTraktIntegration() { if (movie.movie?.ids?.imdb) { const id = movie.movie.ids.imdb; const watchedAt = movie.last_watched_at; - logger.log(`[useTraktIntegration] Processing watched movie: ${movie.movie.title} (${id}) - 100% watched on ${watchedAt}`); - await storageService.mergeWithTraktProgress( + updatePromises.push( + storageService.mergeWithTraktProgress( id, 'movie', 100, // 100% progress for watched items watchedAt + ) ); } } catch (error) { - logger.error('[useTraktIntegration] Error merging watched movie:', error); + logger.error('[useTraktIntegration] Error preparing watched movie update:', error); } } - logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} progress items + ${watchedMovies.length} watched movies`); + // Execute all updates in parallel + await Promise.all(updatePromises); + + logger.log(`[useTraktIntegration] Successfully merged ${updatePromises.length} items from Trakt`); return true; } catch (error) { logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error); @@ -419,17 +430,10 @@ export function useTraktIntegration() { useEffect(() => { if (isAuthenticated) { // Fetch Trakt progress and merge with local - logger.log('[useTraktIntegration] User authenticated, fetching Trakt progress to replace local data'); fetchAndMergeTraktProgress().then((success) => { if (success) { - logger.log('[useTraktIntegration] Trakt progress merged successfully - local data replaced with Trakt data'); - } else { - logger.warn('[useTraktIntegration] Failed to merge Trakt progress'); + logger.log('[useTraktIntegration] Trakt progress merged successfully'); } - // Small delay to ensure storage subscribers are notified - setTimeout(() => { - logger.log('[useTraktIntegration] Trakt progress merge completed, UI should refresh'); - }, 100); }); } }, [isAuthenticated, fetchAndMergeTraktProgress]); @@ -440,12 +444,7 @@ export function useTraktIntegration() { const handleAppStateChange = (nextAppState: AppStateStatus) => { if (nextAppState === 'active') { - logger.log('[useTraktIntegration] App became active, syncing Trakt data'); - fetchAndMergeTraktProgress().then((success) => { - if (success) { - logger.log('[useTraktIntegration] App focus sync completed successfully'); - } - }).catch(error => { + fetchAndMergeTraktProgress().catch(error => { logger.error('[useTraktIntegration] App focus sync failed:', error); }); } @@ -461,12 +460,7 @@ export function useTraktIntegration() { // Trigger sync when auth status is manually refreshed (for login scenarios) useEffect(() => { if (isAuthenticated) { - logger.log('[useTraktIntegration] Auth status refresh detected, triggering Trakt progress merge'); - fetchAndMergeTraktProgress().then((success) => { - if (success) { - logger.log('[useTraktIntegration] Trakt progress merged after manual auth refresh'); - } - }); + fetchAndMergeTraktProgress(); } }, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]); diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts index a4ec9d5..63427bc 100644 --- a/src/hooks/useWatchProgress.ts +++ b/src/hooks/useWatchProgress.ts @@ -170,7 +170,6 @@ export const useWatchProgress = ( // Subscribe to storage changes for real-time updates useEffect(() => { const unsubscribe = storageService.subscribeToWatchProgressUpdates(() => { - logger.log('[useWatchProgress] Storage updated, reloading progress'); loadWatchProgress(); }); diff --git a/src/modules/TorrentPlayer.ts b/src/modules/TorrentPlayer.ts deleted file mode 100644 index 0519ecb..0000000 --- a/src/modules/TorrentPlayer.ts +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 86d41b1..e92ab4d 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react'; import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme as NavigationDarkTheme, Theme, NavigationProp } from '@react-navigation/native'; import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack'; import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; -import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState } from 'react-native'; +import { useColorScheme, Platform, Animated, StatusBar, TouchableOpacity, View, Text, AppState, Easing } from 'react-native'; import { PaperProvider, MD3DarkTheme, MD3LightTheme, adaptNavigationTheme } from 'react-native-paper'; import type { MD3Theme } from 'react-native-paper'; import type { BottomTabBarProps } from '@react-navigation/bottom-tabs'; @@ -17,7 +17,6 @@ import { useTheme } from '../contexts/ThemeContext'; // Import screens with their proper types import HomeScreen from '../screens/HomeScreen'; -import DiscoverScreen from '../screens/DiscoverScreen'; import LibraryScreen from '../screens/LibraryScreen'; import SettingsScreen from '../screens/SettingsScreen'; import MetadataScreen from '../screens/MetadataScreen'; @@ -39,13 +38,14 @@ import PlayerSettingsScreen from '../screens/PlayerSettingsScreen'; import LogoSourceSettings from '../screens/LogoSourceSettings'; import ThemeScreen from '../screens/ThemeScreen'; import ProfilesScreen from '../screens/ProfilesScreen'; -import InternalProvidersSettings from '../screens/InternalProvidersSettings'; +import OnboardingScreen from '../screens/OnboardingScreen'; +import ScraperSettingsScreen from '../screens/ScraperSettingsScreen'; // Stack navigator types export type RootStackParamList = { + Onboarding: undefined; MainTabs: undefined; Home: undefined; - Discover: undefined; Library: undefined; Settings: undefined; Search: undefined; @@ -60,12 +60,14 @@ export type RootStackParamList = { id: string; type: string; episodeId?: string; + episodeThumbnail?: string; }; VideoPlayer: { id: string; type: string; stream: Stream; episodeId?: string; + backdrop?: string; }; Player: { uri: string; @@ -82,6 +84,7 @@ export type RootStackParamList = { episodeId?: string; imdbId?: string; availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } }; + backdrop?: string; }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Credits: { mediaId: string; mediaType: string }; @@ -102,7 +105,7 @@ export type RootStackParamList = { LogoSourceSettings: undefined; ThemeSettings: undefined; ProfilesSettings: undefined; - InternalProvidersSettings: undefined; + ScraperSettings: undefined; }; export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>; @@ -110,8 +113,8 @@ export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamLi // Tab navigator types export type MainTabParamList = { Home: undefined; - Discover: undefined; Library: undefined; + Search: undefined; Settings: undefined; }; @@ -298,7 +301,7 @@ export const CustomNavigationDarkTheme: Theme = { type IconNameType = 'home' | 'home-outline' | 'compass' | 'compass-outline' | 'play-box-multiple' | 'play-box-multiple-outline' | 'puzzle' | 'puzzle-outline' | - 'cog' | 'cog-outline'; + 'cog' | 'cog-outline' | 'feature-search' | 'feature-search-outline'; // Add TabIcon component const TabIcon = React.memo(({ focused, color, iconName }: { @@ -395,8 +398,6 @@ const WrappedScreen: React.FC<{Screen: React.ComponentType<any>}> = ({ Screen }) // Tab Navigator const MainTabs = () => { - // Always use dark mode - const isDarkMode = true; const { currentTheme } = useTheme(); const renderTabBar = (props: BottomTabBarProps) => { @@ -479,12 +480,12 @@ const MainTabs = () => { case 'Home': iconName = 'home'; break; - case 'Discover': - iconName = 'compass'; - break; case 'Library': iconName = 'play-box-multiple'; break; + case 'Search': + iconName = 'feature-search'; + break; case 'Settings': iconName = 'cog'; break; @@ -538,124 +539,86 @@ const MainTabs = () => { <Tab.Navigator tabBar={renderTabBar} - screenOptions={({ route }) => ({ - tabBarIcon: ({ focused, color, size }) => { - let iconName: IconNameType = 'home'; - - switch (route.name) { - case 'Home': - iconName = 'home'; - break; - case 'Discover': - iconName = 'compass'; - break; - case 'Library': - iconName = 'play-box-multiple'; - break; - case 'Settings': - iconName = 'cog'; - break; - } - - return <TabIcon focused={focused} color={color} iconName={iconName} />; + screenOptions={({ route, navigation, theme }) => ({ + transitionSpec: { + animation: 'timing', + config: { + duration: 200, + easing: Easing.bezier(0.25, 0.1, 0.25, 1.0), + }, }, - tabBarActiveTintColor: currentTheme.colors.primary, - tabBarInactiveTintColor: currentTheme.colors.white, + sceneStyleInterpolator: ({ current }) => ({ + sceneStyle: { + opacity: current.progress.interpolate({ + inputRange: [-1, 0, 1], + outputRange: [0, 1, 0], + }), + transform: [ + { + scale: current.progress.interpolate({ + inputRange: [-1, 0, 1], + outputRange: [0.95, 1, 0.95], + }), + }, + { + translateY: current.progress.interpolate({ + inputRange: [-1, 0, 1], + outputRange: [8, 0, 8], + }), + }, + ], + }, + }), + header: () => (route.name === 'Home' ? <NuvioHeader /> : null), + headerShown: route.name === 'Home', + tabBarShowLabel: false, tabBarStyle: { position: 'absolute', - backgroundColor: 'transparent', borderTopWidth: 0, elevation: 0, - height: 85, - paddingBottom: 20, - paddingTop: 12, - }, - tabBarLabelStyle: { - fontSize: 12, - fontWeight: '600', - marginTop: 0, - }, - // Completely disable animations between tabs for better performance - animationEnabled: false, - // Keep all screens mounted and active - lazy: false, - freezeOnBlur: false, - detachPreviousScreen: false, - // Configure how the screen renders - detachInactiveScreens: false, - tabBarBackground: () => ( - Platform.OS === 'ios' ? ( - <BlurView - tint="dark" - intensity={75} - style={{ - position: 'absolute', - height: '100%', - width: '100%', - borderTopColor: currentTheme.colors.border, - borderTopWidth: 0.5, - shadowColor: currentTheme.colors.black, - shadowOffset: { width: 0, height: -2 }, - shadowOpacity: 0.1, - shadowRadius: 3, - }} - /> - ) : ( - <LinearGradient - colors={[ - 'rgba(0, 0, 0, 0)', - 'rgba(0, 0, 0, 0.65)', - 'rgba(0, 0, 0, 0.85)', - 'rgba(0, 0, 0, 0.98)', - ]} - locations={[0, 0.2, 0.4, 0.8]} - style={{ - position: 'absolute', - height: '100%', - width: '100%', - }} - /> - ) - ), - header: () => route.name === 'Home' ? <NuvioHeader /> : null, - headerShown: route.name === 'Home', - // Add fixed screen styling to help with consistency - contentStyle: { backgroundColor: currentTheme.colors.darkBackground, }, + detachInactiveScreens: false, })} - // Global configuration for the tab navigator - detachInactiveScreens={false} > - <Tab.Screen - name="Home" + <Tab.Screen + name="Home" component={HomeScreen} - options={{ + options={{ tabBarLabel: 'Home', + tabBarIcon: ({ color, size, focused }) => ( + <MaterialCommunityIcons name={focused ? 'home' : 'home-outline'} size={size} color={color} /> + ), }} /> - <Tab.Screen - name="Discover" - component={DiscoverScreen} - options={{ - tabBarLabel: 'Discover', - headerShown: false - }} - /> - <Tab.Screen - name="Library" + <Tab.Screen + name="Library" component={LibraryScreen} - options={{ + options={{ tabBarLabel: 'Library', - headerShown: false + tabBarIcon: ({ color, size, focused }) => ( + <MaterialCommunityIcons name={focused ? 'play-box-multiple' : 'play-box-multiple-outline'} size={size} color={color} /> + ), }} /> - <Tab.Screen - name="Settings" + <Tab.Screen + name="Search" + component={SearchScreen} + options={{ + tabBarLabel: 'Search', + tabBarIcon: ({ color, size, focused }) => ( + <MaterialCommunityIcons name={focused ? 'feature-search' : 'feature-search-outline'} size={size} color={color} /> + ), + }} + /> + <Tab.Screen + name="Settings" component={SettingsScreen} - options={{ + options={{ tabBarLabel: 'Settings', - headerShown: false + tabBarIcon: ({ color, size, focused }) => ( + <MaterialCommunityIcons name={focused ? 'cog' : 'cog-outline'} size={size} color={color} /> + ), }} /> </Tab.Navigator> @@ -690,7 +653,7 @@ const customFadeInterpolator = ({ current, layouts }: any) => { }; // Stack Navigator -const AppNavigator = () => { +const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStackParamList }) => { const { currentTheme } = useTheme(); // Handle Android-specific optimizations @@ -719,6 +682,7 @@ const AppNavigator = () => { }) }}> <Stack.Navigator + initialRouteName={initialRouteName || 'MainTabs'} screenOptions={{ headerShown: false, // Use slide_from_right for consistency and smooth transitions @@ -748,6 +712,18 @@ const AppNavigator = () => { }), }} > + <Stack.Screen + name="Onboarding" + component={OnboardingScreen} + options={{ + headerShown: false, + animation: 'fade', + animationDuration: 300, + contentStyle: { + backgroundColor: currentTheme.colors.darkBackground, + }, + }} + /> <Stack.Screen name="MainTabs" component={MainTabs as any} @@ -762,8 +738,8 @@ const AppNavigator = () => { component={MetadataScreen} options={{ headerShown: false, - animation: 'fade', - animationDuration: Platform.OS === 'android' ? 250 : 300, + animation: Platform.OS === 'android' ? 'none' : 'fade', + animationDuration: Platform.OS === 'android' ? 0 : 300, ...(Platform.OS === 'ios' && { cardStyleInterpolator: customFadeInterpolator, animationTypeForReplace: 'push', @@ -796,9 +772,20 @@ const AppNavigator = () => { options={{ animation: 'slide_from_right', animationDuration: Platform.OS === 'android' ? 200 : 300, + // Force fullscreen presentation on iPad + presentation: Platform.OS === 'ios' ? 'fullScreenModal' : 'card', + // Disable gestures during video playback + gestureEnabled: false, + // Ensure proper orientation handling + orientation: 'landscape', contentStyle: { backgroundColor: '#000000', // Pure black for video player }, + // iPad-specific fullscreen options + ...(Platform.OS === 'ios' && { + statusBarHidden: true, + statusBarAnimation: 'none', + }), }} /> <Stack.Screen @@ -1040,8 +1027,8 @@ const AppNavigator = () => { }} /> <Stack.Screen - name="InternalProvidersSettings" - component={InternalProvidersSettings} + name="ScraperSettings" + component={ScraperSettingsScreen} options={{ animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade', animationDuration: Platform.OS === 'android' ? 250 : 200, @@ -1061,4 +1048,4 @@ const AppNavigator = () => { ); }; -export default AppNavigator; \ No newline at end of file +export default AppNavigator; \ No newline at end of file diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx index c9b39bb..e41c947 100644 --- a/src/screens/AddonsScreen.tsx +++ b/src/screens/AddonsScreen.tsx @@ -586,6 +586,8 @@ const createStyles = (colors: any) => StyleSheet.create({ }, }); + + const AddonsScreen = () => { const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const [addons, setAddons] = useState<ExtendedManifest[]>([]); @@ -653,11 +655,17 @@ const AddonsScreen = () => { try { const response = await axios.get<CommunityAddon[]>('https://stremio-addons.com/catalog.json'); // Filter out addons without a manifest or transportUrl (basic validation) - const validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl); + let validAddons = response.data.filter(addon => addon.manifest && addon.transportUrl); + + // Filter out Cinemeta since it's now pre-installed + validAddons = validAddons.filter(addon => addon.manifest.id !== 'com.linvo.cinemeta'); + setCommunityAddons(validAddons); } catch (error) { logger.error('Failed to load community addons:', error); setCommunityError('Failed to load community addons. Please try again later.'); + // Set empty array on error since Cinemeta is pre-installed + setCommunityAddons([]); } finally { setCommunityLoading(false); } @@ -723,6 +731,16 @@ const AddonsScreen = () => { }; const handleRemoveAddon = (addon: ExtendedManifest) => { + // Check if this is a pre-installed addon + if (stremioService.isPreInstalledAddon(addon.id)) { + Alert.alert( + 'Cannot Remove Addon', + `${addon.name} is a pre-installed addon and cannot be removed.`, + [{ text: 'OK', style: 'default' }] + ); + return; + } + Alert.alert( 'Uninstall Addon', `Are you sure you want to uninstall ${addon.name}?`, @@ -877,6 +895,8 @@ const AddonsScreen = () => { const logo = item.logo || null; // Check if addon is configurable const isConfigurable = item.behaviorHints?.configurable === true; + // Check if addon is pre-installed + const isPreInstalled = stremioService.isPreInstalledAddon(item.id); // Format the types into a simple category text const categoryText = types.length > 0 @@ -928,7 +948,14 @@ const AddonsScreen = () => { </View> )} <View style={styles.addonTitleContainer}> - <Text style={styles.addonName}>{item.name}</Text> + <View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}> + <Text style={styles.addonName}>{item.name}</Text> + {isPreInstalled && ( + <View style={[styles.priorityBadge, { marginLeft: 8, backgroundColor: colors.success }]}> + <Text style={[styles.priorityText, { fontSize: 10 }]}>PRE-INSTALLED</Text> + </View> + )} + </View> <View style={styles.addonMetaContainer}> <Text style={styles.addonVersion}>v{item.version || '1.0.0'}</Text> <Text style={styles.addonDot}>•</Text> @@ -946,12 +973,14 @@ const AddonsScreen = () => { <MaterialIcons name="settings" size={20} color={colors.primary} /> </TouchableOpacity> )} - <TouchableOpacity - style={styles.deleteButton} - onPress={() => handleRemoveAddon(item)} - > - <MaterialIcons name="delete" size={20} color={colors.error} /> - </TouchableOpacity> + {!stremioService.isPreInstalledAddon(item.id) && ( + <TouchableOpacity + style={styles.deleteButton} + onPress={() => handleRemoveAddon(item)} + > + <MaterialIcons name="delete" size={20} color={colors.error} /> + </TouchableOpacity> + )} </> ) : ( <View style={styles.priorityBadge}> @@ -1387,4 +1416,4 @@ const AddonsScreen = () => { ); }; -export default AddonsScreen; \ No newline at end of file +export default AddonsScreen; \ No newline at end of file diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index dd7b0c2..162f69b 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -10,7 +10,8 @@ import { SafeAreaView, StatusBar, Dimensions, - SectionList + SectionList, + Platform } from 'react-native'; import { useNavigation } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; @@ -19,15 +20,17 @@ import { MaterialIcons } from '@expo/vector-icons'; import { LinearGradient } from 'expo-linear-gradient'; import { useTheme } from '../contexts/ThemeContext'; import { RootStackParamList } from '../navigation/AppNavigator'; -import { stremioService } from '../services/stremioService'; import { useLibrary } from '../hooks/useLibrary'; +import { useTraktContext } from '../contexts/TraktContext'; import { format, parseISO, isThisWeek, isAfter, startOfToday, addWeeks, isBefore, isSameDay } from 'date-fns'; import Animated, { FadeIn } from 'react-native-reanimated'; -import { CalendarSection } from '../components/calendar/CalendarSection'; +import { CalendarSection as CalendarSectionComponent } from '../components/calendar/CalendarSection'; import { tmdbService } from '../services/tmdbService'; import { logger } from '../utils/logger'; +import { useCalendarData } from '../hooks/useCalendarData'; const { width } = Dimensions.get('window'); +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; interface CalendarEpisode { id: string; @@ -53,182 +56,26 @@ const CalendarScreen = () => { const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const { libraryItems, loading: libraryLoading } = useLibrary(); const { currentTheme } = useTheme(); + const { calendarData, loading, refresh } = useCalendarData(); + const { + isAuthenticated: traktAuthenticated, + isLoading: traktLoading, + watchedShows, + watchlistShows, + continueWatching, + loadAllCollections + } = useTraktContext(); + logger.log(`[Calendar] Initial load - Library has ${libraryItems?.length || 0} items, loading: ${libraryLoading}`); - const [calendarData, setCalendarData] = useState<CalendarSection[]>([]); - const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); const [selectedDate, setSelectedDate] = useState<Date | null>(null); const [filteredEpisodes, setFilteredEpisodes] = useState<CalendarEpisode[]>([]); - const fetchCalendarData = useCallback(async () => { - logger.log("[Calendar] Starting to fetch calendar data"); - setLoading(true); - - try { - // Filter for only series in library - const seriesItems = libraryItems.filter(item => item.type === 'series'); - logger.log(`[Calendar] Library items: ${libraryItems.length}, Series items: ${seriesItems.length}`); - - let allEpisodes: CalendarEpisode[] = []; - let seriesWithoutEpisodes: CalendarEpisode[] = []; - - // For each series, fetch upcoming episodes - for (const series of seriesItems) { - try { - logger.log(`[Calendar] Fetching episodes for series: ${series.name} (${series.id})`); - const metadata = await stremioService.getMetaDetails(series.type, series.id); - logger.log(`[Calendar] Metadata fetched:`, metadata ? 'success' : 'null'); - - if (metadata?.videos && metadata.videos.length > 0) { - logger.log(`[Calendar] Series ${series.name} has ${metadata.videos.length} videos`); - // Filter for upcoming episodes or recently released - const today = startOfToday(); - const fourWeeksLater = addWeeks(today, 4); - const twoWeeksAgo = addWeeks(today, -2); - - // Get TMDB ID for additional metadata - const tmdbId = await tmdbService.findTMDBIdByIMDB(series.id); - let tmdbEpisodes: { [key: string]: any } = {}; - - if (tmdbId) { - const allTMDBEpisodes = await tmdbService.getAllEpisodes(tmdbId); - // Flatten episodes into a map for easy lookup - Object.values(allTMDBEpisodes).forEach(seasonEpisodes => { - seasonEpisodes.forEach(episode => { - const key = `${episode.season_number}:${episode.episode_number}`; - tmdbEpisodes[key] = episode; - }); - }); - } - - const upcomingEpisodes = metadata.videos - .filter(video => { - if (!video.released) return false; - const releaseDate = parseISO(video.released); - return isBefore(releaseDate, fourWeeksLater) && isAfter(releaseDate, twoWeeksAgo); - }) - .map(video => { - const tmdbEpisode = tmdbEpisodes[`${video.season}:${video.episode}`] || {}; - return { - id: video.id, - seriesId: series.id, - title: tmdbEpisode.name || video.title || `Episode ${video.episode}`, - seriesName: series.name || metadata.name, - poster: series.poster || metadata.poster || '', - releaseDate: video.released, - season: video.season || 0, - episode: video.episode || 0, - overview: tmdbEpisode.overview || '', - vote_average: tmdbEpisode.vote_average || 0, - still_path: tmdbEpisode.still_path || null, - season_poster_path: tmdbEpisode.season_poster_path || null - }; - }); - - if (upcomingEpisodes.length > 0) { - allEpisodes = [...allEpisodes, ...upcomingEpisodes]; - } else { - // Add to series without episode dates - seriesWithoutEpisodes.push({ - id: series.id, - seriesId: series.id, - title: 'No upcoming episodes', - seriesName: series.name || (metadata?.name || ''), - poster: series.poster || (metadata?.poster || ''), - releaseDate: '', - season: 0, - episode: 0, - overview: '', - vote_average: 0, - still_path: null, - season_poster_path: null - }); - } - } else { - // Add to series without episode dates - seriesWithoutEpisodes.push({ - id: series.id, - seriesId: series.id, - title: 'No upcoming episodes', - seriesName: series.name || (metadata?.name || ''), - poster: series.poster || (metadata?.poster || ''), - releaseDate: '', - season: 0, - episode: 0, - overview: '', - vote_average: 0, - still_path: null, - season_poster_path: null - }); - } - } catch (error) { - logger.error(`Error fetching episodes for ${series.name}:`, error); - } - } - - // Sort episodes by release date - allEpisodes.sort((a, b) => { - return new Date(a.releaseDate).getTime() - new Date(b.releaseDate).getTime(); - }); - - // Group episodes into sections - const thisWeekEpisodes = allEpisodes.filter( - episode => isThisWeek(parseISO(episode.releaseDate)) - ); - - const upcomingEpisodes = allEpisodes.filter( - episode => isAfter(parseISO(episode.releaseDate), new Date()) && - !isThisWeek(parseISO(episode.releaseDate)) - ); - - const recentEpisodes = allEpisodes.filter( - episode => isBefore(parseISO(episode.releaseDate), new Date()) && - !isThisWeek(parseISO(episode.releaseDate)) - ); - - logger.log(`[Calendar] Episodes summary: All episodes: ${allEpisodes.length}, This Week: ${thisWeekEpisodes.length}, Upcoming: ${upcomingEpisodes.length}, Recent: ${recentEpisodes.length}, No Schedule: ${seriesWithoutEpisodes.length}`); - - const sections: CalendarSection[] = []; - - if (thisWeekEpisodes.length > 0) { - sections.push({ title: 'This Week', data: thisWeekEpisodes }); - } - - if (upcomingEpisodes.length > 0) { - sections.push({ title: 'Upcoming', data: upcomingEpisodes }); - } - - if (recentEpisodes.length > 0) { - sections.push({ title: 'Recently Released', data: recentEpisodes }); - } - - if (seriesWithoutEpisodes.length > 0) { - sections.push({ title: 'Series with No Scheduled Episodes', data: seriesWithoutEpisodes }); - } - - setCalendarData(sections); - } catch (error) { - logger.error('Error fetching calendar data:', error); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [libraryItems]); - - useEffect(() => { - if (libraryItems.length > 0 && !libraryLoading) { - logger.log(`[Calendar] Library loaded with ${libraryItems.length} items, fetching calendar data`); - fetchCalendarData(); - } else if (!libraryLoading) { - logger.log(`[Calendar] Library loaded but empty (${libraryItems.length} items)`); - setLoading(false); - } - }, [libraryItems, libraryLoading, fetchCalendarData]); - const onRefresh = useCallback(() => { setRefreshing(true); - fetchCalendarData(); - }, [fetchCalendarData]); + refresh(true); + setRefreshing(false); + }, [refresh]); const handleSeriesPress = useCallback((seriesId: string, episode?: CalendarEpisode) => { navigation.navigate('Metadata', { @@ -360,7 +207,7 @@ const CalendarScreen = () => { ); // Process all episodes once data is loaded - const allEpisodes = calendarData.reduce((acc, section) => + const allEpisodes = calendarData.reduce((acc: CalendarEpisode[], section: CalendarSection) => [...acc, ...section.data], [] as CalendarEpisode[]); // Log when rendering with relevant state info @@ -389,43 +236,6 @@ const CalendarScreen = () => { setFilteredEpisodes([]); }, []); - if (libraryItems.length === 0 && !libraryLoading) { - return ( - <SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> - <StatusBar barStyle="light-content" /> - - <View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}> - <TouchableOpacity - style={styles.backButton} - onPress={() => navigation.goBack()} - > - <MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} /> - </TouchableOpacity> - <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Calendar</Text> - <View style={{ width: 40 }} /> - </View> - - <View style={styles.emptyLibraryContainer}> - <MaterialIcons name="video-library" size={64} color={currentTheme.colors.lightGray} /> - <Text style={styles.emptyText}> - Your library is empty - </Text> - <Text style={styles.emptySubtext}> - Add series to your library to see their upcoming episodes in the calendar - </Text> - <TouchableOpacity - style={styles.discoverButton} - onPress={() => navigation.navigate('MainTabs')} - > - <Text style={styles.discoverButtonText}> - Return to Home - </Text> - </TouchableOpacity> - </View> - </SafeAreaView> - ); - } - if (loading && !refreshing) { return ( <SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> @@ -464,7 +274,7 @@ const CalendarScreen = () => { </View> )} - <CalendarSection + <CalendarSectionComponent episodes={allEpisodes} onSelectDate={handleDateSelect} /> @@ -506,6 +316,7 @@ const CalendarScreen = () => { renderItem={renderEpisodeItem} renderSectionHeader={renderSectionHeader} contentContainerStyle={styles.listContent} + removeClippedSubviews={false} refreshControl={ <RefreshControl refreshing={refreshing} @@ -663,6 +474,8 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', padding: 12, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 12, + borderBottomWidth: 1, }, backButton: { padding: 8, diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index e80afcc..4e20a6a 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -39,29 +39,39 @@ const SPACING = { const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; -// Screen dimensions and grid layout -const { width } = Dimensions.get('window'); - // Dynamic column calculation based on screen width const calculateCatalogLayout = (screenWidth: number) => { - const MIN_ITEM_WIDTH = 120; // Increased minimum for better readability - const MAX_ITEM_WIDTH = 160; // Adjusted maximum - const HORIZONTAL_PADDING = SPACING.lg * 2; // Total horizontal padding - const ITEM_SPACING = SPACING.sm; // Space between items + const MIN_ITEM_WIDTH = 120; + const MAX_ITEM_WIDTH = 180; // Increased for tablets + const HORIZONTAL_PADDING = SPACING.lg * 2; + const ITEM_SPACING = SPACING.sm; // Calculate how many columns can fit const availableWidth = screenWidth - HORIZONTAL_PADDING; const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING)); - // Limit to reasonable number of columns (2-4 for better UX) - const numColumns = Math.min(Math.max(maxColumns, 2), 4); + // More flexible column limits for different screen sizes + let numColumns; + if (screenWidth < 600) { + // Phone: 2-3 columns + numColumns = Math.min(Math.max(maxColumns, 2), 3); + } else if (screenWidth < 900) { + // Small tablet: 3-5 columns + numColumns = Math.min(Math.max(maxColumns, 3), 5); + } else if (screenWidth < 1200) { + // Large tablet: 4-6 columns + numColumns = Math.min(Math.max(maxColumns, 4), 6); + } else { + // Very large screens: 5-8 columns + numColumns = Math.min(Math.max(maxColumns, 5), 8); + } // Calculate actual item width with proper spacing const totalSpacing = ITEM_SPACING * (numColumns - 1); const itemWidth = (availableWidth - totalSpacing) / numColumns; - // For 2 columns, ensure we use the full available width - const finalItemWidth = numColumns === 2 ? itemWidth : Math.min(itemWidth, MAX_ITEM_WIDTH); + // Ensure item width doesn't exceed maximum + const finalItemWidth = Math.min(itemWidth, MAX_ITEM_WIDTH); return { numColumns, @@ -69,11 +79,6 @@ const calculateCatalogLayout = (screenWidth: number) => { }; }; -const catalogLayout = calculateCatalogLayout(width); -const NUM_COLUMNS = catalogLayout.numColumns; -const ITEM_MARGIN = SPACING.sm; -const ITEM_WIDTH = catalogLayout.itemWidth; - // Create a styles creator function that accepts the theme colors const createStyles = (colors: any) => StyleSheet.create({ container: { @@ -85,6 +90,10 @@ const createStyles = (colors: any) => StyleSheet.create({ alignItems: 'center', paddingHorizontal: 16, paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8, + // Center header on very wide screens + alignSelf: 'center', + maxWidth: 1400, + width: '100%', }, backButton: { flexDirection: 'row', @@ -103,10 +112,18 @@ const createStyles = (colors: any) => StyleSheet.create({ paddingHorizontal: 16, paddingBottom: 16, paddingTop: 8, + // Center title on very wide screens + alignSelf: 'center', + maxWidth: 1400, + width: '100%', }, list: { padding: SPACING.lg, paddingTop: SPACING.sm, + // Center content on very wide screens + alignSelf: 'center', + maxWidth: 1400, // Prevent content from being too wide on large screens + width: '100%', }, item: { marginBottom: SPACING.lg, @@ -162,6 +179,10 @@ const createStyles = (colors: any) => StyleSheet.create({ justifyContent: 'center', alignItems: 'center', padding: SPACING.xl, + // Center content on very wide screens + alignSelf: 'center', + maxWidth: 600, // Narrower max width for centered content + width: '100%', }, emptyText: { color: colors.white, @@ -188,16 +209,37 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { const { addonId, type, id, name: originalName, genreFilter } = route.params; const [items, setItems] = useState<Meta[]>([]); const [loading, setLoading] = useState(true); + const [paginating, setPaginating] = useState(false); const [refreshing, setRefreshing] = useState(false); const [page, setPage] = useState(1); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState<string | null>(null); const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS); const [actualCatalogName, setActualCatalogName] = useState<string | null>(null); + const [screenData, setScreenData] = useState(() => { + const { width } = Dimensions.get('window'); + return { + width, + ...calculateCatalogLayout(width) + }; + }); const { currentTheme } = useTheme(); const colors = currentTheme.colors; const styles = createStyles(colors); const isDarkMode = true; + const isInitialRender = React.useRef(true); + + // Handle screen dimension changes + useEffect(() => { + const subscription = Dimensions.addEventListener('change', ({ window }) => { + setScreenData({ + width: window.width, + ...calculateCatalogLayout(window.width) + }); + }); + + return () => subscription?.remove(); + }, []); const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames(); @@ -262,7 +304,10 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { try { if (shouldRefresh) { setRefreshing(true); - } else if (pageNum === 1) { + setHasMore(true); // Reset hasMore on refresh + } else if (pageNum > 1) { + setPaginating(true); + } else { setLoading(true); } @@ -360,6 +405,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { setHasMore(false); } else { foundItems = true; + setHasMore(true); // Ensure hasMore is true if we found items } if (shouldRefresh || pageNum === 1) { @@ -442,8 +488,11 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { index === self.findIndex((t) => t.id === item.id) ); - if (uniqueItems.length === 0) { + if (uniqueItems.length === 0 && allItems.length === 0) { setHasMore(false); + } else { + foundItems = true; + setHasMore(true); // Ensure hasMore is true if we found items } if (shouldRefresh || pageNum === 1) { @@ -467,30 +516,36 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { } finally { setLoading(false); setRefreshing(false); + setPaginating(false); } }, [addonId, type, id, genreFilter, dataSource]); useEffect(() => { - loadItems(1); + loadItems(1, true); }, [loadItems]); const handleRefresh = useCallback(() => { setPage(1); + setItems([]); // Clear items on refresh loadItems(1, true); }, [loadItems]); const handleLoadMore = useCallback(() => { - if (!loading && hasMore) { + if (isInitialRender.current) { + isInitialRender.current = false; + return; + } + if (!loading && !paginating && hasMore && !refreshing) { const nextPage = page + 1; setPage(nextPage); loadItems(nextPage); } - }, [loading, hasMore, page, loadItems]); + }, [loading, paginating, hasMore, page, loadItems, refreshing]); const renderItem = useCallback(({ item, index }: { item: Meta; index: number }) => { // Calculate if this is the last item in a row - const isLastInRow = (index + 1) % NUM_COLUMNS === 0; - // For 2-column layout, ensure proper spacing + const isLastInRow = (index + 1) % screenData.numColumns === 0; + // For proper spacing const rightMargin = isLastInRow ? 0 : SPACING.sm; return ( @@ -499,8 +554,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { styles.item, { marginRight: rightMargin, - // For 2 columns, ensure items fill the available space properly - width: NUM_COLUMNS === 2 ? ITEM_WIDTH : ITEM_WIDTH + width: screenData.itemWidth } ]} onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })} @@ -527,7 +581,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { </View> </TouchableOpacity> ); - }, [navigation, styles, NUM_COLUMNS, ITEM_WIDTH]); + }, [navigation, styles, screenData.numColumns, screenData.itemWidth]); const renderEmptyState = () => ( <View style={styles.centered}> @@ -625,8 +679,8 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { data={items} renderItem={renderItem} keyExtractor={(item) => `${item.id}-${item.type}`} - numColumns={NUM_COLUMNS} - key={NUM_COLUMNS} + numColumns={screenData.numColumns} + key={screenData.numColumns} refreshControl={ <RefreshControl refreshing={refreshing} @@ -638,7 +692,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { onEndReached={handleLoadMore} onEndReachedThreshold={0.5} ListFooterComponent={ - loading && items.length > 0 ? ( + paginating ? ( <View style={styles.footer}> <ActivityIndicator size="small" color={colors.primary} /> </View> @@ -652,4 +706,4 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => { ); }; -export default CatalogScreen; \ No newline at end of file +export default CatalogScreen; \ No newline at end of file diff --git a/src/screens/DiscoverScreen.tsx b/src/screens/DiscoverScreen.tsx deleted file mode 100644 index 3df7368..0000000 --- a/src/screens/DiscoverScreen.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { - View, - Text, - StyleSheet, - TouchableOpacity, - ActivityIndicator, - StatusBar, - Dimensions, - Platform, -} from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { NavigationProp } from '@react-navigation/native'; -import { MaterialIcons } from '@expo/vector-icons'; -import { catalogService, StreamingContent } from '../services/catalogService'; -import { RootStackParamList } from '../navigation/AppNavigator'; -import { logger } from '../utils/logger'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useTheme } from '../contexts/ThemeContext'; - -// Components -import CategorySelector from '../components/discover/CategorySelector'; -import GenreSelector from '../components/discover/GenreSelector'; -import CatalogsList from '../components/discover/CatalogsList'; - -// Constants and types -import { CATEGORIES, COMMON_GENRES, Category, GenreCatalog } from '../constants/discover'; - -// Styles -import useDiscoverStyles from '../styles/screens/discoverStyles'; - -const DiscoverScreen = () => { - const navigation = useNavigation<NavigationProp<RootStackParamList>>(); - const [selectedCategory, setSelectedCategory] = useState<Category>(CATEGORIES[0]); - const [selectedGenre, setSelectedGenre] = useState<string>('All'); - const [catalogs, setCatalogs] = useState<GenreCatalog[]>([]); - const [loading, setLoading] = useState(true); - const styles = useDiscoverStyles(); - const insets = useSafeAreaInsets(); - const { currentTheme } = useTheme(); - - // Force consistent status bar settings - useEffect(() => { - const applyStatusBarConfig = () => { - StatusBar.setBarStyle('light-content'); - if (Platform.OS === 'android') { - StatusBar.setTranslucent(true); - StatusBar.setBackgroundColor('transparent'); - } - }; - - applyStatusBarConfig(); - - // Re-apply on focus - const unsubscribe = navigation.addListener('focus', applyStatusBarConfig); - return unsubscribe; - }, [navigation]); - - // Load content when category or genre changes - useEffect(() => { - loadContent(selectedCategory, selectedGenre); - }, [selectedCategory, selectedGenre]); - - const loadContent = async (category: Category, genre: string) => { - setLoading(true); - try { - // If genre is 'All', don't apply genre filter - const genreFilter = genre === 'All' ? undefined : genre; - const fetchedCatalogs = await catalogService.getCatalogByType(category.type, genreFilter); - - // Collect all content items - const content: StreamingContent[] = []; - fetchedCatalogs.forEach(catalog => { - content.push(...catalog.items); - }); - - if (genre === 'All') { - // Group by genres when "All" is selected - const genreCatalogs: GenreCatalog[] = []; - - // Get all genres from content - const genresSet = new Set<string>(); - content.forEach(item => { - if (item.genres && item.genres.length > 0) { - item.genres.forEach(g => genresSet.add(g)); - } - }); - - // Create catalogs for each genre - genresSet.forEach(g => { - const genreItems = content.filter(item => - item.genres && item.genres.includes(g) - ); - - if (genreItems.length > 0) { - genreCatalogs.push({ - genre: g, - items: genreItems - }); - } - }); - - // Sort by number of items - genreCatalogs.sort((a, b) => b.items.length - a.items.length); - - setCatalogs(genreCatalogs); - } else { - // When a specific genre is selected, show as a single catalog - setCatalogs([{ genre, items: content }]); - } - } catch (error) { - logger.error('Failed to load content:', error); - setCatalogs([]); - } finally { - setLoading(false); - } - }; - - const handleCategoryPress = useCallback((category: Category) => { - if (category.id !== selectedCategory.id) { - setSelectedCategory(category); - setSelectedGenre('All'); // Reset to All when changing category - } - }, [selectedCategory]); - - const handleGenrePress = useCallback((genre: string) => { - if (genre !== selectedGenre) { - setSelectedGenre(genre); - } - }, [selectedGenre]); - - const handleSearchPress = useCallback(() => { - navigation.navigate('Search'); - }, [navigation]); - - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; - const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; - const headerHeight = headerBaseHeight + topSpacing; - - const renderEmptyState = () => ( - <View style={styles.emptyContainer}> - <Text style={styles.emptyText}> - No content found for {selectedGenre !== 'All' ? selectedGenre : 'these filters'} - </Text> - </View> - ); - - return ( - <View style={styles.container}> - {/* Fixed position header background */} - <View style={[styles.headerBackground, { height: headerHeight }]} /> - - <View style={{ flex: 1 }}> - {/* Header Section */} - <View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}> - <View style={styles.headerContent}> - <Text style={styles.headerTitle}>Discover</Text> - <TouchableOpacity - onPress={handleSearchPress} - style={styles.searchButton} - activeOpacity={0.7} - > - <MaterialIcons - name="search" - size={24} - color={currentTheme.colors.white} - /> - </TouchableOpacity> - </View> - </View> - - {/* Content Container */} - <View style={styles.contentContainer}> - {/* Categories Section */} - <CategorySelector - categories={CATEGORIES} - selectedCategory={selectedCategory} - onSelectCategory={handleCategoryPress} - /> - - {/* Genres Section */} - <GenreSelector - genres={COMMON_GENRES} - selectedGenre={selectedGenre} - onSelectGenre={handleGenrePress} - /> - - {/* Content Section */} - {loading ? ( - <View style={styles.loadingContainer}> - <ActivityIndicator size="large" color={currentTheme.colors.primary} /> - </View> - ) : catalogs.length > 0 ? ( - <CatalogsList - catalogs={catalogs} - selectedCategory={selectedCategory} - /> - ) : renderEmptyState()} - </View> - </View> - </View> - ); -}; - -export default React.memo(DiscoverScreen); \ No newline at end of file diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index e622643..2048dd5 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -62,6 +62,12 @@ import homeStyles, { sharedStyles } from '../styles/homeStyles'; import { useTheme } from '../contexts/ThemeContext'; import type { Theme } from '../contexts/ThemeContext'; import * as ScreenOrientation from 'expo-screen-orientation'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import FirstTimeWelcome from '../components/FirstTimeWelcome'; +import { imageCacheService } from '../services/imageCacheService'; + +// Constants +const CATALOG_SETTINGS_KEY = 'catalog_settings'; // Define interfaces for our data interface Category { @@ -69,299 +75,17 @@ interface Category { name: string; } -interface ContentItemProps { - item: StreamingContent; - onPress: (id: string, type: string) => void; -} - -interface DropUpMenuProps { - visible: boolean; - onClose: () => void; - item: StreamingContent; - onOptionSelect: (option: string) => void; -} - interface ContinueWatchingRef { refresh: () => Promise<boolean>; } -const DropUpMenu = React.memo(({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => { - const translateY = useSharedValue(300); - const opacity = useSharedValue(0); - const isDarkMode = useColorScheme() === 'dark'; - const { currentTheme } = useTheme(); - const SNAP_THRESHOLD = 100; - - useEffect(() => { - if (visible) { - opacity.value = withTiming(1, { duration: 200 }); - translateY.value = withTiming(0, { duration: 300 }); - } else { - opacity.value = withTiming(0, { duration: 200 }); - translateY.value = withTiming(300, { duration: 300 }); - } - - // Cleanup animations when component unmounts - return () => { - opacity.value = 0; - translateY.value = 300; - }; - }, [visible]); - - const gesture = useMemo(() => Gesture.Pan() - .onStart(() => { - // Store initial position if needed - }) - .onUpdate((event) => { - if (event.translationY > 0) { // Only allow dragging downwards - translateY.value = event.translationY; - opacity.value = interpolate( - event.translationY, - [0, 300], - [1, 0], - Extrapolate.CLAMP - ); - } - }) - .onEnd((event) => { - if (event.translationY > SNAP_THRESHOLD || event.velocityY > 500) { - translateY.value = withTiming(300, { duration: 300 }); - opacity.value = withTiming(0, { duration: 200 }); - runOnJS(onClose)(); - } else { - translateY.value = withTiming(0, { duration: 300 }); - opacity.value = withTiming(1, { duration: 200 }); - } - }), [onClose]); - - const overlayStyle = useAnimatedStyle(() => ({ - opacity: opacity.value, - backgroundColor: currentTheme.colors.transparentDark, - })); - - const menuStyle = useAnimatedStyle(() => ({ - transform: [{ translateY: translateY.value }], - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white, - })); - - const menuOptions = useMemo(() => [ - { - icon: item.inLibrary ? 'bookmark' : 'bookmark-border', - label: item.inLibrary ? 'Remove from Library' : 'Add to Library', - action: 'library' - }, - { - icon: 'check-circle', - label: 'Mark as Watched', - action: 'watched' - }, - { - icon: 'playlist-add', - label: 'Add to Playlist', - action: 'playlist' - }, - { - icon: 'share', - label: 'Share', - action: 'share' - } - ], [item.inLibrary]); - - const handleOptionSelect = useCallback((action: string) => { - onOptionSelect(action); - onClose(); - }, [onOptionSelect, onClose]); - - return ( - <Modal - visible={visible} - transparent - animationType="none" - onRequestClose={onClose} - > - <GestureHandlerRootView style={{ flex: 1 }}> - <Animated.View style={[styles.modalOverlay, overlayStyle]}> - <Pressable style={styles.modalOverlayPressable} onPress={onClose} /> - <GestureDetector gesture={gesture}> - <Animated.View style={[styles.menuContainer, menuStyle]}> - <View style={[styles.dragHandle, { backgroundColor: currentTheme.colors.transparentLight }]} /> - <View style={[styles.menuHeader, { borderBottomColor: currentTheme.colors.border }]}> - <ExpoImage - source={{ uri: item.poster }} - style={styles.menuPoster} - contentFit="cover" - /> - <View style={styles.menuTitleContainer}> - <Text style={[styles.menuTitle, { color: isDarkMode ? currentTheme.colors.white : currentTheme.colors.black }]}> - {item.name} - </Text> - {item.year && ( - <Text style={[styles.menuYear, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}> - {item.year} - </Text> - )} - </View> - </View> - <View style={styles.menuOptions}> - {menuOptions.map((option, index) => ( - <TouchableOpacity - key={option.action} - style={[ - styles.menuOption, - { borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' }, - index === menuOptions.length - 1 && styles.lastMenuOption - ]} - onPress={() => handleOptionSelect(option.action)} - > - <MaterialIcons - name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"} - size={24} - color={currentTheme.colors.primary} - /> - <Text style={[ - styles.menuOptionText, - { color: isDarkMode ? currentTheme.colors.white : currentTheme.colors.black } - ]}> - {option.label} - </Text> - </TouchableOpacity> - ))} - </View> - </Animated.View> - </GestureDetector> - </Animated.View> - </GestureHandlerRootView> - </Modal> - ); -}); - -const ContentItem = React.memo(({ item: initialItem, onPress }: ContentItemProps) => { - const [menuVisible, setMenuVisible] = useState(false); - const [localItem, setLocalItem] = useState(initialItem); - const [isWatched, setIsWatched] = useState(false); - const [imageLoaded, setImageLoaded] = useState(false); - const [imageError, setImageError] = useState(false); - const { currentTheme } = useTheme(); - - const handleLongPress = useCallback(() => { - setMenuVisible(true); - }, []); - - const handlePress = useCallback(() => { - onPress(localItem.id, localItem.type); - }, [localItem.id, localItem.type, onPress]); - - const handleOptionSelect = useCallback((option: string) => { - switch (option) { - case 'library': - if (localItem.inLibrary) { - catalogService.removeFromLibrary(localItem.type, localItem.id); - } else { - catalogService.addToLibrary(localItem); - } - break; - case 'watched': - setIsWatched(prev => !prev); - break; - case 'playlist': - case 'share': - // These options don't have implementations yet - break; - } - }, [localItem]); - - const handleMenuClose = useCallback(() => { - setMenuVisible(false); - }, []); - - // Only update localItem when initialItem changes - useEffect(() => { - setLocalItem(initialItem); - }, [initialItem]); - - // Subscribe to library updates - useEffect(() => { - const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => { - const isInLibrary = libraryItems.some( - libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type - ); - if (isInLibrary !== localItem.inLibrary) { - setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary })); - } - }); - - return () => unsubscribe(); - }, [localItem.id, localItem.type]); - - return ( - <> - <TouchableOpacity - style={styles.contentItem} - activeOpacity={0.7} - onPress={handlePress} - onLongPress={handleLongPress} - delayLongPress={300} - > - <View style={styles.contentItemContainer}> - <ExpoImage - source={{ uri: localItem.poster }} - style={styles.poster} - contentFit="cover" - transition={300} - cachePolicy="memory-disk" - recyclingKey={`poster-${localItem.id}`} - onLoadStart={() => { - setImageLoaded(false); - setImageError(false); - }} - onLoadEnd={() => setImageLoaded(true)} - onError={() => { - setImageError(true); - setImageLoaded(true); - }} - /> - {(!imageLoaded || imageError) && ( - <View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation2 }]}> - {!imageError ? ( - <ActivityIndicator color={currentTheme.colors.primary} size="small" /> - ) : ( - <MaterialIcons name="broken-image" size={24} color={currentTheme.colors.lightGray} /> - )} - </View> - )} - {isWatched && ( - <View style={styles.watchedIndicator}> - <MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} /> - </View> - )} - {localItem.inLibrary && ( - <View style={styles.libraryBadge}> - <MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} /> - </View> - )} - </View> - </TouchableOpacity> - - {menuVisible && ( - <DropUpMenu - visible={menuVisible} - onClose={handleMenuClose} - item={localItem} - onOptionSelect={handleOptionSelect} - /> - )} - </> - ); -}, (prevProps, nextProps) => { - // Custom comparison function to prevent unnecessary re-renders - return ( - prevProps.item.id === nextProps.item.id && - prevProps.item.inLibrary === nextProps.item.inLibrary && - prevProps.onPress === nextProps.onPress - ); -}); +type HomeScreenListItem = + | { type: 'featured'; key: string } + | { type: 'thisWeek'; key: string } + | { type: 'continueWatching'; key: string } + | { type: 'catalog'; catalog: CatalogContent; key: string } + | { type: 'placeholder'; key: string } + | { type: 'welcome'; key: string }; // Sample categories (real app would get these from API) const SAMPLE_CATEGORIES: Category[] = [ @@ -393,9 +117,10 @@ const HomeScreen = () => { const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); const [hasContinueWatching, setHasContinueWatching] = useState(false); - const [catalogs, setCatalogs] = useState<CatalogContent[]>([]); + const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]); const [catalogsLoading, setCatalogsLoading] = useState(true); const [loadedCatalogCount, setLoadedCatalogCount] = useState(0); + const [hasAddons, setHasAddons] = useState<boolean | null>(null); const totalCatalogsRef = useRef(0); const { @@ -415,6 +140,16 @@ const HomeScreen = () => { try { const addons = await catalogService.getAllAddons(); + // Set hasAddons state based on whether we have any addons + setHasAddons(addons.length > 0); + + // Load catalog settings to check which catalogs are enabled + const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY); + const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {}; + + // Hoist addon manifest loading out of the loop + const addonManifests = await stremioService.getInstalledAddonsAsync(); + // Create placeholder array with proper order and track indices const catalogPlaceholders: (CatalogContent | null)[] = []; const catalogPromises: Promise<void>[] = []; @@ -423,101 +158,100 @@ const HomeScreen = () => { for (const addon of addons) { if (addon.catalogs) { for (const catalog of addon.catalogs) { - const currentIndex = catalogIndex; - catalogPlaceholders.push(null); // Reserve position + // Check if this catalog is enabled (default to true if no setting exists) + const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`; + const isEnabled = catalogSettings[settingKey] ?? true; - const catalogPromise = (async () => { - try { - const addonManifest = await stremioService.getInstalledAddonsAsync(); - const manifest = addonManifest.find((a: any) => a.id === addon.id); - if (!manifest) return; + // Only load enabled catalogs + if (isEnabled) { + const currentIndex = catalogIndex; + catalogPlaceholders.push(null); // Reserve position + + const catalogPromise = (async () => { + try { + const manifest = addonManifests.find((a: any) => a.id === addon.id); + if (!manifest) return; - const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); - if (metas && metas.length > 0) { - const items = metas.map((meta: any) => ({ - id: meta.id, - type: meta.type, - name: meta.name, - poster: meta.poster, - posterShape: meta.posterShape, - banner: meta.background, - logo: meta.logo, - imdbRating: meta.imdbRating, - year: meta.year, - genres: meta.genres, - description: meta.description, - runtime: meta.runtime, - released: meta.released, - trailerStreams: meta.trailerStreams, - videos: meta.videos, - directors: meta.director, - creators: meta.creator, - certification: meta.certification - })); - - let displayName = catalog.name; - const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; - if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { - displayName = `${displayName} ${contentType}`; + const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1); + if (metas && metas.length > 0) { + const items = metas.map((meta: any) => ({ + id: meta.id, + type: meta.type, + name: meta.name, + poster: meta.poster, + posterShape: meta.posterShape, + banner: meta.background, + logo: meta.logo, + imdbRating: meta.imdbRating, + year: meta.year, + genres: meta.genres, + description: meta.description, + runtime: meta.runtime, + released: meta.released, + trailerStreams: meta.trailerStreams, + videos: meta.videos, + directors: meta.director, + creators: meta.creator, + certification: meta.certification + })); + + let displayName = catalog.name; + const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows'; + if (!displayName.toLowerCase().includes(contentType.toLowerCase())) { + displayName = `${displayName} ${contentType}`; + } + + const catalogContent = { + addon: addon.id, + type: catalog.type, + id: catalog.id, + name: displayName, + items + }; + + // Update the catalog at its specific position + setCatalogs(prevCatalogs => { + const newCatalogs = [...prevCatalogs]; + newCatalogs[currentIndex] = catalogContent; + return newCatalogs; + }); } - - const catalogContent = { - addon: addon.id, - type: catalog.type, - id: catalog.id, - name: displayName, - items - }; - - console.log(`[HomeScreen] Loaded catalog: ${displayName} at position ${currentIndex} (${items.length} items)`); - - // Update the catalog at its specific position - setCatalogs(prevCatalogs => { - const newCatalogs = [...prevCatalogs]; - newCatalogs[currentIndex] = catalogContent; - return newCatalogs; - }); + } catch (error) { + console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error); + } finally { + setLoadedCatalogCount(prev => prev + 1); } - } catch (error) { - console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error); - } finally { - setLoadedCatalogCount(prev => prev + 1); - } - })(); - - catalogPromises.push(catalogPromise); - catalogIndex++; + })(); + + catalogPromises.push(catalogPromise); + catalogIndex++; + } } } } totalCatalogsRef.current = catalogIndex; - console.log(`[HomeScreen] Starting to load ${catalogIndex} catalogs progressively...`); // Initialize catalogs array with proper length setCatalogs(new Array(catalogIndex).fill(null)); // Start all catalog loading promises but don't wait for them // They will update the state progressively as they complete - Promise.allSettled(catalogPromises).then(() => { - console.log('[HomeScreen] All catalogs processed'); - - // Final cleanup: Filter out null values to get only successfully loaded catalogs - setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null)); - }); + await Promise.allSettled(catalogPromises); + // Only set catalogsLoading to false after all promises have settled + setCatalogsLoading(false); } catch (error) { console.error('[HomeScreen] Error in progressive catalog loading:', error); - } finally { setCatalogsLoading(false); } }, []); // Only count feature section as loading if it's enabled in settings - // For catalogs, we show them progressively, so only show loading if no catalogs are loaded yet + // For catalogs, we show them progressively, so loading should be false as soon as we have any content const isLoading = useMemo(() => - (showHeroSection ? featuredLoading : false) || (catalogsLoading && catalogs.length === 0), - [showHeroSection, featuredLoading, catalogsLoading, catalogs.length] + (showHeroSection ? featuredLoading : false) || (catalogsLoading && loadedCatalogCount === 0), + [showHeroSection, featuredLoading, catalogsLoading, loadedCatalogCount] ); // React to settings changes @@ -591,26 +325,47 @@ const HomeScreen = () => { }; }, [currentTheme.colors.darkBackground]); - // Preload images function - memoized to avoid recreating on every render + // Optimized preload images function with better memory management const preloadImages = useCallback(async (content: StreamingContent[]) => { if (!content.length) return; try { - const imagePromises = content.map(item => { - const imagesToLoad = [ - item.poster, - item.banner, - item.logo - ].filter(Boolean) as string[]; + // Significantly reduced concurrent prefetching to prevent heating + const BATCH_SIZE = 2; // Reduced from 3 to 2 + const MAX_IMAGES = 5; // Reduced from 10 to 5 + + // Only preload the most important images (poster and banner, skip logo) + const allImages = content.slice(0, MAX_IMAGES) + .map(item => [item.poster, item.banner]) + .flat() + .filter(Boolean) as string[]; - return Promise.all( - imagesToLoad.map(imageUrl => - ExpoImage.prefetch(imageUrl) - ) - ); - }); - - await Promise.all(imagePromises); + // Process in smaller batches with longer delays + for (let i = 0; i < allImages.length; i += BATCH_SIZE) { + const batch = allImages.slice(i, i + BATCH_SIZE); + + try { + await Promise.all( + batch.map(async (imageUrl) => { + try { + // Use our cache service instead of direct prefetch + await imageCacheService.getCachedImageUrl(imageUrl); + // Increased delay between prefetches to reduce CPU load + await new Promise(resolve => setTimeout(resolve, 100)); + } catch (error) { + // Silently handle individual prefetch errors + } + }) + ); + + // Longer delay between batches to allow GC and reduce heating + if (i + BATCH_SIZE < allImages.length) { + await new Promise(resolve => setTimeout(resolve, 200)); + } + } catch (error) { + // Continue with next batch if current batch fails + } + } } catch (error) { // Silently handle preload errors } @@ -624,11 +379,27 @@ const HomeScreen = () => { if (!featuredContent) return; try { + // Clear image cache to reduce memory pressure before orientation change + if (typeof (global as any)?.ExpoImage?.clearMemoryCache === 'function') { + try { + (global as any).ExpoImage.clearMemoryCache(); + } catch (e) { + // Ignore cache clear errors + } + } + // Lock orientation to landscape before navigation to prevent glitches + try { await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE); - // Small delay to ensure orientation is set before navigation + // Longer delay to ensure orientation is fully set before navigation + await new Promise(resolve => setTimeout(resolve, 200)); + } catch (orientationError) { + // If orientation lock fails, continue anyway but log it + logger.warn('[HomeScreen] Orientation lock failed:', orientationError); + // Still add a small delay await new Promise(resolve => setTimeout(resolve, 100)); + } navigation.navigate('Player', { uri: stream.url, @@ -640,6 +411,8 @@ const HomeScreen = () => { type: featuredContent.type }); } catch (error) { + logger.error('[HomeScreen] Error in handlePlayStream:', error); + // Fallback: navigate anyway navigation.navigate('Player', { uri: stream.url, @@ -654,37 +427,15 @@ const HomeScreen = () => { }, [featuredContent, navigation]); const refreshContinueWatching = useCallback(async () => { - console.log('[HomeScreen] Refreshing continue watching...'); if (continueWatchingRef.current) { try { const hasContent = await continueWatchingRef.current.refresh(); - console.log(`[HomeScreen] Continue watching has content: ${hasContent}`); setHasContinueWatching(hasContent); - // Debug: Let's check what's in storage - const allProgress = await storageService.getAllWatchProgress(); - console.log('[HomeScreen] All watch progress in storage:', Object.keys(allProgress).length, 'items'); - console.log('[HomeScreen] Watch progress items:', allProgress); - - // Check if any items are being filtered out due to >85% progress - let filteredCount = 0; - for (const [key, progress] of Object.entries(allProgress)) { - const progressPercent = (progress.currentTime / progress.duration) * 100; - if (progressPercent >= 85) { - filteredCount++; - console.log(`[HomeScreen] Filtered out ${key}: ${progressPercent.toFixed(1)}% complete`); - } else { - console.log(`[HomeScreen] Valid progress ${key}: ${progressPercent.toFixed(1)}% complete`); - } - } - console.log(`[HomeScreen] Filtered out ${filteredCount} completed items`); - } catch (error) { console.error('[HomeScreen] Error refreshing continue watching:', error); setHasContinueWatching(false); } - } else { - console.log('[HomeScreen] Continue watching ref is null'); } }, []); @@ -722,6 +473,120 @@ const HomeScreen = () => { return null; }, [isLoading, currentTheme.colors]); + const listData: HomeScreenListItem[] = useMemo(() => { + const data: HomeScreenListItem[] = []; + + // If no addons are installed, just show the welcome component + if (hasAddons === false) { + data.push({ type: 'welcome', key: 'welcome' }); + return data; + } + + // Normal flow when addons are present + if (showHeroSection) { + data.push({ type: 'featured', key: 'featured' }); + } + + data.push({ type: 'thisWeek', key: 'thisWeek' }); + data.push({ type: 'continueWatching', key: 'continueWatching' }); + + catalogs.forEach((catalog, index) => { + if (catalog) { + data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` }); + } else { + // Add a key for placeholders + data.push({ type: 'placeholder', key: `placeholder-${index}` }); + } + }); + + return data; + }, [hasAddons, showHeroSection, catalogs]); + + const renderListItem = useCallback(({ item }: { item: HomeScreenListItem }) => { + switch (item.type) { + case 'featured': + return ( + <FeaturedContent + key={`featured-${showHeroSection}-${featuredContentSource}`} + featuredContent={featuredContent} + isSaved={isSaved} + handleSaveToLibrary={handleSaveToLibrary} + /> + ); + case 'thisWeek': + return <Animated.View entering={FadeIn.duration(300).delay(100)}><ThisWeekSection /></Animated.View>; + case 'continueWatching': + return <ContinueWatchingSection ref={continueWatchingRef} />; + case 'catalog': + return ( + <Animated.View entering={FadeIn.duration(300)}> + <CatalogSection catalog={item.catalog} /> + </Animated.View> + ); + case 'placeholder': + return ( + <Animated.View entering={FadeIn.duration(300)}> + <View style={styles.catalogPlaceholder}> + <View style={styles.placeholderHeader}> + <View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} /> + <ActivityIndicator size="small" color={currentTheme.colors.primary} /> + </View> + <ScrollView + horizontal + showsHorizontalScrollIndicator={false} + contentContainerStyle={styles.placeholderPosters}> + {[...Array(5)].map((_, posterIndex) => ( + <View + key={posterIndex} + style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]} + /> + ))} + </ScrollView> + </View> + </Animated.View> + ); + case 'welcome': + return <FirstTimeWelcome />; + default: + return null; + } + }, [ + showHeroSection, + featuredContentSource, + featuredContent, + isSaved, + handleSaveToLibrary, + currentTheme.colors + ]); + + const ListFooterComponent = useMemo(() => ( + <> + {catalogsLoading && loadedCatalogCount > 0 && loadedCatalogCount < totalCatalogsRef.current && ( + <View style={styles.loadingMoreCatalogs}> + <ActivityIndicator size="small" color={currentTheme.colors.primary} /> + <Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}> + Loading catalogs... ({loadedCatalogCount}/{totalCatalogsRef.current}) + </Text> + </View> + )} + {!catalogsLoading && catalogs.filter(c => c).length === 0 && ( + <View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}> + <MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} /> + <Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}> + No content available + </Text> + <TouchableOpacity + style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]} + onPress={() => navigation.navigate('Settings')} + > + <MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} /> + <Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text> + </TouchableOpacity> + </View> + )} + </> + ), [catalogsLoading, catalogs, loadedCatalogCount, totalCatalogsRef.current, navigation, currentTheme.colors]); + // Memoize the main content section const renderMainContent = useMemo(() => { if (isLoading) return null; @@ -733,102 +598,40 @@ const HomeScreen = () => { backgroundColor="transparent" translucent /> - <ScrollView + <FlatList + data={listData} + renderItem={renderListItem} + keyExtractor={item => item.key} contentContainerStyle={[ styles.scrollContent, { paddingTop: Platform.OS === 'ios' ? 100 : 90 } ]} showsVerticalScrollIndicator={false} - removeClippedSubviews={true} - > - {showHeroSection && ( - <FeaturedContent - key={`featured-${showHeroSection}-${featuredContentSource}`} - featuredContent={featuredContent} - isSaved={isSaved} - handleSaveToLibrary={handleSaveToLibrary} - /> - )} - - <Animated.View entering={FadeIn.duration(400).delay(150)}> - <ThisWeekSection /> - </Animated.View> - - <ContinueWatchingSection ref={continueWatchingRef} /> - - {/* Show catalogs as they load */} - {catalogs.map((catalog, index) => { - if (!catalog) { - // Show placeholder for loading catalog - return ( - <View key={`placeholder-${index}`} style={styles.catalogPlaceholder}> - <View style={styles.placeholderHeader}> - <View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} /> - <ActivityIndicator size="small" color={currentTheme.colors.primary} /> - </View> - <View style={styles.placeholderPosters}> - {[...Array(4)].map((_, posterIndex) => ( - <View - key={posterIndex} - style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]} - /> - ))} - </View> - </View> - ); - } - - return ( - <Animated.View - key={`${catalog.addon}-${catalog.id}-${index}`} - entering={FadeIn.duration(300)} - > - <CatalogSection catalog={catalog} /> - </Animated.View> - ); + ListFooterComponent={ListFooterComponent} + initialNumToRender={5} + maxToRenderPerBatch={5} + windowSize={11} + removeClippedSubviews={Platform.OS === 'android'} + onEndReachedThreshold={0.5} + updateCellsBatchingPeriod={50} + maintainVisibleContentPosition={{ + minIndexForVisible: 0, + autoscrollToTopThreshold: 10 + }} + getItemLayout={(data, index) => ({ + length: index === 0 ? 400 : 280, // Approximate heights for different item types + offset: index === 0 ? 0 : 400 + (index - 1) * 280, + index, })} - - {/* Show loading indicator for remaining catalogs */} - {catalogsLoading && catalogs.length < totalCatalogsRef.current && ( - <View style={styles.loadingMoreCatalogs}> - <ActivityIndicator size="small" color={currentTheme.colors.primary} /> - <Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}> - Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current}) - </Text> - </View> - )} - - {/* Show empty state only if all catalogs are loaded and none are available */} - {!catalogsLoading && catalogs.length === 0 && ( - <View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}> - <MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} /> - <Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}> - No content available - </Text> - <TouchableOpacity - style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]} - onPress={() => navigation.navigate('Settings')} - > - <MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} /> - <Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text> - </TouchableOpacity> - </View> - )} - </ScrollView> + /> </View> ); }, [ - isLoading, - currentTheme.colors, - showHeroSection, - featuredContent, - isSaved, - handleSaveToLibrary, - hasContinueWatching, - catalogs, - catalogsLoading, - navigation, - featuredContentSource + isLoading, + currentTheme.colors, + listData, + renderListItem, + ListFooterComponent ]); return isLoading ? renderLoadingScreen : renderMainContent; @@ -857,11 +660,8 @@ const calculatePosterLayout = (screenWidth: number) => { const usableWidth = availableWidth - 8; const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25); - console.log(`[HomeScreen] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`); - if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) { bestLayout = { numFullPosters: n, posterWidth }; - console.log(`[HomeScreen] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`); } } @@ -900,6 +700,8 @@ const styles = StyleSheet.create<any>({ padding: 16, marginHorizontal: 16, marginBottom: 16, + backgroundColor: 'rgba(0,0,0,0.2)', + borderRadius: 8, }, loadingMoreText: { marginLeft: 12, @@ -922,12 +724,14 @@ const styles = StyleSheet.create<any>({ }, placeholderPosters: { flexDirection: 'row', + paddingVertical: 8, gap: 8, }, placeholderPoster: { width: POSTER_WIDTH, aspectRatio: 2/3, borderRadius: 4, + marginRight: 2, }, emptyCatalog: { padding: 32, diff --git a/src/screens/InternalProvidersSettings.tsx b/src/screens/InternalProvidersSettings.tsx deleted file mode 100644 index 996d3d1..0000000 --- a/src/screens/InternalProvidersSettings.tsx +++ /dev/null @@ -1,491 +0,0 @@ -import React, { useState, useCallback, useEffect } from 'react'; -import { - View, - Text, - StyleSheet, - ScrollView, - SafeAreaView, - Platform, - TouchableOpacity, - StatusBar, - Switch, - Alert, -} from 'react-native'; -import { useNavigation } from '@react-navigation/native'; -import { useSettings } from '../hooks/useSettings'; -import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; -import { useTheme } from '../contexts/ThemeContext'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; - -interface SettingItemProps { - title: string; - description?: string; - icon: string; - value: boolean; - onValueChange: (value: boolean) => void; - isLast?: boolean; - badge?: string; -} - -const SettingItem: React.FC<SettingItemProps> = ({ - title, - description, - icon, - value, - onValueChange, - isLast, - badge, -}) => { - const { currentTheme } = useTheme(); - - return ( - <View - style={[ - styles.settingItem, - !isLast && styles.settingItemBorder, - { borderBottomColor: 'rgba(255,255,255,0.08)' }, - ]} - > - <View style={styles.settingContent}> - <View style={[ - styles.settingIconContainer, - { backgroundColor: 'rgba(255,255,255,0.1)' } - ]}> - <MaterialIcons - name={icon} - size={20} - color={currentTheme.colors.primary} - /> - </View> - <View style={styles.settingText}> - <View style={styles.titleRow}> - <Text - style={[ - styles.settingTitle, - { color: currentTheme.colors.text }, - ]} - > - {title} - </Text> - {badge && ( - <View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}> - <Text style={styles.badgeText}>{badge}</Text> - </View> - )} - </View> - {description && ( - <Text - style={[ - styles.settingDescription, - { color: currentTheme.colors.textMuted }, - ]} - > - {description} - </Text> - )} - </View> - <Switch - value={value} - onValueChange={onValueChange} - trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }} - thumbColor={Platform.OS === 'android' ? (value ? currentTheme.colors.white : currentTheme.colors.white) : ''} - ios_backgroundColor={'rgba(255,255,255,0.1)'} - /> - </View> - </View> - ); -}; - -const InternalProvidersSettings: React.FC = () => { - const { settings, updateSetting } = useSettings(); - const { currentTheme } = useTheme(); - const navigation = useNavigation(); - - // Individual provider states - const [hdrezkaEnabled, setHdrezkaEnabled] = useState(true); - - // Load individual provider settings - useEffect(() => { - const loadProviderSettings = async () => { - try { - const hdrezkaSettings = await AsyncStorage.getItem('hdrezka_settings'); - - if (hdrezkaSettings) { - const parsed = JSON.parse(hdrezkaSettings); - setHdrezkaEnabled(parsed.enabled !== false); - } - } catch (error) { - console.error('Error loading provider settings:', error); - } - }; - - loadProviderSettings(); - }, []); - - const handleBack = () => { - navigation.goBack(); - }; - - const handleMasterToggle = useCallback((enabled: boolean) => { - if (!enabled) { - Alert.alert( - 'Disable Internal Providers', - 'This will disable all built-in streaming providers (HDRezka). You can still use external Stremio addons.', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Disable', - style: 'destructive', - onPress: () => { - updateSetting('enableInternalProviders', false); - } - } - ] - ); - } else { - updateSetting('enableInternalProviders', true); - } - }, [updateSetting]); - - const handleHdrezkaToggle = useCallback(async (enabled: boolean) => { - setHdrezkaEnabled(enabled); - try { - await AsyncStorage.setItem('hdrezka_settings', JSON.stringify({ enabled })); - } catch (error) { - console.error('Error saving HDRezka settings:', error); - } - }, []); - - return ( - <SafeAreaView - style={[ - styles.container, - { backgroundColor: currentTheme.colors.darkBackground }, - ]} - > - <StatusBar - translucent - backgroundColor="transparent" - barStyle="light-content" - /> - - <View style={styles.header}> - <TouchableOpacity - onPress={handleBack} - style={styles.backButton} - activeOpacity={0.7} - > - <MaterialIcons - name="arrow-back" - size={24} - color={currentTheme.colors.text} - /> - </TouchableOpacity> - <Text - style={[ - styles.headerTitle, - { color: currentTheme.colors.text }, - ]} - > - Internal Providers - </Text> - </View> - - <ScrollView - style={styles.scrollView} - contentContainerStyle={styles.scrollContent} - > - {/* Master Toggle Section */} - <View style={styles.section}> - <Text - style={[ - styles.sectionTitle, - { color: currentTheme.colors.textMuted }, - ]} - > - MASTER CONTROL - </Text> - <View - style={[ - styles.card, - { backgroundColor: currentTheme.colors.elevation2 }, - ]} - > - <SettingItem - title="Enable Internal Providers" - description="Toggle all built-in streaming providers on/off" - icon="toggle-on" - value={settings.enableInternalProviders} - onValueChange={handleMasterToggle} - isLast={true} - /> - </View> - </View> - - {/* Individual Providers Section */} - {settings.enableInternalProviders && ( - <View style={styles.section}> - <Text - style={[ - styles.sectionTitle, - { color: currentTheme.colors.textMuted }, - ]} - > - INDIVIDUAL PROVIDERS - </Text> - <View - style={[ - styles.card, - { backgroundColor: currentTheme.colors.elevation2 }, - ]} - > - <SettingItem - title="HDRezka" - description="Popular streaming service with multiple quality options" - icon="hd" - value={hdrezkaEnabled} - onValueChange={handleHdrezkaToggle} - isLast={true} - /> - </View> - </View> - )} - - {/* Information Section */} - <View style={styles.section}> - <Text - style={[ - styles.sectionTitle, - { color: currentTheme.colors.textMuted }, - ]} - > - INFORMATION - </Text> - <View - style={[ - styles.infoCard, - { - backgroundColor: currentTheme.colors.elevation2, - borderColor: `${currentTheme.colors.primary}30` - }, - ]} - > - <MaterialIcons - name="info-outline" - size={24} - color={currentTheme.colors.primary} - style={styles.infoIcon} - /> - <View style={styles.infoContent}> - <Text - style={[ - styles.infoTitle, - { color: currentTheme.colors.text }, - ]} - > - About Internal Providers - </Text> - <Text - style={[ - styles.infoDescription, - { color: currentTheme.colors.textMuted }, - ]} - > - Internal providers are built directly into the app and don't require separate addon installation. They complement your Stremio addons by providing additional streaming sources. - </Text> - <View style={styles.featureList}> - <View style={styles.featureItem}> - <MaterialIcons - name="check-circle" - size={16} - color={currentTheme.colors.primary} - /> - <Text - style={[ - styles.featureText, - { color: currentTheme.colors.textMuted }, - ]} - > - No addon installation required - </Text> - </View> - <View style={styles.featureItem}> - <MaterialIcons - name="check-circle" - size={16} - color={currentTheme.colors.primary} - /> - <Text - style={[ - styles.featureText, - { color: currentTheme.colors.textMuted }, - ]} - > - Multiple quality options - </Text> - </View> - <View style={styles.featureItem}> - <MaterialIcons - name="check-circle" - size={16} - color={currentTheme.colors.primary} - /> - <Text - style={[ - styles.featureText, - { color: currentTheme.colors.textMuted }, - ]} - > - Fast and reliable streaming - </Text> - </View> - </View> - </View> - </View> - </View> - </ScrollView> - </SafeAreaView> - ); -}; - -const styles = StyleSheet.create({ - container: { - flex: 1, - }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16, - paddingBottom: 16, - }, - backButton: { - padding: 8, - marginRight: 8, - }, - headerTitle: { - fontSize: 24, - fontWeight: '700', - flex: 1, - }, - scrollView: { - flex: 1, - }, - scrollContent: { - padding: 16, - paddingBottom: 100, - }, - section: { - marginBottom: 24, - }, - sectionTitle: { - fontSize: 13, - fontWeight: '600', - letterSpacing: 0.8, - marginBottom: 8, - paddingHorizontal: 4, - }, - card: { - borderRadius: 16, - overflow: 'hidden', - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - settingItem: { - padding: 16, - borderBottomWidth: 0.5, - }, - settingItemBorder: { - borderBottomWidth: 0.5, - }, - settingContent: { - flexDirection: 'row', - alignItems: 'center', - }, - settingIconContainer: { - marginRight: 16, - width: 36, - height: 36, - borderRadius: 10, - alignItems: 'center', - justifyContent: 'center', - }, - settingText: { - flex: 1, - }, - titleRow: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 4, - }, - settingTitle: { - fontSize: 16, - fontWeight: '500', - }, - settingDescription: { - fontSize: 14, - opacity: 0.8, - lineHeight: 20, - }, - badge: { - height: 18, - minWidth: 18, - borderRadius: 9, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 6, - marginLeft: 8, - }, - badgeText: { - color: 'white', - fontSize: 10, - fontWeight: '600', - }, - infoCard: { - borderRadius: 16, - padding: 16, - flexDirection: 'row', - borderWidth: 1, - shadowColor: '#000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.1, - shadowRadius: 4, - elevation: 3, - }, - infoIcon: { - marginRight: 12, - marginTop: 2, - }, - infoContent: { - flex: 1, - }, - infoTitle: { - fontSize: 16, - fontWeight: '600', - marginBottom: 8, - }, - infoDescription: { - fontSize: 14, - lineHeight: 20, - marginBottom: 12, - }, - featureList: { - gap: 6, - }, - featureItem: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - featureText: { - fontSize: 14, - flex: 1, - }, -}); - -export default InternalProvidersSettings; \ No newline at end of file diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx index 22f61a2..9fe19c8 100644 --- a/src/screens/LibraryScreen.tsx +++ b/src/screens/LibraryScreen.tsx @@ -28,12 +28,17 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme } from '../contexts/ThemeContext'; import { useTraktContext } from '../contexts/TraktContext'; import TraktIcon from '../../assets/rating-icons/trakt.svg'; -import { traktService, TraktService } from '../services/traktService'; +import { traktService, TraktService, TraktImages } from '../services/traktService'; +import { TraktLoadingSpinner } from '../components/common/TraktLoadingSpinner'; // Define interfaces for proper typing interface LibraryItem extends StreamingContent { progress?: number; lastWatched?: string; + gradient: [string, string]; + imdbId?: string; + traktId: number; + images?: TraktImages; } interface TraktDisplayItem { @@ -47,6 +52,7 @@ interface TraktDisplayItem { rating?: number; imdbId?: string; traktId: number; + images?: TraktImages; } interface TraktFolder { @@ -60,6 +66,82 @@ interface TraktFolder { const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; +const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: TraktDisplayItem; width: number; navigation: any; currentTheme: any }) => { + const [posterUrl, setPosterUrl] = useState<string | null>(null); + + useEffect(() => { + let isMounted = true; + const fetchPoster = async () => { + if (item.images) { + const url = await TraktService.getTraktPosterUrlCached(item.images); + if (isMounted && url) { + setPosterUrl(url); + } + } + }; + fetchPoster(); + return () => { isMounted = false; }; + }, [item.images]); + + const handlePress = useCallback(() => { + if (item.imdbId) { + navigation.navigate('Metadata', { id: item.imdbId, type: item.type }); + } + }, [navigation, item.imdbId, item.type]); + + return ( + <TouchableOpacity + style={[styles.itemContainer, { width }]} + onPress={handlePress} + activeOpacity={0.7} + > + <View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}> + {posterUrl ? ( + <Image + source={{ uri: posterUrl }} + style={styles.poster} + contentFit="cover" + transition={300} + recyclingKey={`trakt-item-${item.id}`} + /> + ) : ( + <View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center' }]}> + <ActivityIndicator color={currentTheme.colors.primary} /> + </View> + )} + <LinearGradient + colors={['transparent', 'rgba(0,0,0,0.85)']} + style={styles.posterGradient} + > + <Text + style={[styles.itemTitle, { color: currentTheme.colors.white }]} + numberOfLines={2} + > + {item.name} + </Text> + {item.lastWatched && ( + <Text style={styles.lastWatched}> + Last watched: {item.lastWatched} + </Text> + )} + {item.plays && item.plays > 1 && ( + <Text style={styles.playsCount}> + {item.plays} plays + </Text> + )} + </LinearGradient> + + <View style={[styles.badgeContainer, { backgroundColor: 'rgba(232,37,75,0.9)' }]}> + <TraktIcon width={12} height={12} style={{ marginRight: 4 }} /> + <Text style={[styles.badgeText, { color: currentTheme.colors.white }]}> + {item.type === 'movie' ? 'Movie' : 'Series'} + </Text> + </View> + </View> + </TouchableOpacity> + ); +}); + const SkeletonLoader = () => { const pulseAnim = React.useRef(new RNAnimated.Value(0)).current; const { width } = useWindowDimensions(); @@ -168,7 +250,7 @@ const LibraryScreen = () => { setLoading(true); try { const items = await catalogService.getLibraryItems(); - setLibraryItems(items); + setLibraryItems(items as LibraryItem[]); } catch (error) { logger.error('Failed to load library:', error); } finally { @@ -180,7 +262,7 @@ const LibraryScreen = () => { // Subscribe to library updates const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => { - setLibraryItems(items); + setLibraryItems(items as LibraryItem[]); }); return () => { @@ -246,136 +328,6 @@ const LibraryScreen = () => { return folders.filter(folder => folder.itemCount > 0); }, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); - // State for poster URLs (since they're now async) - const [traktPostersMap, setTraktPostersMap] = useState<Map<string, string>>(new Map()); - - // Prepare Trakt items with placeholders, then load posters async - const traktItems = useMemo(() => { - if (!traktAuthenticated || (!watchedMovies?.length && !watchedShows?.length)) { - return []; - } - - const items: TraktDisplayItem[] = []; - - // Process watched movies - if (watchedMovies) { - for (const watchedMovie of watchedMovies) { - const movie = watchedMovie.movie; - if (movie) { - const itemId = String(movie.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - - items.push({ - id: itemId, - name: movie.title, - type: 'movie', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', - year: movie.year, - lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(), - plays: watchedMovie.plays, - imdbId: movie.ids.imdb, - traktId: movie.ids.trakt, - }); - } - } - } - - // Process watched shows - if (watchedShows) { - for (const watchedShow of watchedShows) { - const show = watchedShow.show; - if (show) { - const itemId = String(show.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - - items.push({ - id: itemId, - name: show.title, - type: 'series', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', - year: show.year, - lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(), - plays: watchedShow.plays, - imdbId: show.ids.imdb, - traktId: show.ids.trakt, - }); - } - } - } - - // Sort by last watched date (most recent first) - return items.sort((a, b) => { - const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0; - const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; - return dateB - dateA; - }); - }, [traktAuthenticated, watchedMovies, watchedShows, traktPostersMap]); - - // Effect to load cached poster URLs - useEffect(() => { - const loadCachedPosters = async () => { - if (!traktAuthenticated) return; - - const postersToLoad = new Map<string, any>(); - - // Collect movies that need posters - if (watchedMovies) { - for (const watchedMovie of watchedMovies) { - const movie = watchedMovie.movie; - if (movie) { - const itemId = String(movie.ids.trakt); - if (!traktPostersMap.has(itemId)) { - postersToLoad.set(itemId, movie.images); - } - } - } - } - - // Collect shows that need posters - if (watchedShows) { - for (const watchedShow of watchedShows) { - const show = watchedShow.show; - if (show) { - const itemId = String(show.ids.trakt); - if (!traktPostersMap.has(itemId)) { - postersToLoad.set(itemId, show.images); - } - } - } - } - - // Load posters in parallel - const posterPromises = Array.from(postersToLoad.entries()).map(async ([itemId, images]) => { - try { - const posterUrl = await TraktService.getTraktPosterUrl(images); - return { - itemId, - posterUrl: posterUrl || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster' - }; - } catch (error) { - logger.error(`Failed to get cached poster for ${itemId}:`, error); - return { - itemId, - posterUrl: 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster' - }; - } - }); - - const results = await Promise.all(posterPromises); - - // Update state with new posters - setTraktPostersMap(prevMap => { - const newMap = new Map(prevMap); - results.forEach(({ itemId, posterUrl }) => { - newMap.set(itemId, posterUrl); - }); - return newMap; - }); - }; - - loadCachedPosters(); - }, [traktAuthenticated, watchedMovies, watchedShows]); - const itemWidth = (width - 48) / 2; // 2 items per row with padding const renderItem = ({ item }: { item: LibraryItem }) => ( @@ -491,9 +443,9 @@ const LibraryScreen = () => { <Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}> Trakt Collection </Text> - {traktAuthenticated && traktItems.length > 0 && ( + {traktAuthenticated && traktFolders.length > 0 && ( <Text style={styles.folderCount}> - {traktItems.length} items + {traktFolders.length} items </Text> )} {!traktAuthenticated && ( @@ -514,59 +466,9 @@ const LibraryScreen = () => { </TouchableOpacity> ); - const renderTraktItem = ({ item, customWidth }: { item: TraktDisplayItem; customWidth?: number }) => { - const posterUrl = item.poster || 'https://via.placeholder.com/300x450/ff0000/ffffff?text=No+Poster'; - const width = customWidth || itemWidth; - - return ( - <TouchableOpacity - style={[styles.itemContainer, { width }]} - onPress={() => { - // Navigate using IMDB ID for Trakt items - if (item.imdbId) { - navigation.navigate('Metadata', { id: item.imdbId, type: item.type }); - } - }} - activeOpacity={0.7} - > - <View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}> - <Image - source={{ uri: posterUrl }} - style={styles.poster} - contentFit="cover" - transition={300} - /> - <LinearGradient - colors={['transparent', 'rgba(0,0,0,0.85)']} - style={styles.posterGradient} - > - <Text - style={[styles.itemTitle, { color: currentTheme.colors.white }]} - numberOfLines={2} - > - {item.name} - </Text> - <Text style={styles.lastWatched}> - Last watched: {item.lastWatched} - </Text> - {item.plays && item.plays > 1 && ( - <Text style={styles.playsCount}> - {item.plays} plays - </Text> - )} - </LinearGradient> - - {/* Trakt badge */} - <View style={[styles.badgeContainer, { backgroundColor: 'rgba(232,37,75,0.9)' }]}> - <TraktIcon width={12} height={12} style={{ marginRight: 4 }} /> - <Text style={[styles.badgeText, { color: currentTheme.colors.white }]}> - {item.type === 'movie' ? 'Movie' : 'Series'} - </Text> - </View> - </View> - </TouchableOpacity> - ); - }; + const renderTraktItem = useCallback(({ item }: { item: TraktDisplayItem }) => { + return <TraktItem item={item} width={itemWidth} navigation={navigation} currentTheme={currentTheme} />; + }, [itemWidth, navigation, currentTheme]); // Get items for a specific Trakt folder const getTraktFolderItems = useCallback((folderId: string): TraktDisplayItem[] => { @@ -579,19 +481,17 @@ const LibraryScreen = () => { for (const watchedMovie of watchedMovies) { const movie = watchedMovie.movie; if (movie) { - const itemId = String(movie.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - items.push({ - id: itemId, + id: String(movie.ids.trakt), name: movie.title, type: 'movie', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: 'placeholder', year: movie.year, lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(), plays: watchedMovie.plays, imdbId: movie.ids.imdb, traktId: movie.ids.trakt, + images: movie.images, }); } } @@ -601,19 +501,17 @@ const LibraryScreen = () => { for (const watchedShow of watchedShows) { const show = watchedShow.show; if (show) { - const itemId = String(show.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - items.push({ - id: itemId, + id: String(show.ids.trakt), name: show.title, type: 'series', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: 'placeholder', year: show.year, lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(), plays: watchedShow.plays, imdbId: show.ids.imdb, traktId: show.ids.trakt, + images: show.images, }); } } @@ -625,32 +523,28 @@ const LibraryScreen = () => { if (continueWatching) { for (const item of continueWatching) { if (item.type === 'movie' && item.movie) { - const itemId = String(item.movie.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - items.push({ - id: itemId, + id: String(item.movie.ids.trakt), name: item.movie.title, type: 'movie', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: 'placeholder', year: item.movie.year, lastWatched: new Date(item.paused_at).toLocaleDateString(), imdbId: item.movie.ids.imdb, traktId: item.movie.ids.trakt, + images: item.movie.images, }); } else if (item.type === 'episode' && item.show && item.episode) { - const itemId = String(item.show.ids.trakt); - const cachedPoster = traktPostersMap.get(itemId); - items.push({ id: `${item.show.ids.trakt}:${item.episode.season}:${item.episode.number}`, name: `${item.show.title} S${item.episode.season}E${item.episode.number}`, type: 'series', - poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...', + poster: 'placeholder', year: item.show.year, lastWatched: new Date(item.paused_at).toLocaleDateString(), imdbId: item.show.ids.imdb, traktId: item.show.ids.trakt, + images: item.show.images, }); } } @@ -663,19 +557,16 @@ const LibraryScreen = () => { for (const watchlistMovie of watchlistMovies) { const movie = watchlistMovie.movie; if (movie) { - const itemId = String(movie.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(movie.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(movie.ids.trakt), name: movie.title, type: 'movie', - poster: posterUrl, + poster: 'placeholder', year: movie.year, lastWatched: new Date(watchlistMovie.listed_at).toLocaleDateString(), imdbId: movie.ids.imdb, traktId: movie.ids.trakt, + images: movie.images, }); } } @@ -685,19 +576,16 @@ const LibraryScreen = () => { for (const watchlistShow of watchlistShows) { const show = watchlistShow.show; if (show) { - const itemId = String(show.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(show.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(show.ids.trakt), name: show.title, type: 'series', - poster: posterUrl, + poster: 'placeholder', year: show.year, lastWatched: new Date(watchlistShow.listed_at).toLocaleDateString(), imdbId: show.ids.imdb, traktId: show.ids.trakt, + images: show.images, }); } } @@ -710,19 +598,16 @@ const LibraryScreen = () => { for (const collectionMovie of collectionMovies) { const movie = collectionMovie.movie; if (movie) { - const itemId = String(movie.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(movie.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(movie.ids.trakt), name: movie.title, type: 'movie', - poster: posterUrl, + poster: 'placeholder', year: movie.year, lastWatched: new Date(collectionMovie.collected_at).toLocaleDateString(), imdbId: movie.ids.imdb, traktId: movie.ids.trakt, + images: movie.images, }); } } @@ -732,19 +617,16 @@ const LibraryScreen = () => { for (const collectionShow of collectionShows) { const show = collectionShow.show; if (show) { - const itemId = String(show.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(show.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(show.ids.trakt), name: show.title, type: 'series', - poster: posterUrl, + poster: 'placeholder', year: show.year, lastWatched: new Date(collectionShow.collected_at).toLocaleDateString(), imdbId: show.ids.imdb, traktId: show.ids.trakt, + images: show.images, }); } } @@ -757,37 +639,31 @@ const LibraryScreen = () => { for (const ratedItem of ratedContent) { if (ratedItem.movie) { const movie = ratedItem.movie; - const itemId = String(movie.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(movie.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(movie.ids.trakt), name: movie.title, type: 'movie', - poster: posterUrl, + poster: 'placeholder', year: movie.year, lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), rating: ratedItem.rating, imdbId: movie.ids.imdb, traktId: movie.ids.trakt, + images: movie.images, }); } else if (ratedItem.show) { const show = ratedItem.show; - const itemId = String(show.ids.trakt); - const posterUrl = TraktService.getTraktPosterUrl(show.images) || - 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'; - items.push({ - id: itemId, + id: String(show.ids.trakt), name: show.title, type: 'series', - poster: posterUrl, + poster: 'placeholder', year: show.year, lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(), rating: ratedItem.rating, imdbId: show.ids.imdb, traktId: show.ids.trakt, + images: show.images, }); } } @@ -801,11 +677,11 @@ const LibraryScreen = () => { const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0; return dateB - dateA; }); - }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent, traktPostersMap]); + }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]); const renderTraktContent = () => { if (traktLoading) { - return <SkeletonLoader />; + return <TraktLoadingSpinner />; } // If no specific folder is selected, show the folder structure @@ -880,70 +756,21 @@ const LibraryScreen = () => { ); } - // Separate movies and shows for the selected folder - const movies = folderItems.filter(item => item.type === 'movie'); - const shows = folderItems.filter(item => item.type === 'series'); - return ( - <ScrollView - style={styles.sectionsContainer} + <FlatList + data={folderItems} + renderItem={({ item }) => renderTraktItem({ item })} + keyExtractor={(item) => `${item.type}-${item.id}`} + numColumns={2} + columnWrapperStyle={styles.row} + style={styles.traktContainer} + contentContainerStyle={{ paddingBottom: insets.bottom + 80 }} showsVerticalScrollIndicator={false} - contentContainerStyle={styles.sectionsContent} - > - {movies.length > 0 && ( - <View style={styles.section}> - <View style={styles.sectionHeader}> - <MaterialIcons - name="movie" - size={24} - color={currentTheme.colors.white} - style={styles.sectionIcon} - /> - <Text style={[styles.sectionTitle, { color: currentTheme.colors.white }]}> - Movies ({movies.length}) - </Text> - </View> - <ScrollView - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalScrollContent} - > - {movies.map((item) => ( - <View key={item.id} style={{ width: itemWidth * 0.8, marginRight: 12 }}> - {renderTraktItem({ item, customWidth: itemWidth * 0.8 })} - </View> - ))} - </ScrollView> - </View> - )} - - {shows.length > 0 && ( - <View style={styles.section}> - <View style={styles.sectionHeader}> - <MaterialIcons - name="live-tv" - size={24} - color={currentTheme.colors.white} - style={styles.sectionIcon} - /> - <Text style={[styles.sectionTitle, { color: currentTheme.colors.white }]}> - TV Shows ({shows.length}) - </Text> - </View> - <ScrollView - horizontal - showsHorizontalScrollIndicator={false} - contentContainerStyle={styles.horizontalScrollContent} - > - {shows.map((item) => ( - <View key={item.id} style={{ width: itemWidth * 0.8, marginRight: 12 }}> - {renderTraktItem({ item, customWidth: itemWidth * 0.8 })} - </View> - ))} - </ScrollView> - </View> - )} - </ScrollView> + initialNumToRender={10} + maxToRenderPerBatch={10} + windowSize={21} + removeClippedSubviews={Platform.OS === 'android'} + /> ); }; @@ -1013,7 +840,7 @@ const LibraryScreen = () => { backgroundColor: currentTheme.colors.primary, shadowColor: currentTheme.colors.black }]} - onPress={() => navigation.navigate('Discover')} + onPress={() => navigation.navigate('MainTabs')} activeOpacity={0.7} > <Text style={[styles.exploreButtonText, { color: currentTheme.colors.white }]}>Explore Content</Text> @@ -1056,7 +883,7 @@ const LibraryScreen = () => { <View style={{ flex: 1 }}> {/* Header Section with proper top spacing */} <View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}> - <View style={styles.headerContent}> + <View style={[styles.headerContent, showTraktContent && { justifyContent: 'flex-start' }]}> {showTraktContent ? ( <> <TouchableOpacity @@ -1076,18 +903,28 @@ const LibraryScreen = () => { color={currentTheme.colors.white} /> </TouchableOpacity> - <View style={styles.headerTitleContainer}> - <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}> + <Text style={[styles.headerTitle, { color: currentTheme.colors.white, fontSize: 24, marginLeft: 16 }]}> {selectedTraktFolder ? traktFolders.find(f => f.id === selectedTraktFolder)?.name || 'Collection' : 'Trakt Collection' } - </Text> - </View> - <View style={styles.headerSpacer} /> + </Text> </> ) : ( - <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text> + <> + <Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Library</Text> + <TouchableOpacity + style={[styles.calendarButton, { backgroundColor: currentTheme.colors.primary }]} + onPress={() => navigation.navigate('Calendar')} + activeOpacity={0.7} + > + <MaterialIcons + name="event" + size={24} + color={currentTheme.colors.white} + /> + </TouchableOpacity> + </> )} </View> </View> @@ -1387,6 +1224,28 @@ const styles = StyleSheet.create({ headerSpacer: { width: 44, // Match the back button width }, + traktContainer: { + flex: 1, + }, + emptyListText: { + fontSize: 16, + fontWeight: '500', + }, + row: { + justifyContent: 'space-between', + paddingHorizontal: 16, + }, + calendarButton: { + width: 44, + height: 44, + borderRadius: 22, + justifyContent: 'center', + alignItems: 'center', + elevation: 3, + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + }, }); export default LibraryScreen; \ No newline at end of file diff --git a/src/screens/MDBListSettingsScreen.tsx b/src/screens/MDBListSettingsScreen.tsx index 93126f5..6634d1a 100644 --- a/src/screens/MDBListSettingsScreen.tsx +++ b/src/screens/MDBListSettingsScreen.tsx @@ -32,10 +32,10 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; export const isMDBListEnabled = async (): Promise<boolean> => { try { const enabledSetting = await AsyncStorage.getItem(MDBLIST_ENABLED_STORAGE_KEY); - return enabledSetting === null || enabledSetting === 'true'; + return enabledSetting === 'true'; } catch (error) { logger.error('[MDBList] Error checking if MDBList is enabled:', error); - return true; // Default to enabled if there's an error + return false; // Default to disabled if there's an error } }; @@ -364,7 +364,7 @@ const MDBListSettingsScreen = () => { const [apiKey, setApiKey] = useState(''); const [isLoading, setIsLoading] = useState(true); const [isKeySet, setIsKeySet] = useState(false); - const [isMdbListEnabled, setIsMdbListEnabled] = useState(true); + const [isMdbListEnabled, setIsMdbListEnabled] = useState(false); const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null); const [isInputFocused, setIsInputFocused] = useState(false); const [enabledProviders, setEnabledProviders] = useState<Record<string, boolean>>({}); @@ -388,14 +388,14 @@ const MDBListSettingsScreen = () => { setIsMdbListEnabled(savedSetting === 'true'); logger.log('[MDBListSettingsScreen] MDBList enabled setting:', savedSetting === 'true'); } else { - // Default to enabled if no setting found - setIsMdbListEnabled(true); - await AsyncStorage.setItem(MDBLIST_ENABLED_STORAGE_KEY, 'true'); - logger.log('[MDBListSettingsScreen] MDBList enabled setting not found, defaulting to true'); + // Default to disabled if no setting found + setIsMdbListEnabled(false); + await AsyncStorage.setItem(MDBLIST_ENABLED_STORAGE_KEY, 'false'); + logger.log('[MDBListSettingsScreen] MDBList enabled setting not found, defaulting to false'); } } catch (error) { logger.error('[MDBListSettingsScreen] Failed to load MDBList enabled setting:', error); - setIsMdbListEnabled(true); + setIsMdbListEnabled(false); } }; diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx index d55d06b..cd2933d 100644 --- a/src/screens/MetadataScreen.tsx +++ b/src/screens/MetadataScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect, useMemo } from 'react'; +import React, { useCallback, useState, useEffect, useMemo, useRef } from 'react'; import { View, Text, @@ -15,6 +15,7 @@ import * as Haptics from 'expo-haptics'; import { useTheme } from '../contexts/ThemeContext'; import { useMetadata } from '../hooks/useMetadata'; import { CastSection } from '../components/metadata/CastSection'; +import { CastDetailsModal } from '../components/metadata/CastDetailsModal'; import { SeriesContent } from '../components/metadata/SeriesContent'; import { MovieContent } from '../components/metadata/MovieContent'; import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'; @@ -56,9 +57,9 @@ const MetadataScreen: React.FC = () => { // Optimized state management - reduced state variables const [isContentReady, setIsContentReady] = useState(false); - const [showSkeleton, setShowSkeleton] = useState(true); - const transitionOpacity = useSharedValue(0); - const skeletonOpacity = useSharedValue(1); + const [showCastModal, setShowCastModal] = useState(false); + const [selectedCastMember, setSelectedCastMember] = useState<any>(null); + const transitionOpacity = useSharedValue(1); const { metadata, @@ -187,26 +188,14 @@ const MetadataScreen: React.FC = () => { // Memoized derived values for performance const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]); - // Smooth skeleton to content transition + // Simple content ready state management useEffect(() => { - if (isReady && !isContentReady) { - // Small delay to ensure skeleton is rendered before starting transition - setTimeout(() => { - // Start fade out skeleton and fade in content simultaneously - skeletonOpacity.value = withTiming(0, { duration: 300 }); - transitionOpacity.value = withTiming(1, { duration: 400 }); - - // Hide skeleton after fade out completes - setTimeout(() => { - setShowSkeleton(false); - setIsContentReady(true); - }, 300); - }, 100); + if (isReady) { + setIsContentReady(true); + transitionOpacity.value = withTiming(1, { duration: 50 }); } else if (!isReady && isContentReady) { setIsContentReady(false); - setShowSkeleton(true); transitionOpacity.value = 0; - skeletonOpacity.value = 1; } }, [isReady, isContentReady]); @@ -223,25 +212,111 @@ const MetadataScreen: React.FC = () => { const handleShowStreams = useCallback(() => { const { watchProgress } = watchProgressData; + + // Helper to build episodeId from episode object + const buildEpisodeId = (ep: any): string => { + return ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; + }; + if (type === 'series') { - const targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? - (episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined); - + // Determine if current episode is finished + let progressPercent = 0; + if (watchProgress && watchProgress.duration > 0) { + progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; + } + + let targetEpisodeId: string | undefined; + + if (progressPercent >= 85 && watchProgress?.episodeId) { + // Try to navigate to next episode – support multiple episodeId formats + let currentSeason: number | null = null; + let currentEpisode: number | null = null; + + const parts = watchProgress.episodeId.split(':'); + + if (parts.length === 3) { + // showId:season:episode + currentSeason = parseInt(parts[1], 10); + currentEpisode = parseInt(parts[2], 10); + } else if (parts.length === 2) { + // season:episode + currentSeason = parseInt(parts[0], 10); + currentEpisode = parseInt(parts[1], 10); + } else { + // pattern like s5e01 + const match = watchProgress.episodeId.match(/s(\d+)e(\d+)/i); + if (match) { + currentSeason = parseInt(match[1], 10); + currentEpisode = parseInt(match[2], 10); + } + } + + if (currentSeason !== null && currentEpisode !== null) { + // DIRECT APPROACH: Just create the next episode ID directly + // This ensures we navigate to the next episode even if it's not yet in our episodes array + const nextEpisodeId = `${id}:${currentSeason}:${currentEpisode + 1}`; + console.log(`[MetadataScreen] Created next episode ID directly: ${nextEpisodeId}`); + + // Still try to find the episode in our list to verify it exists + const nextEpisodeExists = episodes.some(ep => + ep.season_number === currentSeason && ep.episode_number === (currentEpisode + 1) + ); + + if (nextEpisodeExists) { + console.log(`[MetadataScreen] Verified next episode S${currentSeason}E${currentEpisode + 1} exists in episodes list`); + } else { + console.log(`[MetadataScreen] Warning: Next episode S${currentSeason}E${currentEpisode + 1} not found in episodes list, but proceeding anyway`); + } + + targetEpisodeId = nextEpisodeId; + } + } + + // Fallback logic: if not finished or nextEp not found + if (!targetEpisodeId) { + targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ? buildEpisodeId(episodes[0]) : undefined); + console.log(`[MetadataScreen] Using fallback episode ID: ${targetEpisodeId}`); + } + if (targetEpisodeId) { - navigation.navigate('Streams', { id, type, episodeId: targetEpisodeId }); + // Ensure the episodeId has showId prefix (id:season:episode) + const epParts = targetEpisodeId.split(':'); + let normalizedEpisodeId = targetEpisodeId; + if (epParts.length === 2) { + normalizedEpisodeId = `${id}:${epParts[0]}:${epParts[1]}`; + } + console.log(`[MetadataScreen] Navigating to streams with episodeId: ${normalizedEpisodeId}`); + navigation.navigate('Streams', { id, type, episodeId: normalizedEpisodeId }); return; } } - navigation.navigate('Streams', { id, type, episodeId }); + + // Normalize fallback episodeId too + let fallbackEpisodeId = episodeId; + if (episodeId && episodeId.split(':').length === 2) { + const p = episodeId.split(':'); + fallbackEpisodeId = `${id}:${p[0]}:${p[1]}`; + } + console.log(`[MetadataScreen] Navigating with fallback episodeId: ${fallbackEpisodeId}`); + navigation.navigate('Streams', { id, type, episodeId: fallbackEpisodeId }); }, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]); const handleEpisodeSelect = useCallback((episode: Episode) => { + console.log('[MetadataScreen] Selected Episode:', JSON.stringify(episode, null, 2)); const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; - navigation.navigate('Streams', { id, type, episodeId }); + navigation.navigate('Streams', { + id, + type, + episodeId, + episodeThumbnail: episode.still_path || undefined + }); }, [navigation, id, type]); const handleBack = useCallback(() => navigation.goBack(), [navigation]); - const handleSelectCastMember = useCallback(() => {}, []); // Simplified for performance + const handleSelectCastMember = useCallback((castMember: any) => { + setSelectedCastMember(castMember); + setShowCastModal(true); + }, []); // Ultra-optimized animated styles - minimal calculations const containerStyle = useAnimatedStyle(() => ({ @@ -257,10 +332,6 @@ const MetadataScreen: React.FC = () => { opacity: transitionOpacity.value, }), []); - const skeletonStyle = useAnimatedStyle(() => ({ - opacity: skeletonOpacity.value, - }), []); - // Memoized error component for performance const ErrorComponent = useMemo(() => { if (!metadataError) return null; @@ -299,123 +370,123 @@ const MetadataScreen: React.FC = () => { return ErrorComponent; } - return ( - <View style={StyleSheet.absoluteFill}> - {/* Skeleton Loading Screen - with fade out transition */} - {showSkeleton && ( - <Animated.View - style={[StyleSheet.absoluteFill, skeletonStyle]} - pointerEvents={metadata ? 'none' : 'auto'} - > - <MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} /> - </Animated.View> - )} + // Show loading screen if metadata is not yet available + if (loading || !isContentReady) { + return <MetadataLoadingScreen type={type as 'movie' | 'series'} />; + } - {/* Main Content - with fade in transition */} + return ( + <SafeAreaView + style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} + edges={['bottom']} + > + <StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated /> + {metadata && ( - <Animated.View - style={[StyleSheet.absoluteFill, transitionStyle]} - pointerEvents={metadata ? 'auto' : 'none'} - > - <SafeAreaView - style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]} - edges={['bottom']} + <> + {/* Floating Header - Optimized */} + <FloatingHeader + metadata={metadata} + logoLoadError={assetData.logoLoadError} + handleBack={handleBack} + handleToggleLibrary={handleToggleLibrary} + headerElementsY={animations.headerElementsY} + inLibrary={inLibrary} + headerOpacity={animations.headerOpacity} + headerElementsOpacity={animations.headerElementsOpacity} + safeAreaTop={safeAreaTop} + setLogoLoadError={assetData.setLogoLoadError} + /> + + <Animated.ScrollView + style={styles.scrollView} + showsVerticalScrollIndicator={false} + onScroll={animations.scrollHandler} + scrollEventThrottle={16} + bounces={false} + overScrollMode="never" + contentContainerStyle={styles.scrollContent} > - <StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated /> - - {/* Floating Header - Optimized */} - <FloatingHeader + {/* Hero Section - Optimized */} + <HeroSection metadata={metadata} + bannerImage={assetData.bannerImage} + loadingBanner={assetData.loadingBanner} logoLoadError={assetData.logoLoadError} - handleBack={handleBack} + scrollY={animations.scrollY} + heroHeight={animations.heroHeight} + heroOpacity={animations.heroOpacity} + logoOpacity={animations.logoOpacity} + buttonsOpacity={animations.buttonsOpacity} + buttonsTranslateY={animations.buttonsTranslateY} + watchProgressOpacity={animations.watchProgressOpacity} + watchProgressWidth={animations.watchProgressWidth} + watchProgress={watchProgressData.watchProgress} + type={type as 'movie' | 'series'} + getEpisodeDetails={watchProgressData.getEpisodeDetails} + handleShowStreams={handleShowStreams} handleToggleLibrary={handleToggleLibrary} - headerElementsY={animations.headerElementsY} inLibrary={inLibrary} - headerOpacity={animations.headerOpacity} - headerElementsOpacity={animations.headerElementsOpacity} - safeAreaTop={safeAreaTop} + id={id} + navigation={navigation} + getPlayButtonText={watchProgressData.getPlayButtonText} + setBannerImage={assetData.setBannerImage} setLogoLoadError={assetData.setLogoLoadError} + groupedEpisodes={groupedEpisodes} /> - <Animated.ScrollView - style={styles.scrollView} - showsVerticalScrollIndicator={false} - onScroll={animations.scrollHandler} - scrollEventThrottle={16} - bounces={false} - overScrollMode="never" - contentContainerStyle={styles.scrollContent} - > - {/* Hero Section - Optimized */} - <HeroSection + {/* Main Content - Optimized */} + <Animated.View style={contentStyle}> + <MetadataDetails metadata={metadata} - bannerImage={assetData.bannerImage} - loadingBanner={assetData.loadingBanner} - logoLoadError={assetData.logoLoadError} - scrollY={animations.scrollY} - heroHeight={animations.heroHeight} - heroOpacity={animations.heroOpacity} - logoOpacity={animations.logoOpacity} - buttonsOpacity={animations.buttonsOpacity} - buttonsTranslateY={animations.buttonsTranslateY} - watchProgressOpacity={animations.watchProgressOpacity} - watchProgressWidth={animations.watchProgressWidth} - watchProgress={watchProgressData.watchProgress} + imdbId={imdbId} type={type as 'movie' | 'series'} - getEpisodeDetails={watchProgressData.getEpisodeDetails} - handleShowStreams={handleShowStreams} - handleToggleLibrary={handleToggleLibrary} - inLibrary={inLibrary} - id={id} - navigation={navigation} - getPlayButtonText={watchProgressData.getPlayButtonText} - setBannerImage={assetData.setBannerImage} - setLogoLoadError={assetData.setLogoLoadError} + renderRatings={() => imdbId ? ( + <RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} /> + ) : null} /> - {/* Main Content - Optimized */} - <Animated.View style={contentStyle}> - <MetadataDetails - metadata={metadata} - imdbId={imdbId} - type={type as 'movie' | 'series'} - renderRatings={() => imdbId ? ( - <RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} /> - ) : null} + {/* Cast Section with skeleton when loading */} + <CastSection + cast={cast} + loadingCast={loadingCast} + onSelectCastMember={handleSelectCastMember} + /> + + {/* Recommendations Section with skeleton when loading */} + {type === 'movie' && ( + <MoreLikeThisSection + recommendations={recommendations} + loadingRecommendations={loadingRecommendations} /> + )} - <CastSection - cast={cast} - loadingCast={loadingCast} - onSelectCastMember={handleSelectCastMember} + {/* Series/Movie Content with episode skeleton when loading */} + {type === 'series' ? ( + <SeriesContent + episodes={Object.values(groupedEpisodes).flat()} + selectedSeason={selectedSeason} + loadingSeasons={loadingSeasons} + onSeasonChange={handleSeasonChangeWithHaptics} + onSelectEpisode={handleEpisodeSelect} + groupedEpisodes={groupedEpisodes} + metadata={metadata || undefined} /> - - {type === 'movie' && ( - <MoreLikeThisSection - recommendations={recommendations} - loadingRecommendations={loadingRecommendations} - /> - )} - - {type === 'series' ? ( - <SeriesContent - episodes={episodes} - selectedSeason={selectedSeason} - loadingSeasons={loadingSeasons} - onSeasonChange={handleSeasonChangeWithHaptics} - onSelectEpisode={handleEpisodeSelect} - groupedEpisodes={groupedEpisodes} - metadata={metadata || undefined} - /> - ) : ( - metadata && <MovieContent metadata={metadata} /> - )} - </Animated.View> - </Animated.ScrollView> - </SafeAreaView> - </Animated.View> + ) : ( + metadata && <MovieContent metadata={metadata} /> + )} + </Animated.View> + </Animated.ScrollView> + </> )} - </View> + + {/* Cast Details Modal */} + <CastDetailsModal + visible={showCastModal} + onClose={() => setShowCastModal(false)} + castMember={selectedCastMember} + /> + </SafeAreaView> ); }; @@ -466,6 +537,44 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '600', }, + // Skeleton loading styles + skeletonSection: { + padding: 16, + marginBottom: 24, + }, + skeletonTitle: { + width: 150, + height: 20, + borderRadius: 4, + marginBottom: 16, + }, + skeletonCastRow: { + flexDirection: 'row', + gap: 12, + }, + skeletonCastItem: { + width: 80, + height: 120, + borderRadius: 8, + }, + skeletonRecommendationsRow: { + flexDirection: 'row', + gap: 12, + }, + skeletonRecommendationItem: { + width: 120, + height: 180, + borderRadius: 8, + }, + skeletonEpisodesContainer: { + gap: 12, + }, + skeletonEpisodeItem: { + width: '100%', + height: 80, + borderRadius: 8, + marginBottom: 8, + }, }); export default MetadataScreen; \ No newline at end of file diff --git a/src/screens/NotificationSettingsScreen.tsx b/src/screens/NotificationSettingsScreen.tsx index dda3dca..469c2dd 100644 --- a/src/screens/NotificationSettingsScreen.tsx +++ b/src/screens/NotificationSettingsScreen.tsx @@ -9,6 +9,7 @@ import { Alert, SafeAreaView, StatusBar, + Platform, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; @@ -17,6 +18,8 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useNavigation } from '@react-navigation/native'; import { logger } from '../utils/logger'; +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + const NotificationSettingsScreen = () => { const navigation = useNavigation(); const { currentTheme } = useTheme(); @@ -30,13 +33,19 @@ const NotificationSettingsScreen = () => { const [loading, setLoading] = useState(true); const [countdown, setCountdown] = useState<number | null>(null); const [testNotificationId, setTestNotificationId] = useState<string | null>(null); + const [isSyncing, setIsSyncing] = useState(false); + const [notificationStats, setNotificationStats] = useState({ total: 0, upcoming: 0, thisWeek: 0 }); - // Load settings on mount + // Load settings and stats on mount useEffect(() => { const loadSettings = async () => { try { const savedSettings = await notificationService.getSettings(); setSettings(savedSettings); + + // Load notification stats + const stats = notificationService.getNotificationStats(); + setNotificationStats(stats); } catch (error) { logger.error('Error loading notification settings:', error); } finally { @@ -47,6 +56,14 @@ const NotificationSettingsScreen = () => { loadSettings(); }, []); + // Refresh stats when settings change + useEffect(() => { + if (!loading) { + const stats = notificationService.getNotificationStats(); + setNotificationStats(stats); + } + }, [settings, loading]); + // Add countdown effect useEffect(() => { let intervalId: NodeJS.Timeout; @@ -122,6 +139,29 @@ const NotificationSettingsScreen = () => { ); }; + const handleSyncNotifications = async () => { + if (isSyncing) return; + + setIsSyncing(true); + try { + await notificationService.syncAllNotifications(); + + // Refresh stats after sync + const stats = notificationService.getNotificationStats(); + setNotificationStats(stats); + + Alert.alert( + 'Sync Complete', + `Successfully synced notifications for your library and Trakt items.\n\nScheduled: ${stats.upcoming} upcoming episodes\nThis week: ${stats.thisWeek} episodes` + ); + } catch (error) { + logger.error('Error syncing notifications:', error); + Alert.alert('Error', 'Failed to sync notifications. Please try again.'); + } finally { + setIsSyncing(false); + } + }; + const handleTestNotification = async () => { try { // Cancel previous test notification if exists @@ -295,6 +335,54 @@ const NotificationSettingsScreen = () => { </View> </View> + <View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}> + <Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Status</Text> + + <View style={[styles.statsContainer, { backgroundColor: currentTheme.colors.elevation1 }]}> + <View style={styles.statItem}> + <MaterialIcons name="schedule" size={20} color={currentTheme.colors.primary} /> + <Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>Upcoming</Text> + <Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.upcoming}</Text> + </View> + <View style={styles.statItem}> + <MaterialIcons name="today" size={20} color={currentTheme.colors.primary} /> + <Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>This Week</Text> + <Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.thisWeek}</Text> + </View> + <View style={styles.statItem}> + <MaterialIcons name="notifications-active" size={20} color={currentTheme.colors.primary} /> + <Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>Total</Text> + <Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.total}</Text> + </View> + </View> + + <TouchableOpacity + style={[ + styles.resetButton, + { + backgroundColor: currentTheme.colors.primary + '20', + borderColor: currentTheme.colors.primary + '50' + } + ]} + onPress={handleSyncNotifications} + disabled={isSyncing} + > + <MaterialIcons + name={isSyncing ? "sync" : "sync"} + size={24} + color={currentTheme.colors.primary} + style={isSyncing ? { transform: [{ rotate: '360deg' }] } : {}} + /> + <Text style={[styles.resetButtonText, { color: currentTheme.colors.primary }]}> + {isSyncing ? 'Syncing...' : 'Sync Library & Trakt'} + </Text> + </TouchableOpacity> + + <Text style={[styles.resetDescription, { color: currentTheme.colors.lightGray }]}> + Automatically syncs notifications for all shows in your library and Trakt watchlist/collection. + </Text> + </View> + <View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}> <Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Advanced</Text> @@ -368,6 +456,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 12, borderBottomWidth: 1, }, backButton: { @@ -465,6 +554,27 @@ const styles = StyleSheet.create({ countdownText: { fontSize: 14, }, + statsContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + padding: 16, + borderRadius: 8, + marginBottom: 16, + }, + statItem: { + alignItems: 'center', + flex: 1, + }, + statLabel: { + fontSize: 12, + marginTop: 4, + textAlign: 'center', + }, + statValue: { + fontSize: 18, + fontWeight: 'bold', + marginTop: 2, + }, }); export default NotificationSettingsScreen; \ No newline at end of file diff --git a/src/screens/OnboardingScreen.tsx b/src/screens/OnboardingScreen.tsx new file mode 100644 index 0000000..e81f18a --- /dev/null +++ b/src/screens/OnboardingScreen.tsx @@ -0,0 +1,373 @@ +import React, { useState, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + Dimensions, + TouchableOpacity, + FlatList, + Image, + StatusBar, + Platform, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { MaterialIcons } from '@expo/vector-icons'; +import { LinearGradient } from 'expo-linear-gradient'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + withTiming, + FadeInDown, + FadeInUp, +} from 'react-native-reanimated'; +import { useTheme } from '../contexts/ThemeContext'; +import { NavigationProp, useNavigation } from '@react-navigation/native'; +import { RootStackParamList } from '../navigation/AppNavigator'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +const { width, height } = Dimensions.get('window'); + +interface OnboardingSlide { + id: string; + title: string; + subtitle: string; + description: string; + icon: keyof typeof MaterialIcons.glyphMap; + gradient: [string, string]; +} + +const onboardingData: OnboardingSlide[] = [ + { + id: '1', + title: 'Welcome to Nuvio', + subtitle: 'Your Ultimate Content Hub', + description: 'Discover, organize, and manage your favorite movies and TV shows from multiple sources in one beautiful app.', + icon: 'play-circle-filled', + gradient: ['#667eea', '#764ba2'], + }, + { + id: '2', + title: 'Powerful Addons', + subtitle: 'Extend Your Experience', + description: 'Install addons to access content from various platforms and services. Choose what works best for you.', + icon: 'extension', + gradient: ['#f093fb', '#f5576c'], + }, + { + id: '3', + title: 'Smart Discovery', + subtitle: 'Find What You Love', + description: 'Browse trending content, search across all your sources, and get personalized recommendations.', + icon: 'explore', + gradient: ['#4facfe', '#00f2fe'], + }, + { + id: '4', + title: 'Your Library', + subtitle: 'Track & Organize', + description: 'Save favorites, track your progress, and sync with Trakt to keep everything organized across devices.', + icon: 'library-books', + gradient: ['#43e97b', '#38f9d7'], + }, +]; + +const OnboardingScreen = () => { + const { currentTheme } = useTheme(); + const navigation = useNavigation<NavigationProp<RootStackParamList>>(); + const [currentIndex, setCurrentIndex] = useState(0); + const flatListRef = useRef<FlatList>(null); + const progressValue = useSharedValue(0); + + const animatedProgressStyle = useAnimatedStyle(() => ({ + width: withSpring(`${((currentIndex + 1) / onboardingData.length) * 100}%`), + })); + + const handleNext = () => { + if (currentIndex < onboardingData.length - 1) { + const nextIndex = currentIndex + 1; + setCurrentIndex(nextIndex); + flatListRef.current?.scrollToIndex({ index: nextIndex, animated: true }); + progressValue.value = (nextIndex + 1) / onboardingData.length; + } else { + handleGetStarted(); + } + }; + + const handleSkip = () => { + handleGetStarted(); + }; + + const handleGetStarted = async () => { + try { + await AsyncStorage.setItem('hasCompletedOnboarding', 'true'); + navigation.reset({ + index: 0, + routes: [{ name: 'MainTabs' }], + }); + } catch (error) { + console.error('Error saving onboarding status:', error); + navigation.reset({ + index: 0, + routes: [{ name: 'MainTabs' }], + }); + } + }; + + const renderSlide = ({ item, index }: { item: OnboardingSlide; index: number }) => { + const isActive = index === currentIndex; + + return ( + <View style={styles.slide}> + <LinearGradient + colors={item.gradient} + style={styles.iconContainer} + start={{ x: 0, y: 0 }} + end={{ x: 1, y: 1 }} + > + <Animated.View + entering={FadeInDown.delay(300).duration(800)} + style={styles.iconWrapper} + > + <MaterialIcons + name={item.icon} + size={80} + color="white" + /> + </Animated.View> + </LinearGradient> + + <Animated.View + entering={FadeInUp.delay(500).duration(800)} + style={styles.textContainer} + > + <Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}> + {item.title} + </Text> + <Text style={[styles.subtitle, { color: currentTheme.colors.primary }]}> + {item.subtitle} + </Text> + <Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}> + {item.description} + </Text> + </Animated.View> + </View> + ); + }; + + const renderPagination = () => ( + <View style={styles.pagination}> + {onboardingData.map((_, index) => ( + <View + key={index} + style={[ + styles.paginationDot, + { + backgroundColor: index === currentIndex + ? currentTheme.colors.primary + : currentTheme.colors.elevation2, + opacity: index === currentIndex ? 1 : 0.4, + }, + ]} + /> + ))} + </View> + ); + + return ( + <SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> + <StatusBar barStyle="light-content" backgroundColor={currentTheme.colors.darkBackground} /> + + {/* Header */} + <View style={styles.header}> + <TouchableOpacity onPress={handleSkip} style={styles.skipButton}> + <Text style={[styles.skipText, { color: currentTheme.colors.mediumEmphasis }]}> + Skip + </Text> + </TouchableOpacity> + + {/* Progress Bar */} + <View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}> + <Animated.View + style={[ + styles.progressBar, + { backgroundColor: currentTheme.colors.primary }, + animatedProgressStyle + ]} + /> + </View> + </View> + + {/* Content */} + <FlatList + ref={flatListRef} + data={onboardingData} + renderItem={renderSlide} + horizontal + pagingEnabled + showsHorizontalScrollIndicator={false} + keyExtractor={(item) => item.id} + onMomentumScrollEnd={(event) => { + const slideIndex = Math.round(event.nativeEvent.contentOffset.x / width); + setCurrentIndex(slideIndex); + }} + style={{ flex: 1 }} + /> + + {/* Footer */} + <View style={styles.footer}> + {renderPagination()} + + <View style={styles.buttonContainer}> + <TouchableOpacity + style={[ + styles.button, + styles.nextButton, + { backgroundColor: currentTheme.colors.primary } + ]} + onPress={handleNext} + > + <Text style={[styles.buttonText, { color: 'white' }]}> + {currentIndex === onboardingData.length - 1 ? 'Get Started' : 'Next'} + </Text> + <MaterialIcons + name={currentIndex === onboardingData.length - 1 ? 'check' : 'arrow-forward'} + size={20} + color="white" + style={styles.buttonIcon} + /> + </TouchableOpacity> + </View> + </View> + </SafeAreaView> + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: Platform.OS === 'ios' ? 10 : 20, + paddingBottom: 20, + }, + skipButton: { + padding: 10, + }, + skipText: { + fontSize: 16, + fontWeight: '500', + }, + progressContainer: { + flex: 1, + height: 4, + borderRadius: 2, + marginHorizontal: 20, + overflow: 'hidden', + }, + progressBar: { + height: '100%', + borderRadius: 2, + }, + slide: { + width, + height: '100%', + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 40, + }, + iconContainer: { + width: 160, + height: 160, + borderRadius: 80, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 60, + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 10, + }, + shadowOpacity: 0.3, + shadowRadius: 20, + elevation: 15, + }, + iconWrapper: { + alignItems: 'center', + justifyContent: 'center', + }, + textContainer: { + alignItems: 'center', + paddingHorizontal: 20, + }, + title: { + fontSize: 28, + fontWeight: 'bold', + textAlign: 'center', + marginBottom: 8, + }, + subtitle: { + fontSize: 18, + fontWeight: '600', + textAlign: 'center', + marginBottom: 16, + }, + description: { + fontSize: 16, + textAlign: 'center', + lineHeight: 24, + maxWidth: 280, + }, + footer: { + paddingHorizontal: 20, + paddingBottom: Platform.OS === 'ios' ? 40 : 20, + }, + pagination: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + marginBottom: 40, + }, + paginationDot: { + width: 8, + height: 8, + borderRadius: 4, + marginHorizontal: 4, + }, + buttonContainer: { + alignItems: 'center', + }, + button: { + borderRadius: 30, + paddingVertical: 16, + paddingHorizontal: 32, + minWidth: 160, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + shadowColor: '#000', + shadowOffset: { + width: 0, + height: 4, + }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }, + nextButton: { + // Additional styles for next button can go here + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + }, + buttonIcon: { + marginLeft: 8, + }, +}); + +export default OnboardingScreen; \ No newline at end of file diff --git a/src/screens/ScraperSettingsScreen.tsx b/src/screens/ScraperSettingsScreen.tsx new file mode 100644 index 0000000..becc7b9 --- /dev/null +++ b/src/screens/ScraperSettingsScreen.tsx @@ -0,0 +1,948 @@ +import React, { useState, useEffect } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + Alert, + Switch, + TextInput, + ScrollView, + RefreshControl, + StatusBar, + Platform, + Image, + ActivityIndicator, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { Ionicons } from '@expo/vector-icons'; +import { useNavigation } from '@react-navigation/native'; +import { useSettings } from '../hooks/useSettings'; +import { localScraperService, ScraperInfo } from '../services/localScraperService'; +import { logger } from '../utils/logger'; +import { useTheme } from '../contexts/ThemeContext'; + +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + +// Create a styles creator function that accepts the theme colors +const createStyles = (colors: any) => StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingTop: Platform.OS === 'ios' ? 44 : ANDROID_STATUSBAR_HEIGHT + 16, + paddingBottom: 16, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + }, + backText: { + fontSize: 17, + color: colors.primary, + marginLeft: 8, + }, + headerTitle: { + fontSize: 34, + fontWeight: 'bold', + color: colors.white, + paddingHorizontal: 16, + marginBottom: 24, + }, + scrollView: { + flex: 1, + }, + section: { + backgroundColor: colors.elevation1, + marginBottom: 16, + borderRadius: 12, + padding: 16, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '600', + color: colors.white, + marginBottom: 8, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + sectionDescription: { + fontSize: 14, + color: colors.mediumGray, + marginBottom: 16, + lineHeight: 20, + }, + emptyContainer: { + backgroundColor: colors.elevation2, + borderRadius: 12, + padding: 32, + alignItems: 'center', + justifyContent: 'center', + marginBottom: 24, + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.1, + shadowRadius: 4, + elevation: 2, + }, + emptyText: { + marginTop: 8, + color: colors.mediumGray, + fontSize: 15, + }, + scraperItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: colors.elevation2, + padding: 12, + marginBottom: 8, + borderRadius: 8, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.05, + shadowRadius: 2, + elevation: 1, + }, + scraperLogo: { + width: 40, + height: 40, + marginRight: 12, + borderRadius: 6, + backgroundColor: colors.elevation3, + }, + scraperInfo: { + flex: 1, + }, + scraperName: { + fontSize: 15, + fontWeight: '600', + color: colors.white, + marginBottom: 2, + }, + scraperDescription: { + fontSize: 13, + color: colors.mediumGray, + marginBottom: 4, + lineHeight: 18, + }, + scraperMeta: { + flexDirection: 'row', + alignItems: 'center', + }, + scraperVersion: { + fontSize: 12, + color: colors.mediumGray, + }, + scraperDot: { + fontSize: 12, + color: colors.mediumGray, + marginHorizontal: 8, + }, + scraperTypes: { + fontSize: 12, + color: colors.mediumGray, + }, + scraperLanguage: { + fontSize: 12, + color: colors.mediumGray, + }, + settingRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + }, + settingInfo: { + flex: 1, + marginRight: 16, + }, + settingTitle: { + fontSize: 17, + fontWeight: '600', + color: colors.white, + marginBottom: 2, + }, + settingDescription: { + fontSize: 14, + color: colors.mediumEmphasis, + lineHeight: 20, + }, + textInput: { + backgroundColor: colors.elevation1, + borderRadius: 8, + padding: 12, + color: colors.white, + marginBottom: 16, + fontSize: 15, + }, + button: { + backgroundColor: colors.elevation2, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + marginRight: 8, + }, + primaryButton: { + backgroundColor: colors.primary, + }, + secondaryButton: { + backgroundColor: colors.elevation2, + }, + buttonText: { + fontSize: 16, + fontWeight: '600', + color: colors.white, + textAlign: 'center', + }, + secondaryButtonText: { + fontSize: 16, + fontWeight: '600', + color: colors.mediumGray, + textAlign: 'center', + }, + clearButton: { + backgroundColor: '#ff3b30', + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 8, + }, + clearButtonText: { + fontSize: 14, + fontWeight: '600', + color: colors.white, + }, + currentRepoContainer: { + backgroundColor: colors.elevation1, + borderRadius: 8, + padding: 12, + marginBottom: 16, + }, + currentRepoLabel: { + fontSize: 14, + fontWeight: '600', + color: colors.primary, + marginBottom: 4, + }, + currentRepoUrl: { + fontSize: 14, + color: colors.white, + fontFamily: Platform.OS === 'ios' ? 'Courier' : 'monospace', + lineHeight: 18, + }, + urlHint: { + fontSize: 12, + color: colors.mediumGray, + marginBottom: 8, + lineHeight: 16, + }, + defaultRepoButton: { + backgroundColor: colors.elevation3, + borderRadius: 6, + paddingVertical: 8, + paddingHorizontal: 12, + marginBottom: 16, + alignItems: 'center', + }, + defaultRepoButtonText: { + color: colors.primary, + fontSize: 14, + fontWeight: '500', + }, + buttonRow: { + flexDirection: 'row', + gap: 12, + }, + infoText: { + fontSize: 14, + color: colors.mediumEmphasis, + lineHeight: 20, + }, + content: { + flex: 1, + }, + emptyState: { + alignItems: 'center', + paddingVertical: 32, + }, + emptyStateTitle: { + fontSize: 18, + fontWeight: '600', + color: colors.white, + marginTop: 16, + marginBottom: 8, + }, + emptyStateDescription: { + fontSize: 14, + color: colors.mediumGray, + textAlign: 'center', + lineHeight: 20, + }, + scrapersList: { + gap: 12, + }, + scrapersContainer: { + marginBottom: 24, + }, + inputContainer: { + marginBottom: 16, + }, + lastSection: { + borderBottomWidth: 0, + }, + disabledSection: { + opacity: 0.5, + }, + disabledText: { + color: colors.elevation3, + }, + disabledContainer: { + opacity: 0.5, + }, + disabledInput: { + backgroundColor: colors.elevation1, + opacity: 0.5, + }, + disabledButton: { + opacity: 0.5, + }, + disabledImage: { + opacity: 0.3, + }, + }); + +const ScraperSettingsScreen: React.FC = () => { + const navigation = useNavigation(); + const { settings, updateSetting } = useSettings(); + const { currentTheme } = useTheme(); + const colors = currentTheme.colors; + const styles = createStyles(colors); + const [repositoryUrl, setRepositoryUrl] = useState(settings.scraperRepositoryUrl); + const [installedScrapers, setInstalledScrapers] = useState<ScraperInfo[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); + const [hasRepository, setHasRepository] = useState(false); + + useEffect(() => { + loadScrapers(); + checkRepository(); + }, []); + + const loadScrapers = async () => { + try { + const scrapers = await localScraperService.getInstalledScrapers(); + setInstalledScrapers(scrapers); + } catch (error) { + logger.error('[ScraperSettings] Failed to load scrapers:', error); + } + }; + + const checkRepository = async () => { + try { + const repoUrl = await localScraperService.getRepositoryUrl(); + setHasRepository(!!repoUrl); + if (repoUrl && repoUrl !== repositoryUrl) { + setRepositoryUrl(repoUrl); + } + } catch (error) { + logger.error('[ScraperSettings] Failed to check repository:', error); + } + }; + + const handleSaveRepository = async () => { + if (!repositoryUrl.trim()) { + Alert.alert('Error', 'Please enter a valid repository URL'); + return; + } + + // Validate URL format + const url = repositoryUrl.trim(); + if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { + Alert.alert( + 'Invalid URL Format', + 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/branch/\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/main/' + ); + return; + } + + try { + setIsLoading(true); + await localScraperService.setRepositoryUrl(url); + await updateSetting('scraperRepositoryUrl', url); + setHasRepository(true); + Alert.alert('Success', 'Repository URL saved successfully'); + } catch (error) { + logger.error('[ScraperSettings] Failed to save repository:', error); + Alert.alert('Error', 'Failed to save repository URL'); + } finally { + setIsLoading(false); + } + }; + + const handleRefreshRepository = async () => { + if (!repositoryUrl.trim()) { + Alert.alert('Error', 'Please set a repository URL first'); + return; + } + + try { + setIsRefreshing(true); + await localScraperService.refreshRepository(); + await loadScrapers(); + Alert.alert('Success', 'Repository refreshed successfully'); + } catch (error) { + logger.error('[ScraperSettings] Failed to refresh repository:', error); + const errorMessage = error instanceof Error ? error.message : String(error); + Alert.alert( + 'Repository Error', + `Failed to refresh repository: ${errorMessage}\n\nPlease ensure your URL is correct and follows this format:\nhttps://raw.githubusercontent.com/username/repo/branch/` + ); + } finally { + setIsRefreshing(false); + } + }; + + const handleToggleScraper = async (scraperId: string, enabled: boolean) => { + try { + await localScraperService.setScraperEnabled(scraperId, enabled); + await loadScrapers(); + } catch (error) { + logger.error('[ScraperSettings] Failed to toggle scraper:', error); + Alert.alert('Error', 'Failed to update scraper status'); + } + }; + + const handleClearScrapers = () => { + Alert.alert( + 'Clear All Scrapers', + 'Are you sure you want to remove all installed scrapers? This action cannot be undone.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + try { + await localScraperService.clearScrapers(); + await loadScrapers(); + Alert.alert('Success', 'All scrapers have been removed'); + } catch (error) { + logger.error('[ScraperSettings] Failed to clear scrapers:', error); + Alert.alert('Error', 'Failed to clear scrapers'); + } + }, + }, + ] + ); + }; + + const handleClearCache = () => { + Alert.alert( + 'Clear Repository Cache', + 'This will remove the saved repository URL and clear all cached scraper data. You will need to re-enter your repository URL.', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear Cache', + style: 'destructive', + onPress: async () => { + try { + await localScraperService.clearScrapers(); + await localScraperService.setRepositoryUrl(''); + await updateSetting('scraperRepositoryUrl', ''); + setRepositoryUrl(''); + setHasRepository(false); + await loadScrapers(); + Alert.alert('Success', 'Repository cache cleared successfully'); + } catch (error) { + logger.error('[ScraperSettings] Failed to clear cache:', error); + Alert.alert('Error', 'Failed to clear repository cache'); + } + }, + }, + ] + ); + }; + + const handleUseDefaultRepo = () => { + const defaultUrl = 'https://raw.githubusercontent.com/tapframe/nuvio-providers/main'; + setRepositoryUrl(defaultUrl); + }; + + const handleToggleLocalScrapers = async (enabled: boolean) => { + await updateSetting('enableLocalScrapers', enabled); + }; + + const handleToggleUrlValidation = async (enabled: boolean) => { + await updateSetting('enableScraperUrlValidation', enabled); + }; + + + + return ( + <View style={styles.container}> + <StatusBar + barStyle={Platform.OS === 'ios' ? 'light-content' : 'light-content'} + backgroundColor={colors.background} + /> + <View style={styles.header}> + <TouchableOpacity + style={styles.backButton} + onPress={() => navigation.goBack()} + > + <Ionicons name="arrow-back" size={24} color={colors.primary} /> + <Text style={styles.backText}>Settings</Text> + </TouchableOpacity> + </View> + + <Text style={styles.headerTitle}>Local Scrapers</Text> + + <ScrollView + style={styles.scrollView} + refreshControl={ + <RefreshControl refreshing={isRefreshing} onRefresh={loadScrapers} /> + } + > + {/* Enable Local Scrapers - Top Priority */} + <View style={styles.section}> + <View style={styles.settingRow}> + <View style={styles.settingInfo}> + <Text style={styles.settingTitle}>Enable Local Scrapers</Text> + <Text style={styles.settingDescription}> + Allow the app to use locally installed scrapers for finding streams + </Text> + </View> + <Switch + value={settings.enableLocalScrapers} + onValueChange={handleToggleLocalScrapers} + trackColor={{ false: colors.elevation3, true: colors.primary }} + thumbColor={settings.enableLocalScrapers ? colors.white : '#f4f3f4'} + /> + </View> + </View> + + {/* Repository Configuration - Moved up for better UX */} + <View style={[styles.section, !settings.enableLocalScrapers && styles.disabledSection]}> + <View style={styles.sectionHeader}> + <Text style={[styles.sectionTitle, !settings.enableLocalScrapers && styles.disabledText]}>Repository Configuration</Text> + {hasRepository && settings.enableLocalScrapers && ( + <TouchableOpacity + style={styles.clearButton} + onPress={handleClearCache} + > + <Text style={styles.clearButtonText}>Clear Cache</Text> + </TouchableOpacity> + )} + </View> + <Text style={[styles.sectionDescription, !settings.enableLocalScrapers && styles.disabledText]}> + Enter the URL of a Nuvio scraper repository to download and install scrapers. + </Text> + + {hasRepository && repositoryUrl && ( + <View style={[styles.currentRepoContainer, !settings.enableLocalScrapers && styles.disabledContainer]}> + <Text style={[styles.currentRepoLabel, !settings.enableLocalScrapers && styles.disabledText]}>Current Repository:</Text> + <Text style={[styles.currentRepoUrl, !settings.enableLocalScrapers && styles.disabledText]}>{repositoryUrl}</Text> + </View> + )} + + <View style={styles.inputContainer}> + <TextInput + style={[styles.textInput, !settings.enableLocalScrapers && styles.disabledInput]} + value={repositoryUrl} + onChangeText={setRepositoryUrl} + placeholder="https://raw.githubusercontent.com/tapframe/nuvio-providers/main" + placeholderTextColor={!settings.enableLocalScrapers ? colors.elevation3 : "#999"} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + editable={settings.enableLocalScrapers} + /> + <Text style={[styles.urlHint, !settings.enableLocalScrapers && styles.disabledText]}> + 💡 Use GitHub raw URL format. Default: https://raw.githubusercontent.com/tapframe/nuvio-providers/main + </Text> + + <TouchableOpacity + style={[styles.defaultRepoButton, !settings.enableLocalScrapers && styles.disabledButton]} + onPress={handleUseDefaultRepo} + disabled={!settings.enableLocalScrapers} + > + <Text style={[styles.defaultRepoButtonText, !settings.enableLocalScrapers && styles.disabledText]}>Use Default Repository</Text> + </TouchableOpacity> + </View> + + <View style={styles.buttonRow}> + <TouchableOpacity + style={[styles.button, styles.primaryButton, !settings.enableLocalScrapers && styles.disabledButton]} + onPress={handleSaveRepository} + disabled={isLoading || !settings.enableLocalScrapers} + > + {isLoading ? ( + <ActivityIndicator size="small" color="#ffffff" /> + ) : ( + <Text style={[styles.buttonText, !settings.enableLocalScrapers && styles.disabledText]}>Save Repository</Text> + )} + </TouchableOpacity> + + {hasRepository && ( + <TouchableOpacity + style={[styles.button, styles.secondaryButton, !settings.enableLocalScrapers && styles.disabledButton]} + onPress={handleRefreshRepository} + disabled={isRefreshing || !settings.enableLocalScrapers} + > + {isRefreshing ? ( + <ActivityIndicator size="small" color={colors.primary} /> + ) : ( + <Text style={[styles.secondaryButtonText, !settings.enableLocalScrapers && styles.disabledText]}>Refresh</Text> + )} + </TouchableOpacity> + )} + </View> + </View> + + {/* Installed Scrapers */} + <View style={[styles.section, !settings.enableLocalScrapers && styles.disabledSection]}> + <View style={styles.sectionHeader}> + <Text style={[styles.sectionTitle, !settings.enableLocalScrapers && styles.disabledText]}>Installed Scrapers</Text> + {installedScrapers.length > 0 && settings.enableLocalScrapers && ( + <TouchableOpacity + style={styles.clearButton} + onPress={handleClearScrapers} + > + <Text style={styles.clearButtonText}>Clear All</Text> + </TouchableOpacity> + )} + </View> + + {installedScrapers.length === 0 ? ( + <View style={[styles.emptyContainer, !settings.enableLocalScrapers && styles.disabledContainer]}> + <Ionicons name="download-outline" size={48} color={!settings.enableLocalScrapers ? colors.elevation3 : colors.mediumGray} /> + <Text style={[styles.emptyStateTitle, !settings.enableLocalScrapers && styles.disabledText]}>No Scrapers Installed</Text> + <Text style={[styles.emptyStateDescription, !settings.enableLocalScrapers && styles.disabledText]}> + Configure a repository above to install scrapers. + </Text> + </View> + ) : ( + <View style={styles.scrapersContainer}> + {installedScrapers.map((scraper) => ( + <View key={scraper.id} style={[styles.scraperItem, !settings.enableLocalScrapers && styles.disabledContainer]}> + {scraper.logo ? ( + <Image + source={{ uri: scraper.logo }} + style={[styles.scraperLogo, !settings.enableLocalScrapers && styles.disabledImage]} + resizeMode="contain" + /> + ) : ( + <View style={[styles.scraperLogo, !settings.enableLocalScrapers && styles.disabledContainer]} /> + )} + <View style={styles.scraperInfo}> + <Text style={[styles.scraperName, !settings.enableLocalScrapers && styles.disabledText]}>{scraper.name}</Text> + <Text style={[styles.scraperDescription, !settings.enableLocalScrapers && styles.disabledText]}>{scraper.description}</Text> + <View style={styles.scraperMeta}> + <Text style={[styles.scraperVersion, !settings.enableLocalScrapers && styles.disabledText]}>v{scraper.version}</Text> + <Text style={[styles.scraperDot, !settings.enableLocalScrapers && styles.disabledText]}>•</Text> + <Text style={[styles.scraperTypes, !settings.enableLocalScrapers && styles.disabledText]}> + {scraper.supportedTypes && Array.isArray(scraper.supportedTypes) ? scraper.supportedTypes.join(', ') : 'Unknown'} + </Text> + {scraper.contentLanguage && Array.isArray(scraper.contentLanguage) && scraper.contentLanguage.length > 0 && ( + <> + <Text style={[styles.scraperDot, !settings.enableLocalScrapers && styles.disabledText]}>•</Text> + <Text style={[styles.scraperLanguage, !settings.enableLocalScrapers && styles.disabledText]}> + {scraper.contentLanguage.map(lang => lang.toUpperCase()).join(', ')} + </Text> + </> + )} + </View> + </View> + <Switch + value={scraper.enabled && settings.enableLocalScrapers} + onValueChange={(enabled) => handleToggleScraper(scraper.id, enabled)} + trackColor={{ false: colors.elevation3, true: colors.primary }} + thumbColor={scraper.enabled && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} + disabled={!settings.enableLocalScrapers} + /> + </View> + ))} + </View> + )} + </View> + + {/* Additional Scraper Settings */} + <View style={[styles.section, !settings.enableLocalScrapers && styles.disabledSection]}> + <Text style={[styles.sectionTitle, !settings.enableLocalScrapers && styles.disabledText]}>Additional Settings</Text> + <View style={styles.settingRow}> + <View style={styles.settingInfo}> + <Text style={[styles.settingTitle, !settings.enableLocalScrapers && styles.disabledText]}>Enable URL Validation</Text> + <Text style={[styles.settingDescription, !settings.enableLocalScrapers && styles.disabledText]}> + Validate streaming URLs before returning them (may slow down results but improves reliability) + </Text> + </View> + <Switch + value={settings.enableScraperUrlValidation && settings.enableLocalScrapers} + onValueChange={handleToggleUrlValidation} + trackColor={{ false: colors.elevation3, true: colors.primary }} + thumbColor={settings.enableScraperUrlValidation && settings.enableLocalScrapers ? colors.white : '#f4f3f4'} + disabled={!settings.enableLocalScrapers} + /> + </View> + </View> + + + {/* About */} + <View style={[styles.section, styles.lastSection]}> + <Text style={styles.sectionTitle}>About Local Scrapers</Text> + <Text style={styles.infoText}> + Local scrapers are JavaScript modules that can search for streaming links from various sources. + They run locally on your device and can be installed from trusted repositories. + </Text> + </View> + </ScrollView> + </View> + ); +}; +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#000000', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + backButton: { + marginRight: 16, + }, + headerTitle: { + fontSize: 20, + fontWeight: '600', + color: '#ffffff', + }, + content: { + flex: 1, + }, + section: { + padding: 16, + borderBottomWidth: 1, + borderBottomColor: '#333', + }, + lastSection: { + borderBottomWidth: 0, + }, + sectionHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 12, + marginHorizontal: -16, + paddingHorizontal: 16, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '600', + color: '#ffffff', + marginBottom: 8, + }, + sectionDescription: { + fontSize: 14, + color: '#999', + marginBottom: 16, + lineHeight: 20, + }, + settingRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + settingInfo: { + flex: 1, + marginRight: 16, + }, + settingTitle: { + fontSize: 16, + fontWeight: '500', + color: '#ffffff', + marginBottom: 4, + }, + settingDescription: { + fontSize: 14, + color: '#999', + lineHeight: 18, + }, + inputContainer: { + marginBottom: 16, + }, + textInput: { + backgroundColor: '#1a1a1a', + borderRadius: 8, + padding: 12, + fontSize: 16, + color: '#ffffff', + borderWidth: 1, + borderColor: '#333', + }, + buttonRow: { + flexDirection: 'row', + gap: 12, + }, + button: { + flex: 1, + paddingVertical: 12, + paddingHorizontal: 16, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + minHeight: 44, + }, + primaryButton: { + backgroundColor: '#007AFF', + }, + secondaryButton: { + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: '#007AFF', + }, + buttonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + secondaryButtonText: { + color: '#007AFF', + fontSize: 16, + fontWeight: '600', + }, + clearButton: { + paddingVertical: 6, + paddingHorizontal: 12, + borderRadius: 6, + backgroundColor: '#ff3b30', + marginLeft: 0, + }, + clearButtonText: { + color: '#ffffff', + fontSize: 14, + fontWeight: '500', + }, + scrapersList: { + gap: 12, + }, + scraperItem: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#1a1a1a', + borderRadius: 8, + padding: 16, + borderWidth: 1, + borderColor: '#333', + }, + scraperLogo: { + width: 40, + height: 40, + marginRight: 12, + borderRadius: 8, + }, + scraperInfo: { + flex: 1, + marginRight: 16, + }, + scraperName: { + fontSize: 16, + fontWeight: '600', + color: '#ffffff', + marginBottom: 4, + }, + scraperDescription: { + fontSize: 14, + color: '#999', + marginBottom: 8, + lineHeight: 18, + }, + scraperMeta: { + flexDirection: 'row', + gap: 12, + }, + scraperVersion: { + fontSize: 12, + color: '#007AFF', + fontWeight: '500', + }, + scraperTypes: { + fontSize: 12, + color: '#666', + textTransform: 'uppercase', + }, + emptyState: { + alignItems: 'center', + paddingVertical: 32, + }, + emptyStateTitle: { + fontSize: 18, + fontWeight: '600', + color: '#ffffff', + marginTop: 16, + marginBottom: 8, + }, + emptyStateDescription: { + fontSize: 14, + color: '#999', + textAlign: 'center', + lineHeight: 20, + }, + infoText: { + fontSize: 14, + color: '#999', + lineHeight: 20, + marginBottom: 12, + }, + currentRepoContainer: { + backgroundColor: '#1a1a1a', + borderRadius: 8, + padding: 12, + marginBottom: 16, + borderWidth: 1, + borderColor: '#333', + }, + currentRepoLabel: { + fontSize: 14, + fontWeight: '500', + color: '#007AFF', + marginBottom: 4, + }, + currentRepoUrl: { + fontSize: 14, + color: '#ffffff', + fontFamily: 'monospace', + lineHeight: 18, + }, + urlHint: { + fontSize: 12, + color: '#666', + marginTop: 8, + lineHeight: 16, + }, + defaultRepoButton: { + backgroundColor: '#333', + borderRadius: 6, + paddingVertical: 8, + paddingHorizontal: 12, + marginTop: 8, + alignItems: 'center', + }, + defaultRepoButtonText: { + color: '#007AFF', + fontSize: 14, + fontWeight: '500', + }, +}); + +export default ScraperSettingsScreen; \ No newline at end of file diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx index 7ba205b..76c76f5 100644 --- a/src/screens/SearchScreen.tsx +++ b/src/screens/SearchScreen.tsx @@ -35,7 +35,6 @@ import Animated, { interpolate, withSpring, withDelay, - ZoomIn } from 'react-native-reanimated'; import { RootStackParamList } from '../navigation/AppNavigator'; import { logger } from '../utils/logger'; @@ -445,7 +444,7 @@ const SearchScreen = () => { onPress={() => { navigation.navigate('Metadata', { id: item.id, type: item.type }); }} - entering={FadeIn.duration(500).delay(index * 100)} + entering={FadeIn.duration(300).delay(index * 50)} activeOpacity={0.7} > <View style={[styles.horizontalItemPosterContainer, { @@ -558,7 +557,6 @@ const SearchScreen = () => { onChangeText={setQuery} returnKeyType="search" keyboardAppearance="dark" - autoFocus ref={inputRef} /> {query.length > 0 && ( @@ -650,7 +648,7 @@ const SearchScreen = () => { {seriesResults.length > 0 && ( <Animated.View style={styles.carouselContainer} - entering={FadeIn.duration(300).delay(100)} + entering={FadeIn.duration(300).delay(50)} > <Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}> TV Shows ({seriesResults.length}) @@ -690,9 +688,9 @@ const styles = StyleSheet.create({ paddingTop: 0, }, header: { - paddingHorizontal: 20, + paddingHorizontal: 15, justifyContent: 'flex-end', - paddingBottom: 8, + paddingBottom: 0, backgroundColor: 'transparent', zIndex: 2, }, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 7f44226..0844eac 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -11,7 +11,9 @@ import { Alert, Platform, Dimensions, - Image + Image, + Button, + Linking } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useNavigation } from '@react-navigation/native'; @@ -24,14 +26,15 @@ import { stremioService } from '../services/stremioService'; import { useCatalogContext } from '../contexts/CatalogContext'; import { useTraktContext } from '../contexts/TraktContext'; import { useTheme } from '../contexts/ThemeContext'; -import { catalogService, DataSource } from '../services/catalogService'; +import { catalogService } from '../services/catalogService'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import * as Sentry from '@sentry/react-native'; const { width } = Dimensions.get('window'); const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; -// Card component with modern style +// Card component with minimalistic style interface SettingsCardProps { children: React.ReactNode; title?: string; @@ -41,18 +44,20 @@ const SettingsCard: React.FC<SettingsCardProps> = ({ children, title }) => { const { currentTheme } = useTheme(); return ( - <View style={[styles.cardContainer]}> + <View + style={[styles.cardContainer]} + > {title && ( <Text style={[ styles.cardTitle, { color: currentTheme.colors.mediumEmphasis } ]}> - {title.toUpperCase()} + {title} </Text> )} <View style={[ styles.card, - { backgroundColor: currentTheme.colors.elevation2 } + { backgroundColor: currentTheme.colors.elevation1 } ]}> {children} </View> @@ -64,7 +69,7 @@ interface SettingItemProps { title: string; description?: string; icon: string; - renderControl: () => React.ReactNode; + renderControl?: () => React.ReactNode; isLast?: boolean; onPress?: () => void; badge?: string | number; @@ -83,17 +88,17 @@ const SettingItem: React.FC<SettingItemProps> = ({ return ( <TouchableOpacity - activeOpacity={0.7} + activeOpacity={0.6} onPress={onPress} style={[ styles.settingItem, !isLast && styles.settingItemBorder, - { borderBottomColor: 'rgba(255,255,255,0.08)' } + { borderBottomColor: currentTheme.colors.elevation2 } ]} > <View style={[ styles.settingIconContainer, - { backgroundColor: 'rgba(255,255,255,0.1)' } + { backgroundColor: currentTheme.colors.elevation2 } ]}> <MaterialIcons name={icon} size={20} color={currentTheme.colors.primary} /> </View> @@ -103,20 +108,22 @@ const SettingItem: React.FC<SettingItemProps> = ({ {title} </Text> {description && ( - <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}> + <Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={1}> {description} </Text> )} </View> {badge && ( - <View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}> - <Text style={styles.badgeText}>{badge}</Text> + <View style={[styles.badge, { backgroundColor: `${currentTheme.colors.primary}20` }]}> + <Text style={[styles.badgeText, { color: currentTheme.colors.primary }]}>{String(badge)}</Text> </View> )} </View> - <View style={styles.settingControl}> - {renderControl()} - </View> + {renderControl && ( + <View style={styles.settingControl}> + {renderControl()} + </View> + )} </TouchableOpacity> ); }; @@ -150,7 +157,6 @@ const SettingsScreen: React.FC = () => { const [addonCount, setAddonCount] = useState<number>(0); const [catalogCount, setCatalogCount] = useState<number>(0); const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false); - const [discoverDataSource, setDiscoverDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS); const loadData = useCallback(async () => { try { @@ -185,9 +191,6 @@ const SettingsScreen: React.FC = () => { const mdblistKey = await AsyncStorage.getItem('mdblist_api_key'); setMdblistKeySet(!!mdblistKey); - // Get discover data source preference - const dataSource = await catalogService.getDataSourcePreference(); - setDiscoverDataSource(dataSource); } catch (error) { console.error('Error loading settings data:', error); } @@ -226,31 +229,47 @@ const SettingsScreen: React.FC = () => { ); }, [updateSetting]); + const handleClearMDBListCache = () => { + Alert.alert( + "Clear MDBList Cache", + "Are you sure you want to clear all cached MDBList data? This cannot be undone.", + [ + { text: "Cancel", style: "cancel" }, + { + text: "Clear", + style: "destructive", + onPress: async () => { + try { + await AsyncStorage.removeItem('mdblist_cache'); + Alert.alert("Success", "MDBList cache has been cleared."); + } catch (error) { + Alert.alert("Error", "Could not clear MDBList cache."); + console.error('Error clearing MDBList cache:', error); + } + } + } + ] + ); + }; + const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => ( <Switch value={value} onValueChange={onValueChange} - trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }} - thumbColor={Platform.OS === 'android' ? (value ? currentTheme.colors.white : currentTheme.colors.white) : ''} - ios_backgroundColor={'rgba(255,255,255,0.1)'} + trackColor={{ false: currentTheme.colors.elevation2, true: currentTheme.colors.primary }} + thumbColor={value ? currentTheme.colors.white : currentTheme.colors.mediumEmphasis} + ios_backgroundColor={currentTheme.colors.elevation2} /> ); const ChevronRight = () => ( <MaterialIcons name="chevron-right" - size={22} - color={'rgba(255,255,255,0.3)'} + size={20} + color={currentTheme.colors.mediumEmphasis} /> ); - // Handle data source change - const handleDiscoverDataSourceChange = useCallback(async (value: string) => { - const dataSource = value as DataSource; - setDiscoverDataSource(dataSource); - await catalogService.setDataSourcePreference(dataSource); - }, []); - const headerBaseHeight = Platform.OS === 'android' ? 80 : 60; const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top; const headerHeight = headerBaseHeight + topSpacing; @@ -274,93 +293,55 @@ const SettingsScreen: React.FC = () => { showsVerticalScrollIndicator={false} contentContainerStyle={styles.scrollContent} > - <SettingsCard title="User & Account"> + {/* Account Section */} + <SettingsCard title="ACCOUNT"> <SettingItem title="Trakt" - description={isAuthenticated ? `Connected as ${userProfile?.username || 'User'}` : "Not Connected"} + description={isAuthenticated ? `@${userProfile?.username || 'User'}` : "Sign in to sync"} icon="person" renderControl={ChevronRight} onPress={() => navigation.navigate('TraktSettings')} - isLast={false} /> - </SettingsCard> - - <SettingsCard title="Profiles"> - {isAuthenticated ? ( + {isAuthenticated && ( <SettingItem - title="Manage Profiles" - description="Create and switch between profiles" + title="Profiles" + description="Manage multiple users" icon="people" renderControl={ChevronRight} onPress={() => navigation.navigate('ProfilesSettings')} isLast={true} /> - ) : ( - <TouchableOpacity - style={[ - styles.profileLockContainer, - { - backgroundColor: `${currentTheme.colors.primary}10`, - borderWidth: 1, - borderColor: `${currentTheme.colors.primary}30` - } - ]} - activeOpacity={1} - > - <View style={styles.profileLockContent}> - <MaterialIcons name="lock-outline" size={24} color={currentTheme.colors.primary} /> - <View style={styles.profileLockTextContainer}> - <Text style={[styles.profileLockTitle, { color: currentTheme.colors.text }]}> - Sign in to use Profiles - </Text> - <Text style={[styles.profileLockDescription, { color: currentTheme.colors.textMuted }]}> - Create multiple profiles for different users and preferences - </Text> - </View> - </View> - <View style={styles.profileBenefits}> - <View style={styles.benefitCol}> - <View style={styles.benefitItem}> - <MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> - <Text style={[styles.benefitText, { color: currentTheme.colors.textMuted }]}> - Separate watchlists - </Text> - </View> - <View style={styles.benefitItem}> - <MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> - <Text style={[styles.benefitText, { color: currentTheme.colors.textMuted }]}> - Content preferences - </Text> - </View> - </View> - <View style={styles.benefitCol}> - <View style={styles.benefitItem}> - <MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> - <Text style={[styles.benefitText, { color: currentTheme.colors.textMuted }]}> - Personalized recommendations - </Text> - </View> - <View style={styles.benefitItem}> - <MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} /> - <Text style={[styles.benefitText, { color: currentTheme.colors.textMuted }]}> - Individual viewing history - </Text> - </View> - </View> - </View> - <TouchableOpacity - style={[styles.loginButton, { backgroundColor: currentTheme.colors.primary }]} - activeOpacity={0.7} - onPress={() => navigation.navigate('TraktSettings')} - > - <Text style={styles.loginButtonText}>Connect with Trakt</Text> - <MaterialIcons name="arrow-forward" size={18} color="#FFFFFF" style={styles.loginButtonIcon} /> - </TouchableOpacity> - </TouchableOpacity> )} </SettingsCard> - <SettingsCard title="Appearance"> + {/* Content & Discovery */} + <SettingsCard title="CONTENT & DISCOVERY"> + <SettingItem + title="Addons" + description={`${addonCount} installed`} + icon="extension" + renderControl={ChevronRight} + onPress={() => navigation.navigate('Addons')} + /> + <SettingItem + title="Catalogs" + description={`${catalogCount} active`} + icon="view-list" + renderControl={ChevronRight} + onPress={() => navigation.navigate('CatalogSettings')} + /> + <SettingItem + title="Home Screen" + description="Layout and content" + icon="home" + renderControl={ChevronRight} + onPress={() => navigation.navigate('HomeScreenSettings')} + isLast={true} + /> + </SettingsCard> + + {/* Appearance & Interface */} + <SettingsCard title="APPEARANCE"> <SettingItem title="Theme" description={currentTheme.name} @@ -370,194 +351,161 @@ const SettingsScreen: React.FC = () => { /> <SettingItem title="Episode Layout" - description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'} + description={settings?.episodeLayoutStyle === 'horizontal' ? 'Horizontal' : 'Vertical'} icon="view-module" renderControl={() => ( - <View style={styles.selectorContainer}> - <TouchableOpacity - style={[ - styles.selectorButton, - settings.episodeLayoutStyle === 'vertical' && { - backgroundColor: currentTheme.colors.primary - } - ]} - onPress={() => updateSetting('episodeLayoutStyle', 'vertical')} - > - <Text style={[ - styles.selectorText, - { color: currentTheme.colors.mediumEmphasis }, - settings.episodeLayoutStyle === 'vertical' && { - color: currentTheme.colors.white, - fontWeight: '600' - } - ]}>Vertical</Text> - </TouchableOpacity> - <TouchableOpacity - style={[ - styles.selectorButton, - settings.episodeLayoutStyle === 'horizontal' && { - backgroundColor: currentTheme.colors.primary - } - ]} - onPress={() => updateSetting('episodeLayoutStyle', 'horizontal')} - > - <Text style={[ - styles.selectorText, - { color: currentTheme.colors.mediumEmphasis }, - settings.episodeLayoutStyle === 'horizontal' && { - color: currentTheme.colors.white, - fontWeight: '600' - } - ]}>Horizontal</Text> - </TouchableOpacity> - </View> + <CustomSwitch + value={settings?.episodeLayoutStyle === 'horizontal'} + onValueChange={(value) => updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')} + /> )} isLast={true} /> </SettingsCard> - <SettingsCard title="Features"> + {/* Integrations */} + <SettingsCard title="INTEGRATIONS"> <SettingItem - title="Calendar" - description="Manage your show calendar settings" - icon="calendar-today" + title="MDBList" + description={mdblistKeySet ? "Connected" : "Enable to add ratings & reviews"} + icon="star" renderControl={ChevronRight} - onPress={() => navigation.navigate('Calendar')} + onPress={() => navigation.navigate('MDBListSettings')} + /> + <SettingItem + title="TMDB" + description="Metadata provider" + icon="movie" + renderControl={ChevronRight} + onPress={() => navigation.navigate('TMDBSettings')} + /> + <SettingItem + title="Media Sources" + description="Logo & image preferences" + icon="image" + renderControl={ChevronRight} + onPress={() => navigation.navigate('LogoSourceSettings')} + isLast={true} + /> + </SettingsCard> + + {/* Playback & Experience */} + <SettingsCard title="PLAYBACK"> + <SettingItem + title="Video Player" + description={Platform.OS === 'ios' + ? (settings?.preferredPlayer === 'internal' ? 'Built-in' : settings?.preferredPlayer?.toUpperCase() || 'Built-in') + : (settings?.useExternalPlayer ? 'External' : 'Built-in') + } + icon="play-circle-outline" + renderControl={ChevronRight} + onPress={() => navigation.navigate('PlayerSettings')} + /> + <SettingItem + title="Local Scrapers" + description="Manage local scraper repositories" + icon="code" + renderControl={ChevronRight} + onPress={() => navigation.navigate('ScraperSettings')} /> <SettingItem title="Notifications" - description="Configure episode notifications and reminders" - icon="notifications" + description="Episode reminders" + icon="notifications-none" renderControl={ChevronRight} onPress={() => navigation.navigate('NotificationSettings')} isLast={true} /> </SettingsCard> - <SettingsCard title="Content"> + {/* About & Support */} + <SettingsCard title="ABOUT"> <SettingItem - title="Addons" - description="Manage your installed addons" - icon="extension" + title="Privacy Policy" + icon="lock" + onPress={() => Linking.openURL('https://github.com/Stremio/stremio-expo/blob/main/PRIVACY_POLICY.md')} renderControl={ChevronRight} - onPress={() => navigation.navigate('Addons')} - badge={addonCount} /> <SettingItem - title="Catalogs" - description="Configure content sources" - icon="view-list" + title="Report Issue" + icon="bug-report" + onPress={() => Sentry.showFeedbackWidget()} renderControl={ChevronRight} - onPress={() => navigation.navigate('CatalogSettings')} - badge={catalogCount} /> <SettingItem - title="Internal Providers" - description="Enable or disable built-in providers like HDRezka" - icon="source" - renderControl={ChevronRight} - onPress={() => navigation.navigate('InternalProvidersSettings')} - /> - <SettingItem - title="Home Screen" - description="Customize layout and content" - icon="home" - renderControl={ChevronRight} - onPress={() => navigation.navigate('HomeScreenSettings')} - /> - <SettingItem - title="MDBList Integration" - description={mdblistKeySet ? "Ratings and reviews provided by MDBList" : "Connect MDBList for ratings and reviews"} + title="Version" + description="1.0.0" icon="info-outline" - renderControl={ChevronRight} - onPress={() => navigation.navigate('MDBListSettings')} - /> - <SettingItem - title="Image Sources" - description="Choose primary source for title logos and backgrounds" - icon="image" - renderControl={ChevronRight} - onPress={() => navigation.navigate('LogoSourceSettings')} - /> - <SettingItem - title="TMDB" - description="API & Metadata Settings" - icon="movie-filter" - renderControl={ChevronRight} - onPress={() => navigation.navigate('TMDBSettings')} isLast={true} /> </SettingsCard> - <SettingsCard title="Playback"> - <SettingItem - title="Video Player" - description={Platform.OS === 'ios' - ? (settings.preferredPlayer === 'internal' - ? 'Built-in Player' - : settings.preferredPlayer - ? settings.preferredPlayer.toUpperCase() - : 'Built-in Player') - : (settings.useExternalPlayer ? 'External Player' : 'Built-in Player') - } - icon="play-arrow" - renderControl={ChevronRight} - onPress={() => navigation.navigate('PlayerSettings')} - isLast={true} - /> - </SettingsCard> + {/* Developer Options - Only show in development */} + {__DEV__ && ( + <SettingsCard title="DEVELOPER"> + <SettingItem + title="Test Onboarding" + icon="play-circle-outline" + onPress={() => navigation.navigate('Onboarding')} + renderControl={ChevronRight} + /> + <SettingItem + title="Reset Onboarding" + icon="refresh" + onPress={async () => { + try { + await AsyncStorage.removeItem('hasCompletedOnboarding'); + Alert.alert('Success', 'Onboarding has been reset. Restart the app to see the onboarding flow.'); + } catch (error) { + Alert.alert('Error', 'Failed to reset onboarding.'); + } + }} + renderControl={ChevronRight} + /> + <SettingItem + title="Clear All Data" + icon="delete-forever" + onPress={() => { + Alert.alert( + 'Clear All Data', + 'This will reset all settings and clear all cached data. Are you sure?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + try { + await AsyncStorage.clear(); + Alert.alert('Success', 'All data cleared. Please restart the app.'); + } catch (error) { + Alert.alert('Error', 'Failed to clear data.'); + } + } + } + ] + ); + }} + isLast={true} + /> + </SettingsCard> + )} - <SettingsCard title="Discover"> - <SettingItem - title="Content Source" - description="Choose where to get content for the Discover screen" - icon="explore" - renderControl={() => ( - <View style={styles.selectorContainer}> - <TouchableOpacity - style={[ - styles.selectorButton, - discoverDataSource === DataSource.STREMIO_ADDONS && { - backgroundColor: currentTheme.colors.primary - } - ]} - onPress={() => handleDiscoverDataSourceChange(DataSource.STREMIO_ADDONS)} - > - <Text style={[ - styles.selectorText, - { color: currentTheme.colors.mediumEmphasis }, - discoverDataSource === DataSource.STREMIO_ADDONS && { - color: currentTheme.colors.white, - fontWeight: '600' - } - ]}>Addons</Text> - </TouchableOpacity> - <TouchableOpacity - style={[ - styles.selectorButton, - discoverDataSource === DataSource.TMDB && { - backgroundColor: currentTheme.colors.primary - } - ]} - onPress={() => handleDiscoverDataSourceChange(DataSource.TMDB)} - > - <Text style={[ - styles.selectorText, - { color: currentTheme.colors.mediumEmphasis }, - discoverDataSource === DataSource.TMDB && { - color: currentTheme.colors.white, - fontWeight: '600' - } - ]}>TMDB</Text> - </TouchableOpacity> - </View> - )} - /> - </SettingsCard> + {/* Cache Management - Only show if MDBList is connected */} + {mdblistKeySet && ( + <SettingsCard title="CACHE MANAGEMENT"> + <SettingItem + title="Clear MDBList Cache" + icon="cached" + onPress={handleClearMDBListCache} + isLast={true} + /> + </SettingsCard> + )} - <View style={styles.versionContainer}> - <Text style={[styles.versionText, {color: currentTheme.colors.mediumEmphasis}]}> - Version 1.0.0 + <View style={styles.footer}> + <Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}> + Made with ❤️ by the Nuvio team </Text> </View> </ScrollView> @@ -572,7 +520,7 @@ const styles = StyleSheet.create({ flex: 1, }, header: { - paddingHorizontal: 20, + paddingHorizontal: Math.max(1, width * 0.05), flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-end', @@ -581,7 +529,7 @@ const styles = StyleSheet.create({ zIndex: 2, }, headerTitle: { - fontSize: 32, + fontSize: Math.min(32, width * 0.08), fontWeight: '800', letterSpacing: 0.3, }, @@ -607,11 +555,11 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: '600', letterSpacing: 0.8, - marginLeft: 16, + marginLeft: Math.max(12, width * 0.04), marginBottom: 8, }, card: { - marginHorizontal: 16, + marginHorizontal: Math.max(12, width * 0.04), borderRadius: 16, overflow: 'hidden', shadowColor: '#000', @@ -625,9 +573,9 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', paddingVertical: 12, - paddingHorizontal: 16, + paddingHorizontal: Math.max(12, width * 0.04), borderBottomWidth: 0.5, - minHeight: 58, + minHeight: Math.max(54, width * 0.14), width: '100%', }, settingItemBorder: { @@ -650,12 +598,12 @@ const styles = StyleSheet.create({ flex: 1, }, settingTitle: { - fontSize: 16, + fontSize: Math.min(16, width * 0.042), fontWeight: '500', marginBottom: 3, }, settingDescription: { - fontSize: 14, + fontSize: Math.min(14, width * 0.037), opacity: 0.8, }, settingControl: { @@ -677,96 +625,39 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: '600', }, - versionContainer: { + segmentedControl: { + flexDirection: 'row', + backgroundColor: 'rgba(255,255,255,0.08)', + borderRadius: 8, + padding: 2, + }, + segment: { + paddingHorizontal: 16, + paddingVertical: 8, + borderRadius: 6, + minWidth: 60, + alignItems: 'center', + }, + segmentActive: { + backgroundColor: 'rgba(255,255,255,0.16)', + }, + segmentText: { + fontSize: 13, + fontWeight: '500', + }, + segmentTextActive: { + color: 'white', + fontWeight: '600', + }, + footer: { alignItems: 'center', justifyContent: 'center', marginTop: 10, marginBottom: 20, }, - versionText: { + footerText: { fontSize: 14, - }, - pickerContainer: { - flex: 1, - }, - picker: { - flex: 1, - }, - selectorContainer: { - flexDirection: 'row', - borderRadius: 8, - overflow: 'hidden', - height: 36, - width: 180, - marginRight: 8, - }, - selectorButton: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 8, - backgroundColor: 'rgba(255,255,255,0.08)', - }, - selectorText: { - fontSize: 13, - fontWeight: '500', - textAlign: 'center', - }, - profileLockContainer: { - padding: 16, - borderRadius: 8, - overflow: 'hidden', - marginVertical: 8, - }, - profileLockContent: { - flexDirection: 'row', - alignItems: 'center', - }, - profileLockTextContainer: { - flex: 1, - marginHorizontal: 12, - }, - profileLockTitle: { - fontSize: 16, - fontWeight: '600', - marginBottom: 4, - }, - profileLockDescription: { - fontSize: 14, - opacity: 0.8, - }, - profileBenefits: { - flexDirection: 'row', - marginTop: 16, - justifyContent: 'space-between', - }, - benefitCol: { - flex: 1, - }, - benefitItem: { - flexDirection: 'row', - alignItems: 'center', - marginBottom: 8, - }, - benefitText: { - fontSize: 14, - marginLeft: 8, - }, - loginButton: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - borderRadius: 8, - paddingVertical: 12, - marginTop: 16, - }, - loginButtonText: { - color: '#FFFFFF', - fontSize: 16, - fontWeight: '600', - }, - loginButtonIcon: { - marginLeft: 8, + opacity: 0.5, }, }); diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx index 390ed0f..462751d 100644 --- a/src/screens/ShowRatingsScreen.tsx +++ b/src/screens/ShowRatingsScreen.tsx @@ -481,7 +481,7 @@ const ShowRatingsScreen = ({ route }: Props) => { <Animated.View key={`s${season.season_number}`} style={styles.ratingColumn} - entering={SlideInRight.delay(season.season_number * 50).duration(200)} + entering={FadeIn.delay(season.season_number * 20).duration(200)} > <Text style={[styles.headerText, { color: colors.white }]}>S{season.season_number}</Text> </Animated.View> @@ -507,7 +507,7 @@ const ShowRatingsScreen = ({ route }: Props) => { <Animated.View key={`s${season.season_number}e${episodeIndex + 1}`} style={styles.ratingColumn} - entering={SlideInRight.delay((season.season_number + episodeIndex) * 10).duration(200)} + entering={FadeIn.delay((season.season_number + episodeIndex) * 5).duration(200)} > {season.episodes[episodeIndex] && <RatingCell diff --git a/src/screens/StreamsScreen.tsx b/src/screens/StreamsScreen.tsx index 66807f7..3939925 100644 --- a/src/screens/StreamsScreen.tsx +++ b/src/screens/StreamsScreen.tsx @@ -16,7 +16,7 @@ import { Linking, } from 'react-native'; import * as ScreenOrientation from 'expo-screen-orientation'; -import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; +import { useRoute, useNavigation } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native'; import { MaterialIcons } from '@expo/vector-icons'; @@ -24,10 +24,12 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Image } from 'expo-image'; import { RootStackParamList, RootStackNavigationProp } from '../navigation/AppNavigator'; import { useMetadata } from '../hooks/useMetadata'; +import { useMetadataAssets } from '../hooks/useMetadataAssets'; import { useTheme } from '../contexts/ThemeContext'; import { Stream } from '../types/metadata'; import { tmdbService } from '../services/tmdbService'; import { stremioService } from '../services/stremioService'; +import { localScraperService } from '../services/localScraperService'; import { VideoPlayerService } from '../services/videoPlayerService'; import { useSettings } from '../hooks/useSettings'; import QualityBadge from '../components/metadata/QualityBadge'; @@ -56,116 +58,56 @@ const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_V const { width, height } = Dimensions.get('window'); // Extracted Components -const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, isExiting }: { +const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, theme }: { stream: Stream; onPress: () => void; index: number; isLoading?: boolean; statusMessage?: string; theme: any; - isExiting?: boolean; }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); - const quality = stream.title?.match(/(\d+)p/)?.[1] || null; - const isHDR = stream.title?.toLowerCase().includes('hdr'); - const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV'); - const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; - const isDebrid = stream.behaviorHints?.cached; + const streamInfo = useMemo(() => { + const title = stream.title || ''; + const name = stream.name || ''; + + // Helper function to format size from bytes + const formatSize = (bytes: number): string => { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; + }; + + // Get size from title (legacy format) or from stream.size field + let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1]; + if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) { + sizeDisplay = formatSize(stream.size); + } + + // Extract quality for badge display + const basicQuality = title.match(/(\d+)p/)?.[1] || null; + + return { + quality: basicQuality, + isHDR: title.toLowerCase().includes('hdr'), + isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'), + size: sizeDisplay, + isDebrid: stream.behaviorHints?.cached, + displayName: name || 'Unnamed Stream', + subTitle: title && title !== name ? title : null + }; + }, [stream.name, stream.title, stream.behaviorHints, stream.size]); - // Determine if this is a HDRezka stream - const isHDRezka = stream.name === 'HDRezka'; - - // For HDRezka streams, the title contains the quality information - const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream'); - const displayAddonName = isHDRezka ? '' : (stream.title || ''); - - // Animation delay based on index - stagger effect (only if not exiting) - const enterDelay = isExiting ? 0 : 100 + (index * 30); - - // Use simple View when exiting to prevent animation conflicts - if (isExiting) { - return ( - <View> - <TouchableOpacity - style={[ - styles.streamCard, - isLoading && styles.streamCardLoading - ]} - onPress={onPress} - disabled={isLoading} - activeOpacity={0.7} - > - <View style={styles.streamDetails}> - <View style={styles.streamNameRow}> - <View style={styles.streamTitleContainer}> - <Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}> - {displayTitle} - </Text> - {displayAddonName && displayAddonName !== displayTitle && ( - <Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}> - {displayAddonName} - </Text> - )} - </View> - - {/* Show loading indicator if stream is loading */} - {isLoading && ( - <View style={styles.loadingIndicator}> - <ActivityIndicator size="small" color={theme.colors.primary} /> - <Text style={[styles.loadingText, { color: theme.colors.primary }]}> - {statusMessage || "Loading..."} - </Text> - </View> - )} - </View> - - <View style={styles.streamMetaRow}> - {quality && quality >= "720" && ( - <QualityBadge type="HD" /> - )} - - {isDolby && ( - <QualityBadge type="VISION" /> - )} - - {size && ( - <View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}> - <Text style={[styles.chipText, { color: theme.colors.white }]}>{size}</Text> - </View> - )} - - {isDebrid && ( - <View style={[styles.chip, { backgroundColor: theme.colors.success }]}> - <Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text> - </View> - )} - - {/* Special badge for HDRezka streams */} - {isHDRezka && ( - <View style={[styles.chip, { backgroundColor: theme.colors.accent }]}> - <Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text> - </View> - )} - </View> - </View> - - <View style={styles.streamAction}> - <MaterialIcons - name="play-arrow" - size={24} - color={theme.colors.primary} - /> - </View> - </TouchableOpacity> - </View> - ); - } + // Animation delay based on index - stagger effect + const enterDelay = 100 + (index * 30); return ( <Animated.View entering={FadeInDown.duration(200).delay(enterDelay)} - layout={Layout.duration(200)} + exiting={FadeOut.duration(150)} > <TouchableOpacity style={[ @@ -180,11 +122,11 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, i <View style={styles.streamNameRow}> <View style={styles.streamTitleContainer}> <Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}> - {displayTitle} + {streamInfo.displayName} </Text> - {displayAddonName && displayAddonName !== displayTitle && ( + {streamInfo.subTitle && ( <Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}> - {displayAddonName} + {streamInfo.subTitle} </Text> )} </View> @@ -201,32 +143,21 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, i </View> <View style={styles.streamMetaRow}> - {quality && quality >= "720" && ( - <QualityBadge type="HD" /> - )} - - {isDolby && ( + {streamInfo.isDolby && ( <QualityBadge type="VISION" /> )} - {size && ( + {streamInfo.size && ( <View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}> - <Text style={[styles.chipText, { color: theme.colors.white }]}>{size}</Text> + <Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text> </View> )} - {isDebrid && ( + {streamInfo.isDebrid && ( <View style={[styles.chip, { backgroundColor: theme.colors.success }]}> <Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text> </View> )} - - {/* Special badge for HDRezka streams */} - {isHDRezka && ( - <View style={[styles.chip, { backgroundColor: theme.colors.accent }]}> - <Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text> - </View> - )} </View> </View> @@ -240,7 +171,7 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, i </TouchableOpacity> </Animated.View> ); -}; +}); const QualityTag = React.memo(({ text, color, theme }: { text: string; color: string; theme: any }) => { const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]); @@ -252,6 +183,41 @@ const QualityTag = React.memo(({ text, color, theme }: { text: string; color: st ); }); +const PulsingChip = memo(({ text, delay }: { text: string; delay: number }) => { + const { currentTheme } = useTheme(); + const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]); + + const pulseValue = useSharedValue(0.6); + + useEffect(() => { + const startPulse = () => { + pulseValue.value = withTiming(1, { duration: 1200 }, () => { + pulseValue.value = withTiming(0.6, { duration: 1200 }, () => { + runOnJS(startPulse)(); + }); + }); + }; + + const timer = setTimeout(startPulse, delay); + return () => { + clearTimeout(timer); + cancelAnimation(pulseValue); + }; + }, [delay]); + + const animatedStyle = useAnimatedStyle(() => { + return { + opacity: pulseValue.value + }; + }); + + return ( + <Animated.View style={[styles.activeScraperChip, animatedStyle]}> + <Text style={styles.activeScraperText}>{text}</Text> + </Animated.View> + ); +}); + const ProviderFilter = memo(({ selectedProvider, providers, @@ -268,7 +234,7 @@ const ProviderFilter = memo(({ const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => ( <Animated.View entering={FadeIn.duration(300).delay(100 + index * 40)} - layout={Layout.springify()} + exiting={FadeOut.duration(150)} > <TouchableOpacity key={item.id} @@ -318,7 +284,7 @@ const ProviderFilter = memo(({ export const StreamsScreen = () => { const route = useRoute<RouteProp<RootStackParamList, 'Streams'>>(); const navigation = useNavigation<RootStackNavigationProp>(); - const { id, type, episodeId } = route.params; + const { id, type, episodeId, episodeThumbnail } = route.params; const { settings } = useSettings(); const { currentTheme } = useTheme(); const { colors } = currentTheme; @@ -328,14 +294,10 @@ export const StreamsScreen = () => { const loadStartTimeRef = useRef(0); const hasDoneInitialLoadRef = useRef(false); - // Add state for handling orientation transition - const [isTransitioning, setIsTransitioning] = useState(false); - // Add state to prevent animation conflicts during exit - const [isExiting, setIsExiting] = useState(false); - // Add timing logs - const [loadStartTime, setLoadStartTime] = useState(0); + // Track when we started fetching streams so we can show an extended loading state + const [streamsLoadStart, setStreamsLoadStart] = useState<number | null>(null); const [providerLoadTimes, setProviderLoadTimes] = useState<{[key: string]: number}>({}); // Prevent excessive re-renders by using this guard @@ -345,6 +307,10 @@ export const StreamsScreen = () => { } }, []); + useEffect(() => { + console.log('[StreamsScreen] Received thumbnail from params:', episodeThumbnail); + }, [episodeThumbnail]); + const { metadata, episodes, @@ -358,8 +324,15 @@ export const StreamsScreen = () => { setSelectedEpisode, groupedEpisodes, imdbId, + scraperStatuses, + activeFetchingScrapers, } = useMetadata({ id, type }); + // Get backdrop from metadata assets + const setMetadataStub = useCallback(() => {}, []); + const memoizedSettings = useMemo(() => settings, [settings.logoSourcePreference, settings.tmdbLanguagePreference]); + const { bannerImage } = useMetadataAssets(metadata, id, type, imdbId, memoizedSettings, setMetadataStub); + // Create styles using current theme colors const styles = React.useMemo(() => createStyles(colors), [colors]); @@ -416,7 +389,7 @@ export const StreamsScreen = () => { } // Update loading states for individual providers - const expectedProviders = ['stremio', 'hdrezka']; + const expectedProviders = ['stremio']; const now = Date.now(); setLoadingProviders(prevLoading => { @@ -443,9 +416,14 @@ export const StreamsScreen = () => { // Update useEffect to check for sources useEffect(() => { const checkProviders = async () => { - // Check for both Stremio addons and if the internal provider is enabled + // Check for Stremio addons const hasStremioProviders = await stremioService.hasStreamProviders(); - const hasProviders = hasStremioProviders || settings.enableInternalProviders; + + // Check for local scrapers (only if enabled in settings) + const hasLocalScrapers = settings.enableLocalScrapers && await localScraperService.hasScrapers(); + + // We have providers if we have either Stremio addons OR enabled local scrapers + const hasProviders = hasStremioProviders || hasLocalScrapers; if (!isMounted.current) return; @@ -461,13 +439,14 @@ export const StreamsScreen = () => { if (type === 'series' && episodeId) { logger.log(`🎬 Loading episode streams for: ${episodeId}`); setLoadingProviders({ - 'stremio': true, - 'hdrezka': true + 'stremio': true }); setSelectedEpisode(episodeId); + setStreamsLoadStart(Date.now()); loadEpisodeStreams(episodeId); } else if (type === 'movie') { logger.log(`🎬 Loading movie streams for: ${id}`); + setStreamsLoadStart(Date.now()); loadStreams(); } @@ -483,7 +462,7 @@ export const StreamsScreen = () => { }; checkProviders(); - }, [type, id, episodeId, settings.autoplayBestStream, settings.enableInternalProviders]); + }, [type, id, episodeId, settings.autoplayBestStream]); React.useEffect(() => { // Trigger entrance animations @@ -506,9 +485,6 @@ export const StreamsScreen = () => { // Memoize handlers const handleBack = useCallback(() => { - // Set exit state to prevent animation conflicts and hide content immediately - setIsExiting(true); - const cleanup = () => { headerOpacity.value = withTiming(0, { duration: 100 }); heroScale.value = withTiming(0.95, { duration: 100 }); @@ -561,8 +537,6 @@ export const StreamsScreen = () => { // Provider priority (higher number = higher priority) const getProviderPriority = (addonId: string): number => { - if (addonId === 'hdrezka') return 100; // HDRezka highest priority - // Get Stremio addon installation order (earlier = higher priority) const installedAddons = stremioService.getInstalledAddons(); const addonIndex = installedAddons.findIndex(addon => addon.id === addonId); @@ -651,8 +625,7 @@ export const StreamsScreen = () => { const streamsToPass = type === 'series' ? episodeStreams : groupedStreams; // Determine the stream name using the same logic as StreamCard - const isHDRezka = stream.name === 'HDRezka'; - const streamName = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream'); + const streamName = stream.name || stream.title || 'Unnamed Stream'; navigation.navigate('Player', { uri: stream.url, @@ -669,6 +642,7 @@ export const StreamsScreen = () => { episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, imdbId: imdbId || undefined, availableStreams: streamsToPass, + backdrop: bannerImage || undefined, }); } catch (error) { logger.error('[StreamsScreen] Error locking orientation before navigation:', error); @@ -689,9 +663,10 @@ export const StreamsScreen = () => { episodeId: type === 'series' && selectedEpisode ? selectedEpisode : undefined, imdbId: imdbId || undefined, availableStreams: streamsToPass, + backdrop: bannerImage || undefined, }); } - }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams]); + }, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage]); // Update handleStreamPress const handleStreamPress = useCallback(async (stream: Stream) => { @@ -892,11 +867,7 @@ export const StreamsScreen = () => { { id: 'all', name: 'All Providers' }, ...Array.from(allProviders) .sort((a, b) => { - // Put HDRezka first - if (a === 'hdrezka') return -1; - if (b === 'hdrezka') return 1; - - // Then sort by Stremio addon installation order + // Sort by Stremio addon installation order const indexA = installedAddons.findIndex(addon => addon.id === a); const indexB = installedAddons.findIndex(addon => addon.id === b); @@ -908,11 +879,6 @@ export const StreamsScreen = () => { .map(provider => { const addonInfo = streams[provider]; - // Special handling for HDRezka - if (provider === 'hdrezka') { - return { id: provider, name: 'HDRezka' }; - } - // Standard handling for Stremio addons const installedAddon = installedAddons.find(addon => addon.id === provider); @@ -929,48 +895,6 @@ export const StreamsScreen = () => { const streams = type === 'series' ? episodeStreams : groupedStreams; const installedAddons = stremioService.getInstalledAddons(); - // Helper function to extract quality as a number for sorting - const getQualityNumeric = (title: string | undefined): number => { - if (!title) return 0; - - // First try to match quality with "p" (e.g., "1080p", "720p") - const matchWithP = title.match(/(\d+)p/i); - if (matchWithP) { - return parseInt(matchWithP[1], 10); - } - - // Then try to match standalone quality numbers at the end of the title - const matchAtEnd = title.match(/\b(\d{3,4})\s*$/); - if (matchAtEnd) { - const quality = parseInt(matchAtEnd[1], 10); - // Only return if it looks like a video quality (between 240 and 8000) - if (quality >= 240 && quality <= 8000) { - return quality; - } - } - - // Try to match quality patterns anywhere in the title with common formats - const qualityPatterns = [ - /\b(\d{3,4})p\b/i, // 1080p, 720p, etc. - /\b(\d{3,4})\s*$/, // 1080, 720 at end - /\s(\d{3,4})\s/, // 720 surrounded by spaces - /-\s*(\d{3,4})\s*$/, // -720 at end - /\b(240|360|480|720|1080|1440|2160|4320|8000)\b/i // specific quality values - ]; - - 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; - }; - // Filter streams by selected provider - only if not "all" const filteredEntries = Object.entries(streams) .filter(([addonId]) => { @@ -982,11 +906,7 @@ export const StreamsScreen = () => { return addonId === selectedProvider; }) .sort(([addonIdA], [addonIdB]) => { - // Put HDRezka first - if (addonIdA === 'hdrezka') return -1; - if (addonIdB === 'hdrezka') return 1; - - // Then sort by Stremio addon installation order + // Sort by Stremio addon installation order const indexA = installedAddons.findIndex(addon => addon.id === addonIdA); const indexB = installedAddons.findIndex(addon => addon.id === addonIdB); @@ -996,25 +916,10 @@ export const StreamsScreen = () => { return 0; }) .map(([addonId, { addonName, streams: providerStreams }]) => { - let sortedProviderStreams = providerStreams; - if (addonId === 'hdrezka') { - sortedProviderStreams = [...providerStreams].sort((a, b) => { - const qualityA = getQualityNumeric(a.title); - const qualityB = getQualityNumeric(b.title); - return qualityB - qualityA; // Sort descending (e.g., 1080p before 720p) - }); - } else { - // Sort other streams by quality if possible - sortedProviderStreams = [...providerStreams].sort((a, b) => { - const qualityA = getQualityNumeric(a.name || a.title); - const qualityB = getQualityNumeric(b.name || b.title); - return qualityB - qualityA; // Sort descending - }); - } return { title: addonName, addonId, - data: sortedProviderStreams + data: providerStreams }; }); @@ -1022,16 +927,31 @@ export const StreamsScreen = () => { }, [selectedProvider, type, episodeStreams, groupedStreams]); const episodeImage = useMemo(() => { + if (episodeThumbnail) { + if (episodeThumbnail.startsWith('http')) { + return episodeThumbnail; + } + return tmdbService.getImageUrl(episodeThumbnail, 'original'); + } if (!currentEpisode) return null; if (currentEpisode.still_path) { + if (currentEpisode.still_path.startsWith('http')) { + return currentEpisode.still_path; + } return tmdbService.getImageUrl(currentEpisode.still_path, 'original'); } return metadata?.poster || null; - }, [currentEpisode, metadata]); + }, [currentEpisode, metadata, episodeThumbnail]); const isLoading = type === 'series' ? loadingEpisodeStreams : loadingStreams; const streams = type === 'series' ? episodeStreams : groupedStreams; + // Determine extended loading phases + const streamsEmpty = Object.keys(streams).length === 0; + const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0; + const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000); + const showStillFetching = streamsEmpty && loadElapsed >= 10000; + const heroStyle = useAnimatedStyle(() => ({ transform: [{ scale: heroScale.value }], opacity: headerOpacity.value @@ -1065,10 +985,9 @@ export const StreamsScreen = () => { isLoading={isLoading} statusMessage={undefined} theme={currentTheme} - isExiting={isExiting} /> ); - }, [handleStreamPress, currentTheme, isExiting]); + }, [handleStreamPress, currentTheme]); const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => { const isProviderLoading = loadingProviders[section.addonId]; @@ -1076,7 +995,7 @@ export const StreamsScreen = () => { return ( <Animated.View entering={FadeIn.duration(400)} - layout={Layout.springify()} + exiting={FadeOut.duration(150)} style={styles.sectionHeaderContainer} > <View style={styles.sectionHeaderContent}> @@ -1101,43 +1020,7 @@ export const StreamsScreen = () => { }; }, []); - // Add orientation handling when screen comes into focus - useFocusEffect( - useCallback(() => { - // Set transitioning state to mask any visual glitches - setIsTransitioning(true); - - // Immediately lock to portrait when returning to this screen - const lockToPortrait = async () => { - try { - await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP); - // Small delay then unlock to allow natural portrait orientation - setTimeout(async () => { - try { - await ScreenOrientation.unlockAsync(); - // Clear transition state after orientation is handled - setTimeout(() => { - setIsTransitioning(false); - }, 100); - } catch (error) { - logger.error('[StreamsScreen] Error unlocking orientation:', error); - setIsTransitioning(false); - } - }, 200); - } catch (error) { - logger.error('[StreamsScreen] Error locking to portrait:', error); - setIsTransitioning(false); - } - }; - lockToPortrait(); - - return () => { - // Cleanup when screen loses focus - setIsTransitioning(false); - }; - }, []) - ); return ( <View style={styles.container}> @@ -1147,17 +1030,6 @@ export const StreamsScreen = () => { barStyle="light-content" /> - {/* Instant overlay when exiting to prevent glitches */} - {isExiting && ( - <View style={[StyleSheet.absoluteFill, { backgroundColor: colors.darkBackground, zIndex: 100 }]} /> - )} - - {/* Transition overlay to mask orientation changes */} - {isTransitioning && ( - <View style={styles.transitionOverlay}> - <ActivityIndicator size="small" color={colors.primary} /> - </View> - )} <Animated.View entering={FadeIn.duration(300)} @@ -1196,11 +1068,11 @@ export const StreamsScreen = () => { {type === 'series' && currentEpisode && ( <Animated.View style={[styles.streamsHeroContainer, heroStyle]}> <Animated.View - entering={FadeIn.duration(600).springify()} + entering={FadeIn.duration(300)} style={StyleSheet.absoluteFill} > <Animated.View - entering={FadeIn.duration(800).delay(100).springify().withInitialValues({ + entering={FadeIn.duration(400).delay(100).withInitialValues({ transform: [{ scale: 1.05 }] })} style={StyleSheet.absoluteFill} @@ -1286,6 +1158,21 @@ export const StreamsScreen = () => { )} </Animated.View> + {/* Active Scrapers Status */} + {activeFetchingScrapers.length > 0 && ( + <Animated.View + entering={FadeIn.duration(300)} + style={styles.activeScrapersContainer} + > + <Text style={styles.activeScrapersTitle}>Fetching from:</Text> + <View style={styles.activeScrapersRow}> + {activeFetchingScrapers.map((scraperName, index) => ( + <PulsingChip key={scraperName} text={scraperName} delay={index * 200} /> + ))} + </View> + </Animated.View> + )} + {/* Update the streams/loading state display logic */} { showNoSourcesError ? ( <Animated.View @@ -1299,13 +1186,13 @@ export const StreamsScreen = () => { </Text> <TouchableOpacity style={styles.addSourcesButton} - onPress={() => navigation.navigate('Settings')} + onPress={() => navigation.navigate('Addons')} > <Text style={styles.addSourcesButtonText}>Add Sources</Text> </TouchableOpacity> </Animated.View> - ) : Object.keys(streams).length === 0 ? ( - (loadingStreams || loadingEpisodeStreams) ? ( + ) : streamsEmpty ? ( + showInitialLoading ? ( <Animated.View entering={FadeIn.duration(300)} style={styles.loadingContainer} @@ -1315,6 +1202,14 @@ export const StreamsScreen = () => { {isAutoplayWaiting ? 'Finding best stream for autoplay...' : 'Finding available streams...'} </Text> </Animated.View> + ) : showStillFetching ? ( + <Animated.View + entering={FadeIn.duration(300)} + style={styles.loadingContainer} + > + <MaterialIcons name="hourglass-bottom" size={32} color={colors.primary} /> + <Text style={styles.loadingText}>Still fetching streams…</Text> + </Animated.View> ) : ( // No streams and not loading = no streams available <Animated.View @@ -1347,9 +1242,9 @@ export const StreamsScreen = () => { renderItem={renderItem} renderSectionHeader={renderSectionHeader} stickySectionHeadersEnabled={false} - initialNumToRender={8} - maxToRenderPerBatch={4} - windowSize={5} + initialNumToRender={6} + maxToRenderPerBatch={3} + windowSize={4} removeClippedSubviews={false} contentContainerStyle={styles.streamsContainer} style={styles.streamsContent} @@ -1824,6 +1719,37 @@ const createStyles = (colors: any) => StyleSheet.create({ fontSize: 14, fontWeight: '600', }, + activeScrapersContainer: { + paddingHorizontal: 16, + paddingVertical: 8, + backgroundColor: 'transparent', + marginHorizontal: 16, + marginBottom: 4, + }, + activeScrapersTitle: { + color: colors.mediumEmphasis, + fontSize: 12, + fontWeight: '500', + marginBottom: 6, + opacity: 0.8, + }, + activeScrapersRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 4, + }, + activeScraperChip: { + backgroundColor: colors.elevation2, + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 6, + borderWidth: 0, + }, + activeScraperText: { + color: colors.mediumEmphasis, + fontSize: 11, + fontWeight: '400', + }, }); -export default memo(StreamsScreen); \ No newline at end of file +export default memo(StreamsScreen); \ No newline at end of file diff --git a/src/screens/TraktSettingsScreen.tsx b/src/screens/TraktSettingsScreen.tsx index ba6bb44..73a6fa1 100644 --- a/src/screens/TraktSettingsScreen.tsx +++ b/src/screens/TraktSettingsScreen.tsx @@ -248,6 +248,7 @@ const TraktSettingsScreen: React.FC = () => { </Text> {userProfile.vip && ( <View style={styles.vipBadge}> + <MaterialIcons name="star" size={14} color="#FFF" /> <Text style={styles.vipText}>VIP</Text> </View> )} @@ -267,13 +268,11 @@ const TraktSettingsScreen: React.FC = () => { style={[ styles.button, styles.signOutButton, - { backgroundColor: isDarkMode ? 'rgba(255,59,48,0.1)' : 'rgba(255,59,48,0.08)' } + { backgroundColor: currentTheme.colors.error } ]} onPress={handleSignOut} > - <Text style={[styles.buttonText, { color: '#FF3B30' }]}> - Sign Out - </Text> + <Text style={styles.buttonText}>Sign Out</Text> </TouchableOpacity> </View> ) : ( diff --git a/src/screens/index.ts b/src/screens/index.ts index 725e3ed..46e6bab 100644 --- a/src/screens/index.ts +++ b/src/screens/index.ts @@ -1,6 +1,5 @@ // Export all screens from a single file export { default as HomeScreen } from './HomeScreen'; -export { default as PlayerScreen } from './PlayerScreen'; export { default as SearchScreen } from './SearchScreen'; export { default as AddonsScreen } from './AddonsScreen'; export { default as SettingsScreen } from './SettingsScreen'; @@ -10,4 +9,5 @@ export { default as DiscoverScreen } from './DiscoverScreen'; export { default as LibraryScreen } from './LibraryScreen'; export { default as ShowRatingsScreen } from './ShowRatingsScreen'; export { default as CatalogSettingsScreen } from './CatalogSettingsScreen'; -export { default as StreamsScreen } from './StreamsScreen'; \ No newline at end of file +export { default as StreamsScreen } from './StreamsScreen'; +export { default as OnboardingScreen } from './OnboardingScreen'; \ No newline at end of file diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index d8ede4b..e256735 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -70,6 +70,7 @@ export interface StreamingContent { imdb_id?: string; slug?: string; releaseInfo?: string; + traktSource?: 'watchlist' | 'continue-watching' | 'watched'; } export interface CatalogContent { @@ -640,18 +641,47 @@ class CatalogService { }; } - public addToLibrary(content: StreamingContent): void { + public async addToLibrary(content: StreamingContent): Promise<void> { const key = `${content.type}:${content.id}`; this.library[key] = content; this.saveLibrary(); this.notifyLibrarySubscribers(); + + // Auto-setup notifications for series when added to library + if (content.type === 'series') { + try { + const { notificationService } = await import('./notificationService'); + await notificationService.updateNotificationsForSeries(content.id); + console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`); + } catch (error) { + console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); + } + } } - public removeFromLibrary(type: string, id: string): void { + public async removeFromLibrary(type: string, id: string): Promise<void> { const key = `${type}:${id}`; delete this.library[key]; this.saveLibrary(); this.notifyLibrarySubscribers(); + + // Cancel notifications for series when removed from library + if (type === 'series') { + try { + const { notificationService } = await import('./notificationService'); + // Cancel all notifications for this series + const scheduledNotifications = await notificationService.getScheduledNotifications(); + const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id); + + for (const notification of seriesToCancel) { + await notificationService.cancelNotification(notification.id); + } + + console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`); + } catch (error) { + console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); + } + } } private addToRecentContent(content: StreamingContent): void { diff --git a/src/services/hdrezkaService.ts b/src/services/hdrezkaService.ts deleted file mode 100644 index 0945924..0000000 --- a/src/services/hdrezkaService.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { logger } from '../utils/logger'; -import { Stream } from '../types/metadata'; -import { tmdbService } from './tmdbService'; -import axios from 'axios'; -import { settingsEmitter } from '../hooks/useSettings'; -import AsyncStorage from '@react-native-async-storage/async-storage'; - -// Use node-fetch if available, otherwise fallback to global fetch -let fetchImpl: typeof fetch; -try { - // @ts-ignore - fetchImpl = require('node-fetch'); -} catch { - fetchImpl = fetch; -} - -// Constants -const REZKA_BASE = 'https://hdrezka.ag/'; -const BASE_HEADERS = { - 'X-Hdrezka-Android-App': '1', - 'X-Hdrezka-Android-App-Version': '2.2.0', -}; - -class HDRezkaService { - private MAX_RETRIES = 3; - private RETRY_DELAY = 1000; // 1 second - - // No cookies/session logic needed for Android app API - private getHeaders() { - return { - ...BASE_HEADERS, - 'User-Agent': 'okhttp/4.9.0', - }; - } - - private generateRandomFavs(): string { - const randomHex = () => Math.floor(Math.random() * 16).toString(16); - const generateSegment = (length: number) => Array.from({ length }, () => randomHex()).join(''); - - return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`; - } - - private extractTitleAndYear(input: string): { title: string; year: number | null } | null { - // Handle multiple formats - - // Format 1: "Title, YEAR, Additional info" - const regex1 = /^(.*?),.*?(\d{4})/; - const match1 = input.match(regex1); - if (match1) { - const title = match1[1]; - const year = match1[2]; - return { title: title.trim(), year: year ? parseInt(year, 10) : null }; - } - - // Format 2: "Title (YEAR)" - const regex2 = /^(.*?)\s*\((\d{4})\)/; - const match2 = input.match(regex2); - if (match2) { - const title = match2[1]; - const year = match2[2]; - return { title: title.trim(), year: year ? parseInt(year, 10) : null }; - } - - // Format 3: Look for any 4-digit year in the string - const yearMatch = input.match(/(\d{4})/); - if (yearMatch) { - const year = yearMatch[1]; - // Remove the year and any surrounding brackets/parentheses from the title - let title = input.replace(/\s*\(\d{4}\)|\s*\[\d{4}\]|\s*\d{4}/, ''); - return { title: title.trim(), year: year ? parseInt(year, 10) : null }; - } - - // If no year found but we have a title - if (input.trim()) { - return { title: input.trim(), year: null }; - } - - return null; - } - - private parseVideoLinks(inputString: string | undefined): Record<string, { type: string; url: string }> { - if (!inputString) { - logger.log('[HDRezka] No video links found'); - return {}; - } - - logger.log(`[HDRezka] Parsing video links from stream URL data`); - const linksArray = inputString.split(','); - const result: Record<string, { type: string; url: string }> = {}; - - linksArray.forEach((link) => { - // Handle different quality formats: - // 1. Simple format: [360p]https://example.com/video.mp4 - // 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4 - - // Try simple format first (non-HTML) - let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/); - - // If not found, try HTML format with more flexible pattern - if (!match) { - // Extract quality text from HTML span - const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/); - // Extract URL separately - const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/); - - if (qualityMatch && urlMatch) { - match = [link, qualityMatch[1].trim(), urlMatch[1]] as RegExpMatchArray; - } - } - - if (match) { - const qualityText = match[1].trim(); - const mp4Url = match[2]; - - // Skip null URLs (premium content that requires login) - if (mp4Url !== 'null') { - result[qualityText] = { type: 'mp4', url: mp4Url }; - logger.log(`[HDRezka] Found ${qualityText}: ${mp4Url}`); - } else { - logger.log(`[HDRezka] Premium quality ${qualityText} requires login (null URL)`); - } - } else { - logger.log(`[HDRezka] Could not parse quality from: ${link}`); - } - }); - - logger.log(`[HDRezka] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`); - return result; - } - - private parseSubtitles(inputString: string | undefined): Array<{ - id: string; - language: string; - hasCorsRestrictions: boolean; - type: string; - url: string; - }> { - if (!inputString) { - logger.log('[HDRezka] No subtitles found'); - return []; - } - - logger.log(`[HDRezka] Parsing subtitles data`); - const linksArray = inputString.split(','); - const captions: Array<{ - id: string; - language: string; - hasCorsRestrictions: boolean; - type: string; - url: string; - }> = []; - - linksArray.forEach((link) => { - const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/); - - if (match) { - const language = match[1]; - const url = match[2]; - - captions.push({ - id: url, - language, - hasCorsRestrictions: false, - type: 'vtt', - url: url, - }); - logger.log(`[HDRezka] Found subtitle ${language}: ${url}`); - } - }); - - logger.log(`[HDRezka] Found ${captions.length} subtitles`); - return captions; - } - - async searchAndFindMediaId(media: { title: string; type: string; releaseYear?: number }): Promise<{ - id: string; - year: number; - type: string; - url: string; - title: string; - } | null> { - logger.log(`[HDRezka] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`); - - const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g; - const idRegexPattern = /\/(\d+)-[^/]+\.html$/; - - const fullUrl = new URL('/engine/ajax/search.php', REZKA_BASE); - fullUrl.searchParams.append('q', media.title); - - logger.log(`[HDRezka] Making search request to: ${fullUrl.toString()}`); - try { - const response = await fetchImpl(fullUrl.toString(), { - method: 'GET', - headers: this.getHeaders() - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const searchData = await response.text(); - logger.log(`[HDRezka] Search response length: ${searchData.length}`); - - const movieData: Array<{ - id: string; - year: number; - type: string; - url: string; - title: string; - }> = []; - - let match; - - while ((match = itemRegexPattern.exec(searchData)) !== null) { - const url = match[1]; - const titleAndYear = match[3]; - - const result = this.extractTitleAndYear(titleAndYear); - if (result !== null) { - const id = url.match(idRegexPattern)?.[1] || null; - const isMovie = url.includes('/films/'); - const isShow = url.includes('/series/'); - const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown'; - - movieData.push({ - id: id ?? '', - year: result.year ?? 0, - type, - url, - title: match[2] - }); - logger.log(`[HDRezka] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`); - } - } - - // If year is provided, filter by year - let filteredItems = movieData; - if (media.releaseYear) { - filteredItems = movieData.filter(item => item.year === media.releaseYear); - logger.log(`[HDRezka] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`); - } - - // If type is provided, filter by type - if (media.type) { - filteredItems = filteredItems.filter(item => item.type === media.type); - logger.log(`[HDRezka] Items filtered by type ${media.type}: ${filteredItems.length}`); - } - - if (filteredItems.length > 0) { - logger.log(`[HDRezka] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`); - return filteredItems[0]; - } else if (movieData.length > 0) { - logger.log(`[HDRezka] No exact match, using first result: id=${movieData[0].id}, title=${movieData[0].title}`); - return movieData[0]; - } else { - logger.log(`[HDRezka] No matching items found`); - return null; - } - } catch (error) { - logger.error(`[HDRezka] Search request failed: ${error}`); - return null; - } - } - - async getTranslatorId(url: string, id: string, mediaType: string): Promise<string | null> { - logger.log(`[HDRezka] Getting translator ID for url=${url}, id=${id}`); - - // Make sure the URL is absolute - const fullUrl = url.startsWith('http') ? url : `${REZKA_BASE}${url.startsWith('/') ? url.substring(1) : url}`; - logger.log(`[HDRezka] Making request to: ${fullUrl}`); - - try { - const response = await fetchImpl(fullUrl, { - method: 'GET', - headers: this.getHeaders(), - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const responseText = await response.text(); - logger.log(`[HDRezka] Translator page response length: ${responseText.length}`); - - // 1. Check for "Original + Subtitles" specific ID (often ID 238) - if (responseText.includes(`data-translator_id="238"`)) { - logger.log(`[HDRezka] Found specific translator ID 238 (Original + subtitles)`); - return '238'; - } - - // 2. Try to extract from the main CDN init function (e.g., initCDNMoviesEvents, initCDNSeriesEvents) - const functionName = mediaType === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents'; - const cdnEventsRegex = new RegExp(`sof\.tv\.${functionName}\(${id}, ([^,]+)`, 'i'); - const cdnEventsMatch = responseText.match(cdnEventsRegex); - - if (cdnEventsMatch && cdnEventsMatch[1]) { - const translatorIdFromCdn = cdnEventsMatch[1].trim().replace(/['"]/g, ''); // Remove potential quotes - if (translatorIdFromCdn && translatorIdFromCdn !== 'false' && translatorIdFromCdn !== 'null') { - logger.log(`[HDRezka] Extracted translator ID from CDN init: ${translatorIdFromCdn}`); - return translatorIdFromCdn; - } - } - logger.log(`[HDRezka] CDN init function did not yield a valid translator ID.`); - - // 3. Fallback: Try to find any other data-translator_id attribute in the HTML - // This regex looks for data-translator_id="<digits>" - const anyTranslatorRegex = /data-translator_id="(\d+)"/; - const anyTranslatorMatch = responseText.match(anyTranslatorRegex); - - if (anyTranslatorMatch && anyTranslatorMatch[1]) { - const fallbackTranslatorId = anyTranslatorMatch[1].trim(); - logger.log(`[HDRezka] Found fallback translator ID from data attribute: ${fallbackTranslatorId}`); - return fallbackTranslatorId; - } - logger.log(`[HDRezka] No fallback data-translator_id found.`); - - // If all attempts fail - logger.log(`[HDRezka] Could not find any translator ID for id ${id} on page ${fullUrl}`); - return null; - } catch (error) { - logger.error(`[HDRezka] Failed to get translator ID: ${error}`); - return null; - } - } - - async getStream(id: string, translatorId: string, media: { - type: string; - season?: { number: number }; - episode?: { number: number }; - }): Promise<any> { - logger.log(`[HDRezka] Getting stream for id=${id}, translatorId=${translatorId}`); - - const searchParams = new URLSearchParams(); - searchParams.append('id', id); - searchParams.append('translator_id', translatorId); - - if (media.type === 'show' && media.season && media.episode) { - searchParams.append('season', media.season.number.toString()); - searchParams.append('episode', media.episode.number.toString()); - logger.log(`[HDRezka] Show params: season=${media.season.number}, episode=${media.episode.number}`); - } - - const randomFavs = this.generateRandomFavs(); - searchParams.append('favs', randomFavs); - searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie'); - - const fullUrl = `${REZKA_BASE}ajax/get_cdn_series/`; - logger.log(`[HDRezka] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`); - - let attempts = 0; - const maxAttempts = 3; - - while (attempts < maxAttempts) { - attempts++; - try { - // Log the request details - logger.log('[HDRezka][AXIOS DEBUG]', { - url: fullUrl, - method: 'POST', - headers: this.getHeaders(), - data: searchParams.toString() - }); - const axiosResponse = await axios.post(fullUrl, searchParams.toString(), { - headers: { - ...this.getHeaders(), - 'Content-Type': 'application/x-www-form-urlencoded', - }, - validateStatus: () => true, - }); - logger.log('[HDRezka][AXIOS RESPONSE]', { - status: axiosResponse.status, - headers: axiosResponse.headers, - data: axiosResponse.data - }); - if (axiosResponse.status !== 200) { - throw new Error(`HTTP error! status: ${axiosResponse.status}`); - } - const responseText = typeof axiosResponse.data === 'string' ? axiosResponse.data : JSON.stringify(axiosResponse.data); - logger.log(`[HDRezka] Stream response length: ${responseText.length}`); - try { - const parsedResponse = typeof axiosResponse.data === 'object' ? axiosResponse.data : JSON.parse(responseText); - logger.log(`[HDRezka] Parsed response successfully: ${JSON.stringify(parsedResponse)}`); - if (!parsedResponse.success && parsedResponse.message) { - logger.error(`[HDRezka] Server returned error: ${parsedResponse.message}`); - if (attempts < maxAttempts) { - logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`); - continue; - } - return null; - } - const qualities = this.parseVideoLinks(parsedResponse.url); - const captions = this.parseSubtitles(parsedResponse.subtitle); - return { - qualities, - captions - }; - } catch (e: unknown) { - const error = e instanceof Error ? e.message : String(e); - logger.error(`[HDRezka] Failed to parse JSON response: ${error}`); - if (attempts < maxAttempts) { - logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`); - continue; - } - return null; - } - } catch (error) { - logger.error(`[HDRezka] Stream request failed: ${error}`); - if (attempts < maxAttempts) { - logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`); - continue; - } - return null; - } - } - logger.error(`[HDRezka] All stream request attempts failed`); - return null; - } - - async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> { - try { - logger.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`); - - // Check if internal providers are enabled globally - const appSettingsJson = await AsyncStorage.getItem('app_settings'); - if (appSettingsJson) { - const appSettings = JSON.parse(appSettingsJson); - if (appSettings.enableInternalProviders === false) { - logger.log('[HDRezka] Internal providers are disabled in settings, skipping HDRezka'); - return []; - } - } - - // Check if HDRezka specifically is enabled - const hdrezkaSettingsJson = await AsyncStorage.getItem('hdrezka_settings'); - if (hdrezkaSettingsJson) { - const hdrezkaSettings = JSON.parse(hdrezkaSettingsJson); - if (hdrezkaSettings.enabled === false) { - logger.log('[HDRezka] HDRezka provider is disabled in settings, skipping HDRezka'); - return []; - } - } - - // First, extract the actual title from TMDB if this is an ID - let title = mediaId; - let year: number | undefined = undefined; - - if (mediaId.startsWith('tt') || mediaId.startsWith('tmdb:')) { - let tmdbId: number | null = null; - - // Handle IMDB IDs - if (mediaId.startsWith('tt')) { - logger.log(`[HDRezka] Converting IMDB ID to TMDB ID: ${mediaId}`); - tmdbId = await tmdbService.findTMDBIdByIMDB(mediaId); - } - // Handle TMDB IDs - else if (mediaId.startsWith('tmdb:')) { - tmdbId = parseInt(mediaId.split(':')[1], 10); - } - - if (tmdbId) { - // Fetch metadata from TMDB API - if (mediaType === 'movie') { - logger.log(`[HDRezka] Fetching movie details from TMDB for ID: ${tmdbId}`); - const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString()); - if (movieDetails) { - title = movieDetails.title; - year = movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4), 10) : undefined; - logger.log(`[HDRezka] Using movie title "${title}" (${year}) for search`); - } - } else { - logger.log(`[HDRezka] Fetching TV show details from TMDB for ID: ${tmdbId}`); - const showDetails = await tmdbService.getTVShowDetails(tmdbId); - if (showDetails) { - title = showDetails.name; - year = showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4), 10) : undefined; - logger.log(`[HDRezka] Using TV show title "${title}" (${year}) for search`); - } - } - } - } - - const media = { - title, - type: mediaType === 'movie' ? 'movie' : 'show', - releaseYear: year - }; - - // Step 1: Search and find media ID - const searchResult = await this.searchAndFindMediaId(media); - if (!searchResult || !searchResult.id) { - logger.log('[HDRezka] No search results found'); - return []; - } - - // Step 2: Get translator ID - const translatorId = await this.getTranslatorId( - searchResult.url, - searchResult.id, - media.type - ); - - if (!translatorId) { - logger.log('[HDRezka] No translator ID found'); - return []; - } - - // Step 3: Get stream - const streamParams = { - type: media.type, - season: season ? { number: season } : undefined, - episode: episode ? { number: episode } : undefined - }; - - const streamData = await this.getStream(searchResult.id, translatorId, streamParams); - if (!streamData) { - logger.log('[HDRezka] No stream data found'); - return []; - } - - // Convert to Stream format - const streams: Stream[] = []; - - Object.entries(streamData.qualities).forEach(([quality, data]: [string, any]) => { - streams.push({ - name: 'HDRezka', - title: quality, - url: data.url, - behaviorHints: { - notWebReady: false - } - }); - }); - - logger.log(`[HDRezka] Found ${streams.length} streams`); - return streams; - } catch (error) { - logger.error(`[HDRezka] Error getting streams: ${error}`); - return []; - } - } -} - -export const hdrezkaService = new HDRezkaService(); \ No newline at end of file diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts index e5a8bca..be0028b 100644 --- a/src/services/imageCacheService.ts +++ b/src/services/imageCacheService.ts @@ -1,16 +1,30 @@ import { logger } from '../utils/logger'; +import { Image as ExpoImage } from 'expo-image'; interface CachedImage { url: string; localPath: string; timestamp: number; expiresAt: number; + size?: number; // Track approximate memory usage + accessCount: number; // Track usage frequency + lastAccessed: number; // Track last access time } class ImageCacheService { private cache = new Map<string, CachedImage>(); - private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours - private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images + private readonly CACHE_DURATION = 12 * 60 * 60 * 1000; // Reduced to 12 hours + private readonly MAX_CACHE_SIZE = 50; // Reduced maximum number of cached images + private readonly MAX_MEMORY_MB = 100; // Maximum memory usage in MB + private currentMemoryUsage = 0; + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor() { + // Start cleanup interval every 10 minutes + this.cleanupInterval = setInterval(() => { + this.performCleanup(); + }, 10 * 60 * 1000); + } /** * Get a cached image URL or cache the original if not present @@ -23,24 +37,38 @@ class ImageCacheService { // Check if we have a valid cached version const cached = this.cache.get(originalUrl); if (cached && cached.expiresAt > Date.now()) { - logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`); + // Update access tracking + cached.accessCount++; + cached.lastAccessed = Date.now(); + logger.log(`[ImageCache] Retrieved from cache: ${originalUrl.substring(0, 50)}...`); return cached.localPath; } + // Check memory pressure before adding new entries + if (this.shouldSkipCaching()) { + logger.log(`[ImageCache] Skipping cache due to memory pressure`); + return originalUrl; + } + try { - // For now, return the original URL but mark it as cached - // In a production app, you would implement actual local caching here + // Estimate image size (rough approximation) + const estimatedSize = this.estimateImageSize(originalUrl); + const cachedImage: CachedImage = { url: originalUrl, localPath: originalUrl, // In production, this would be a local file path timestamp: Date.now(), expiresAt: Date.now() + this.CACHE_DURATION, + size: estimatedSize, + accessCount: 1, + lastAccessed: Date.now() }; this.cache.set(originalUrl, cachedImage); - this.enforceMaxCacheSize(); + this.currentMemoryUsage += estimatedSize; + this.enforceMemoryLimits(); - logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Cache size: ${this.cache.size})`); + logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl.substring(0, 50)}... (Cache: ${this.cache.size}/${this.MAX_CACHE_SIZE}, Memory: ${(this.currentMemoryUsage / 1024 / 1024).toFixed(1)}MB)`); return cachedImage.localPath; } catch (error) { logger.error('[ImageCache] Failed to cache image:', error); @@ -62,7 +90,7 @@ class ImageCacheService { public logCacheStatus(): void { const stats = this.getCacheStats(); logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`); - + // Log first 5 cached URLs for debugging const entries = Array.from(this.cache.entries()).slice(0, 5); entries.forEach(([url, cached]) => { @@ -98,7 +126,7 @@ class ImageCacheService { public getCacheStats(): { size: number; expired: number } { const now = Date.now(); let expired = 0; - + for (const cached of this.cache.values()) { if (cached.expiresAt <= now) { expired++; @@ -132,6 +160,112 @@ class ImageCacheService { logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`); } + + /** + * Enforce memory limits using LRU eviction + */ + private enforceMemoryLimits(): void { + const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024; + + if (this.currentMemoryUsage <= maxMemoryBytes) { + return; + } + + // Sort by access frequency and recency (LRU) + const entries = Array.from(this.cache.entries()).sort((a, b) => { + const scoreA = a[1].accessCount * 0.3 + (Date.now() - a[1].lastAccessed) * 0.7; + const scoreB = b[1].accessCount * 0.3 + (Date.now() - b[1].lastAccessed) * 0.7; + return scoreB - scoreA; // Higher score = more likely to be evicted + }); + + let removedCount = 0; + for (const [url, cached] of entries) { + if (this.currentMemoryUsage <= maxMemoryBytes * 0.8) { // Leave 20% buffer + break; + } + + this.cache.delete(url); + this.currentMemoryUsage -= cached.size || 0; + removedCount++; + } + + if (removedCount > 0) { + logger.log(`[ImageCache] Evicted ${removedCount} entries to free memory. Current usage: ${(this.currentMemoryUsage / 1024 / 1024).toFixed(1)}MB`); + } + } + + /** + * Estimate image size based on URL patterns + */ + private estimateImageSize(url: string): number { + // Rough estimates in bytes based on common image types + if (url.includes('poster')) return 150 * 1024; // 150KB for posters + if (url.includes('banner') || url.includes('backdrop')) return 300 * 1024; // 300KB for banners + if (url.includes('logo')) return 50 * 1024; // 50KB for logos + if (url.includes('thumb')) return 75 * 1024; // 75KB for thumbnails + return 200 * 1024; // Default 200KB + } + + /** + * Check if we should skip caching due to memory pressure + */ + private shouldSkipCaching(): boolean { + const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024; + return this.currentMemoryUsage > maxMemoryBytes * 0.9 || this.cache.size >= this.MAX_CACHE_SIZE; + } + + /** + * Perform comprehensive cleanup + */ + private performCleanup(): void { + const initialSize = this.cache.size; + const initialMemory = this.currentMemoryUsage; + + // Remove expired entries + this.clearExpiredCache(); + + // Recalculate memory usage + this.recalculateMemoryUsage(); + + // Enforce limits + this.enforceMemoryLimits(); + this.enforceMaxCacheSize(); + + // Clear Expo image memory cache periodically + try { + ExpoImage.clearMemoryCache(); + } catch (error) { + // Ignore errors from clearing memory cache + } + + const finalSize = this.cache.size; + const finalMemory = this.currentMemoryUsage; + + if (initialSize !== finalSize || Math.abs(initialMemory - finalMemory) > 1024 * 1024) { + logger.log(`[ImageCache] Cleanup completed: ${initialSize}→${finalSize} entries, ${(initialMemory / 1024 / 1024).toFixed(1)}→${(finalMemory / 1024 / 1024).toFixed(1)}MB`); + } + } + + /** + * Recalculate memory usage from cache entries + */ + private recalculateMemoryUsage(): void { + this.currentMemoryUsage = 0; + for (const cached of this.cache.values()) { + this.currentMemoryUsage += cached.size || 0; + } + } + + /** + * Cleanup resources + */ + public destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.clearAllCache(); + } } export const imageCacheService = new ImageCacheService(); \ No newline at end of file diff --git a/src/services/localScraperService.ts b/src/services/localScraperService.ts new file mode 100644 index 0000000..52fb807 --- /dev/null +++ b/src/services/localScraperService.ts @@ -0,0 +1,606 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import axios from 'axios'; +import { logger } from '../utils/logger'; +import { Stream } from '../types/streams'; +import { cacheService } from './cacheService'; + +// Types for local scrapers +export interface ScraperManifest { + name: string; + version: string; + description: string; + author: string; + scrapers: ScraperInfo[]; +} + +export interface ScraperInfo { + id: string; + name: string; + description: string; + version: string; + filename: string; + supportedTypes: ('movie' | 'tv')[]; + enabled: boolean; + logo?: string; + contentLanguage?: string[]; +} + +export interface LocalScraperResult { + title: string; + name?: string; + url: string; + quality?: string; + size?: string; + language?: string; + provider?: string; + type?: string; + seeders?: number; + peers?: number; + infoHash?: string; + [key: string]: any; +} + +// Callback type for scraper results +type ScraperCallback = (streams: Stream[] | null, scraperId: string | null, scraperName: string | null, error: Error | null) => void; + +class LocalScraperService { + private static instance: LocalScraperService; + private readonly STORAGE_KEY = 'local-scrapers'; + private readonly REPOSITORY_KEY = 'scraper-repository-url'; + private readonly SCRAPER_SETTINGS_KEY = 'scraper-settings'; + private installedScrapers: Map<string, ScraperInfo> = new Map(); + private scraperCode: Map<string, string> = new Map(); + private repositoryUrl: string = ''; + private initialized: boolean = false; + + private constructor() { + this.initialize(); + } + + static getInstance(): LocalScraperService { + if (!LocalScraperService.instance) { + LocalScraperService.instance = new LocalScraperService(); + } + return LocalScraperService.instance; + } + + private async initialize(): Promise<void> { + if (this.initialized) return; + + try { + // Load repository URL + const storedRepoUrl = await AsyncStorage.getItem(this.REPOSITORY_KEY); + if (storedRepoUrl) { + this.repositoryUrl = storedRepoUrl; + } + + // Load installed scrapers + const storedScrapers = await AsyncStorage.getItem(this.STORAGE_KEY); + if (storedScrapers) { + const scrapers: ScraperInfo[] = JSON.parse(storedScrapers); + const validScrapers: ScraperInfo[] = []; + + scrapers.forEach(scraper => { + // Skip scrapers with missing essential fields + if (!scraper.id || !scraper.name || !scraper.version) { + logger.warn('[LocalScraperService] Skipping invalid scraper with missing essential fields:', scraper); + return; + } + + // Ensure contentLanguage is an array (migration for older scrapers) + if (!scraper.contentLanguage) { + scraper.contentLanguage = ['en']; // Default to English + } else if (typeof scraper.contentLanguage === 'string') { + scraper.contentLanguage = [scraper.contentLanguage]; // Convert string to array + } + + // Ensure supportedTypes is an array (migration for older scrapers) + if (!scraper.supportedTypes || !Array.isArray(scraper.supportedTypes)) { + scraper.supportedTypes = ['movie', 'tv']; // Default to both types + } + + // Ensure other required fields have defaults + if (!scraper.description) { + scraper.description = 'No description available'; + } + if (!scraper.filename) { + scraper.filename = `${scraper.id}.js`; + } + if (scraper.enabled === undefined) { + scraper.enabled = true; + } + + this.installedScrapers.set(scraper.id, scraper); + validScrapers.push(scraper); + }); + + // Save cleaned scrapers back to storage if any were filtered out + if (validScrapers.length !== scrapers.length) { + logger.log('[LocalScraperService] Cleaned up invalid scrapers, saving valid ones'); + await AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(validScrapers)); + + // Clean up cached code for removed scrapers + const validScraperIds = new Set(validScrapers.map(s => s.id)); + const removedScrapers = scrapers.filter(s => s.id && !validScraperIds.has(s.id)); + for (const removedScraper of removedScrapers) { + try { + await AsyncStorage.removeItem(`scraper-code-${removedScraper.id}`); + logger.log('[LocalScraperService] Removed cached code for invalid scraper:', removedScraper.id); + } catch (error) { + logger.error('[LocalScraperService] Failed to remove cached code for', removedScraper.id, ':', error); + } + } + } + } + + // Load scraper code from cache + await this.loadScraperCode(); + + // Auto-refresh repository on app startup if URL is configured + if (this.repositoryUrl) { + try { + logger.log('[LocalScraperService] Auto-refreshing repository on startup'); + await this.performRepositoryRefresh(); + } catch (error) { + logger.error('[LocalScraperService] Auto-refresh failed on startup:', error); + // Don't fail initialization if auto-refresh fails + } + } + + this.initialized = true; + logger.log('[LocalScraperService] Initialized with', this.installedScrapers.size, 'scrapers'); + } catch (error) { + logger.error('[LocalScraperService] Failed to initialize:', error); + this.initialized = true; // Set to true to prevent infinite retry + } + } + + private async ensureInitialized(): Promise<void> { + if (!this.initialized) { + await this.initialize(); + } + } + + // Set repository URL + async setRepositoryUrl(url: string): Promise<void> { + this.repositoryUrl = url; + await AsyncStorage.setItem(this.REPOSITORY_KEY, url); + logger.log('[LocalScraperService] Repository URL set to:', url); + } + + // Get repository URL + async getRepositoryUrl(): Promise<string> { + await this.ensureInitialized(); + return this.repositoryUrl; + } + + // Fetch and install scrapers from repository + async refreshRepository(): Promise<void> { + await this.ensureInitialized(); + await this.performRepositoryRefresh(); + } + + // Internal method to refresh repository without initialization check + private async performRepositoryRefresh(): Promise<void> { + if (!this.repositoryUrl) { + throw new Error('No repository URL configured'); + } + + try { + logger.log('[LocalScraperService] Fetching repository manifest from:', this.repositoryUrl); + + // Fetch manifest + const manifestUrl = this.repositoryUrl.endsWith('/') + ? `${this.repositoryUrl}manifest.json` + : `${this.repositoryUrl}/manifest.json`; + + const response = await axios.get(manifestUrl, { timeout: 10000 }); + const manifest: ScraperManifest = response.data; + + logger.log('[LocalScraperService] Found', manifest.scrapers.length, 'scrapers in repository'); + + // Download and install each scraper + for (const scraperInfo of manifest.scrapers) { + await this.downloadScraper(scraperInfo); + } + + await this.saveInstalledScrapers(); + logger.log('[LocalScraperService] Repository refresh completed'); + + } catch (error) { + logger.error('[LocalScraperService] Failed to refresh repository:', error); + throw error; + } + } + + // Download individual scraper + private async downloadScraper(scraperInfo: ScraperInfo): Promise<void> { + try { + const scraperUrl = this.repositoryUrl.endsWith('/') + ? `${this.repositoryUrl}${scraperInfo.filename}` + : `${this.repositoryUrl}/${scraperInfo.filename}`; + + logger.log('[LocalScraperService] Downloading scraper:', scraperInfo.name); + + const response = await axios.get(scraperUrl, { timeout: 15000 }); + const scraperCode = response.data; + + // Store scraper info and code + const updatedScraperInfo = { + ...scraperInfo, + enabled: this.installedScrapers.get(scraperInfo.id)?.enabled ?? true // Preserve enabled state + }; + + // Ensure contentLanguage is an array (migration for older scrapers) + if (!updatedScraperInfo.contentLanguage) { + updatedScraperInfo.contentLanguage = ['en']; // Default to English + } else if (typeof updatedScraperInfo.contentLanguage === 'string') { + updatedScraperInfo.contentLanguage = [updatedScraperInfo.contentLanguage]; // Convert string to array + } + + // Ensure supportedTypes is an array (migration for older scrapers) + if (!updatedScraperInfo.supportedTypes || !Array.isArray(updatedScraperInfo.supportedTypes)) { + updatedScraperInfo.supportedTypes = ['movie', 'tv']; // Default to both types + } + + this.installedScrapers.set(scraperInfo.id, updatedScraperInfo); + + this.scraperCode.set(scraperInfo.id, scraperCode); + + // Cache the scraper code + await this.cacheScraperCode(scraperInfo.id, scraperCode); + + logger.log('[LocalScraperService] Successfully downloaded:', scraperInfo.name); + + } catch (error) { + logger.error('[LocalScraperService] Failed to download scraper', scraperInfo.name, ':', error); + } + } + + // Cache scraper code locally + private async cacheScraperCode(scraperId: string, code: string): Promise<void> { + try { + await AsyncStorage.setItem(`scraper-code-${scraperId}`, code); + } catch (error) { + logger.error('[LocalScraperService] Failed to cache scraper code:', error); + } + } + + // Load scraper code from cache + private async loadScraperCode(): Promise<void> { + for (const [scraperId] of this.installedScrapers) { + try { + const cachedCode = await AsyncStorage.getItem(`scraper-code-${scraperId}`); + if (cachedCode) { + this.scraperCode.set(scraperId, cachedCode); + } + } catch (error) { + logger.error('[LocalScraperService] Failed to load cached code for', scraperId, ':', error); + } + } + } + + // Save installed scrapers to storage + private async saveInstalledScrapers(): Promise<void> { + try { + const scrapers = Array.from(this.installedScrapers.values()); + await AsyncStorage.setItem(this.STORAGE_KEY, JSON.stringify(scrapers)); + } catch (error) { + logger.error('[LocalScraperService] Failed to save scrapers:', error); + } + } + + // Get installed scrapers + async getInstalledScrapers(): Promise<ScraperInfo[]> { + await this.ensureInitialized(); + return Array.from(this.installedScrapers.values()); + } + + // Enable/disable scraper + async setScraperEnabled(scraperId: string, enabled: boolean): Promise<void> { + await this.ensureInitialized(); + + const scraper = this.installedScrapers.get(scraperId); + if (scraper) { + scraper.enabled = enabled; + this.installedScrapers.set(scraperId, scraper); + await this.saveInstalledScrapers(); + logger.log('[LocalScraperService] Scraper', scraperId, enabled ? 'enabled' : 'disabled'); + } + } + + // Execute scrapers for streams + async getStreams(type: string, tmdbId: string, season?: number, episode?: number, callback?: ScraperCallback): Promise<void> { + await this.ensureInitialized(); + + const enabledScrapers = Array.from(this.installedScrapers.values()) + .filter(scraper => scraper.enabled && scraper.supportedTypes.includes(type as 'movie' | 'tv')); + + if (enabledScrapers.length === 0) { + logger.log('[LocalScraperService] No enabled scrapers found for type:', type); + return; + } + + logger.log('[LocalScraperService] Executing', enabledScrapers.length, 'scrapers for', type, tmdbId); + + // Execute each scraper + for (const scraper of enabledScrapers) { + this.executeScraper(scraper, type, tmdbId, season, episode, callback); + } + } + + // Execute individual scraper + private async executeScraper( + scraper: ScraperInfo, + type: string, + tmdbId: string, + season?: number, + episode?: number, + callback?: ScraperCallback + ): Promise<void> { + try { + const code = this.scraperCode.get(scraper.id); + if (!code) { + throw new Error(`No code found for scraper ${scraper.id}`); + } + + logger.log('[LocalScraperService] Executing scraper:', scraper.name); + + // Create a sandboxed execution environment + const results = await this.executeSandboxed(code, { + tmdbId, + mediaType: type, + season, + episode + }); + + // Convert results to Nuvio Stream format + const streams = this.convertToStreams(results, scraper); + + if (callback) { + callback(streams, scraper.id, scraper.name, null); + } + + logger.log('[LocalScraperService] Scraper', scraper.name, 'returned', streams.length, 'streams'); + + } catch (error) { + logger.error('[LocalScraperService] Scraper', scraper.name, 'failed:', error); + if (callback) { + callback(null, scraper.id, scraper.name, error as Error); + } + } + } + + // Execute scraper code in sandboxed environment + private async executeSandboxed(code: string, params: any): Promise<LocalScraperResult[]> { + // This is a simplified sandbox - in production, you'd want more security + try { + // Get URL validation setting from AsyncStorage + const settingsData = await AsyncStorage.getItem('app_settings'); + const settings = settingsData ? JSON.parse(settingsData) : {}; + const urlValidationEnabled = settings.enableScraperUrlValidation ?? true; + + // Create a limited global context + const moduleExports = {}; + const moduleObj = { exports: moduleExports }; + + // Try to load cheerio-without-node-native + let cheerio = null; + try { + cheerio = require('cheerio-without-node-native'); + } catch (error) { + try { + cheerio = require('react-native-cheerio'); + } catch (error2) { + // Cheerio not available, scrapers will fall back to regex + } + } + + const sandbox = { + console: { + log: (...args: any[]) => logger.log('[Scraper]', ...args), + error: (...args: any[]) => logger.error('[Scraper]', ...args), + warn: (...args: any[]) => logger.warn('[Scraper]', ...args) + }, + setTimeout, + clearTimeout, + Promise, + JSON, + Date, + Math, + parseInt, + parseFloat, + encodeURIComponent, + decodeURIComponent, + // Add require function for specific modules + require: (moduleName: string) => { + switch (moduleName) { + case 'cheerio-without-node-native': + if (cheerio) return cheerio; + throw new Error('cheerio-without-node-native not available'); + case 'react-native-cheerio': + if (cheerio) return cheerio; + throw new Error('react-native-cheerio not available'); + default: + throw new Error(`Module '${moduleName}' is not available in sandbox`); + } + }, + // Add fetch for HTTP requests (using axios as polyfill) + fetch: async (url: string, options: any = {}) => { + const axiosConfig = { + url, + method: options.method || 'GET', + headers: options.headers || {}, + data: options.body, + timeout: 30000 + }; + + try { + const response = await axios(axiosConfig); + return { + ok: response.status >= 200 && response.status < 300, + status: response.status, + statusText: response.statusText, + headers: response.headers, + json: async () => response.data, + text: async () => typeof response.data === 'string' ? response.data : JSON.stringify(response.data) + }; + } catch (error: any) { + throw new Error(`Fetch failed: ${error.message}`); + } + }, + // Add axios for HTTP requests + axios: axios.create({ + timeout: 30000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + }), + // Node.js compatibility + module: moduleObj, + exports: moduleExports, + global: {}, // Empty global object + // URL validation setting + URL_VALIDATION_ENABLED: urlValidationEnabled + }; + + // Execute the scraper code with timeout + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Scraper execution timeout')), 60000); // 60 second timeout + }); + + const executionPromise = new Promise<LocalScraperResult[]>((resolve, reject) => { + try { + // Create function from code + const func = new Function('sandbox', 'params', ` + const { console, setTimeout, clearTimeout, Promise, JSON, Date, Math, parseInt, parseFloat, encodeURIComponent, decodeURIComponent, require, axios, fetch, module, exports, global, URL_VALIDATION_ENABLED } = sandbox; + ${code} + + // Call the main function (assuming it's exported) + if (typeof getStreams === 'function') { + return getStreams(params.tmdbId, params.mediaType, params.season, params.episode); + } else if (typeof module !== 'undefined' && module.exports && typeof module.exports.getStreams === 'function') { + return module.exports.getStreams(params.tmdbId, params.mediaType, params.season, params.episode); + } else if (typeof global !== 'undefined' && global.getStreams && typeof global.getStreams === 'function') { + return global.getStreams(params.tmdbId, params.mediaType, params.season, params.episode); + } else { + throw new Error('No getStreams function found in scraper'); + } + `); + + const result = func(sandbox, params); + + // Handle both sync and async results + if (result && typeof result.then === 'function') { + result.then(resolve).catch(reject); + } else { + resolve(result || []); + } + } catch (error) { + reject(error); + } + }); + + return await Promise.race([executionPromise, timeoutPromise]) as LocalScraperResult[]; + + } catch (error) { + logger.error('[LocalScraperService] Sandbox execution failed:', error); + throw error; + } + } + + // Convert scraper results to Nuvio Stream format + private convertToStreams(results: LocalScraperResult[], scraper: ScraperInfo): Stream[] { + if (!Array.isArray(results)) { + logger.warn('[LocalScraperService] Scraper returned non-array result'); + return []; + } + + return results.map((result, index) => { + // Build title with quality information for UI compatibility + let title = result.title || result.name || `${scraper.name} Stream ${index + 1}`; + + // Add quality to title if available and not already present + if (result.quality && !title.includes(result.quality)) { + title = `${title} ${result.quality}`; + } + + // Build name with quality information + let streamName = result.name || `${scraper.name}`; + if (result.quality && !streamName.includes(result.quality)) { + streamName = `${streamName} - ${result.quality}`; + } + + const stream: Stream = { + // Include quality in name field for proper display + name: streamName, + title: title, + url: result.url, + addon: scraper.id, + addonId: scraper.id, + addonName: scraper.name, + description: result.size ? `${result.size}` : undefined, + size: result.size ? this.parseSize(result.size) : undefined, + behaviorHints: { + bingeGroup: `local-scraper-${scraper.id}` + } + }; + + // Add additional properties if available + if (result.infoHash) { + stream.infoHash = result.infoHash; + } + + // Preserve any additional fields from the scraper result + if (result.quality && !stream.quality) { + stream.quality = result.quality; + } + + return stream; + }).filter(stream => stream.url); // Filter out streams without URLs + } + + // Parse size string to bytes + private parseSize(sizeStr: string): number { + if (!sizeStr) return 0; + + const match = sizeStr.match(/([0-9.]+)\s*(GB|MB|KB|TB)/i); + if (!match) return 0; + + const value = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + + switch (unit) { + case 'TB': return value * 1024 * 1024 * 1024 * 1024; + case 'GB': return value * 1024 * 1024 * 1024; + case 'MB': return value * 1024 * 1024; + case 'KB': return value * 1024; + default: return value; + } + } + + // Remove all scrapers + async clearScrapers(): Promise<void> { + this.installedScrapers.clear(); + this.scraperCode.clear(); + + // Clear from storage + await AsyncStorage.removeItem(this.STORAGE_KEY); + + // Clear cached code + const keys = await AsyncStorage.getAllKeys(); + const scraperCodeKeys = keys.filter(key => key.startsWith('scraper-code-')); + await AsyncStorage.multiRemove(scraperCodeKeys); + + logger.log('[LocalScraperService] All scrapers cleared'); + } + + // Check if local scrapers are available + async hasScrapers(): Promise<boolean> { + await this.ensureInitialized(); + return Array.from(this.installedScrapers.values()).some(scraper => scraper.enabled); + } +} + +export const localScraperService = LocalScraperService.getInstance(); +export default localScraperService; \ No newline at end of file diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 10e03f9..352d510 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -1,8 +1,11 @@ import * as Notifications from 'expo-notifications'; -import { Platform } from 'react-native'; +import { Platform, AppState, AppStateStatus } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { parseISO, differenceInHours, isToday, addDays } from 'date-fns'; +import { parseISO, differenceInHours, isToday, addDays, isAfter, startOfToday } from 'date-fns'; import { stremioService } from './stremioService'; +import { catalogService } from './catalogService'; +import { traktService } from './traktService'; +import { tmdbService } from './tmdbService'; import { logger } from '../utils/logger'; // Define notification storage keys @@ -47,12 +50,20 @@ class NotificationService { private static instance: NotificationService; private settings: NotificationSettings = DEFAULT_NOTIFICATION_SETTINGS; private scheduledNotifications: NotificationItem[] = []; + private backgroundSyncInterval: NodeJS.Timeout | null = null; + private librarySubscription: (() => void) | null = null; + private appStateSubscription: any = null; + private lastSyncTime: number = 0; + private readonly MIN_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes minimum between syncs private constructor() { // Initialize notifications this.configureNotifications(); this.loadSettings(); this.loadScheduledNotifications(); + this.setupLibraryIntegration(); + this.setupBackgroundSync(); + this.setupAppStateHandling(); } static getInstance(): NotificationService { @@ -140,6 +151,16 @@ class NotificationService { return null; } + // Check if notification already exists for this episode + const existingNotification = this.scheduledNotifications.find( + notification => notification.seriesId === item.seriesId && + notification.season === item.season && + notification.episode === item.episode + ); + if (existingNotification) { + return null; // Don't schedule duplicate notifications + } + const releaseDate = parseISO(item.releaseDate); const now = new Date(); @@ -153,9 +174,9 @@ class NotificationService { const notificationTime = new Date(releaseDate); notificationTime.setHours(notificationTime.getHours() - this.settings.timeBeforeAiring); - // If notification time has already passed, set to now + 1 minute + // If notification time has already passed, don't schedule the notification if (notificationTime < now) { - notificationTime.setTime(now.getTime() + 60000); + return null; } // Schedule the notification @@ -238,50 +259,418 @@ class NotificationService { return [...this.scheduledNotifications]; } - // Update notifications for a library item + // Setup library integration - automatically sync notifications when library changes + private setupLibraryIntegration(): void { + try { + // Subscribe to library updates from catalog service + this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => { + if (!this.settings.enabled) return; + + const now = Date.now(); + const timeSinceLastSync = now - this.lastSyncTime; + + // Only sync if enough time has passed since last sync + if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) { + // Reduced logging verbosity + // logger.log('[NotificationService] Library updated, syncing notifications for', libraryItems.length, 'items'); + await this.syncNotificationsForLibrary(libraryItems); + } else { + // logger.log(`[NotificationService] Library updated, but skipping sync (last sync ${Math.round(timeSinceLastSync / 1000)}s ago)`); + } + }); + } catch (error) { + logger.error('[NotificationService] Error setting up library integration:', error); + } + } + + // Setup background sync for notifications + private setupBackgroundSync(): void { + // Sync notifications every 6 hours + this.backgroundSyncInterval = setInterval(async () => { + if (this.settings.enabled) { + // Reduced logging verbosity + // logger.log('[NotificationService] Running background notification sync'); + await this.performBackgroundSync(); + } + }, 6 * 60 * 60 * 1000); // 6 hours + } + + // Setup app state handling for foreground sync + private setupAppStateHandling(): void { + const subscription = AppState.addEventListener('change', this.handleAppStateChange); + // Store subscription for cleanup + this.appStateSubscription = subscription; + } + + private handleAppStateChange = async (nextAppState: AppStateStatus) => { + if (nextAppState === 'active' && this.settings.enabled) { + const now = Date.now(); + const timeSinceLastSync = now - this.lastSyncTime; + + // Only sync if enough time has passed since last sync + if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) { + // App came to foreground, sync notifications + // Reduced logging verbosity + // logger.log('[NotificationService] App became active, syncing notifications'); + await this.performBackgroundSync(); + } else { + // logger.log(`[NotificationService] App became active, but skipping sync (last sync ${Math.round(timeSinceLastSync / 1000)}s ago)`); + } + } + }; + + // Sync notifications for all library items + private async syncNotificationsForLibrary(libraryItems: any[]): Promise<void> { + try { + const seriesItems = libraryItems.filter(item => item.type === 'series'); + + for (const series of seriesItems) { + await this.updateNotificationsForSeries(series.id); + // Small delay to prevent overwhelming the API + await new Promise(resolve => setTimeout(resolve, 100)); + } + + // Reduced logging verbosity + // logger.log(`[NotificationService] Synced notifications for ${seriesItems.length} series from library`); + } catch (error) { + logger.error('[NotificationService] Error syncing library notifications:', error); + } + } + + // Perform comprehensive background sync including Trakt integration + private async performBackgroundSync(): Promise<void> { + try { + // Update last sync time at the start + this.lastSyncTime = Date.now(); + + // Reduced logging verbosity + // logger.log('[NotificationService] Starting comprehensive background sync'); + + // Get library items + const libraryItems = catalogService.getLibraryItems(); + await this.syncNotificationsForLibrary(libraryItems); + + // Sync Trakt items if authenticated + await this.syncTraktNotifications(); + + // Clean up old notifications + await this.cleanupOldNotifications(); + + // Reduced logging verbosity + // logger.log('[NotificationService] Background sync completed'); + } catch (error) { + logger.error('[NotificationService] Error in background sync:', error); + } + } + + // Sync notifications for comprehensive Trakt data (same as calendar screen) + private async syncTraktNotifications(): Promise<void> { + try { + const isAuthenticated = await traktService.isAuthenticated(); + if (!traktService.isAuthenticated()) { + // Reduced logging verbosity + // logger.log('[NotificationService] Trakt not authenticated, skipping Trakt sync'); + return; + } + + // Reduced logging verbosity + // logger.log('[NotificationService] Syncing comprehensive Trakt notifications'); + + // Get all Trakt data sources (same as calendar screen uses) + const [watchlistShows, continueWatching, watchedShows, collectionShows] = await Promise.all([ + traktService.getWatchlistShows(), + traktService.getPlaybackProgress('shows'), // This is the continue watching data + traktService.getWatchedShows(), + traktService.getCollectionShows() + ]); + + // Combine and deduplicate shows using the same logic as calendar screen + const allTraktShows = new Map(); + + // Add watchlist shows + if (watchlistShows) { + watchlistShows.forEach((item: any) => { + if (item.show && item.show.ids.imdb) { + allTraktShows.set(item.show.ids.imdb, { + id: item.show.ids.imdb, + name: item.show.title, + type: 'series', + year: item.show.year, + source: 'trakt-watchlist' + }); + } + }); + } + + // Add continue watching shows (in-progress shows) + if (continueWatching) { + continueWatching.forEach((item: any) => { + if (item.type === 'episode' && item.show && item.show.ids.imdb) { + const imdbId = item.show.ids.imdb; + if (!allTraktShows.has(imdbId)) { + allTraktShows.set(imdbId, { + id: imdbId, + name: item.show.title, + type: 'series', + year: item.show.year, + source: 'trakt-continue-watching' + }); + } + } + }); + } + + // Add recently watched shows (top 20, same as calendar) + if (watchedShows) { + const recentWatched = watchedShows.slice(0, 20); + recentWatched.forEach((item: any) => { + if (item.show && item.show.ids.imdb) { + const imdbId = item.show.ids.imdb; + if (!allTraktShows.has(imdbId)) { + allTraktShows.set(imdbId, { + id: imdbId, + name: item.show.title, + type: 'series', + year: item.show.year, + source: 'trakt-watched' + }); + } + } + }); + } + + // Add collection shows + if (collectionShows) { + collectionShows.forEach((item: any) => { + if (item.show && item.show.ids.imdb) { + const imdbId = item.show.ids.imdb; + if (!allTraktShows.has(imdbId)) { + allTraktShows.set(imdbId, { + id: imdbId, + name: item.show.title, + type: 'series', + year: item.show.year, + source: 'trakt-collection' + }); + } + } + }); + } + + // Reduced logging verbosity + // logger.log(`[NotificationService] Found ${allTraktShows.size} unique Trakt shows from all sources`); + + // Sync notifications for each Trakt show + let syncedCount = 0; + for (const show of allTraktShows.values()) { + try { + await this.updateNotificationsForSeries(show.id); + syncedCount++; + // Small delay to prevent API rate limiting + await new Promise(resolve => setTimeout(resolve, 200)); + } catch (error) { + logger.error(`[NotificationService] Failed to sync notifications for ${show.name}:`, error); + } + } + + // Reduced logging verbosity + // logger.log(`[NotificationService] Successfully synced notifications for ${syncedCount}/${allTraktShows.size} Trakt shows`); + } catch (error) { + logger.error('[NotificationService] Error syncing Trakt notifications:', error); + } + } + + // Enhanced series notification update with TMDB fallback async updateNotificationsForSeries(seriesId: string): Promise<void> { try { - // Get metadata for the series - const metadata = await stremioService.getMetaDetails('series', seriesId); + // Reduced logging verbosity - only log for debug purposes + // logger.log(`[NotificationService] Updating notifications for series: ${seriesId}`); - if (!metadata || !metadata.videos) { + // Try Stremio first + let metadata = await stremioService.getMetaDetails('series', seriesId); + let upcomingEpisodes: any[] = []; + + if (metadata && metadata.videos) { + const now = new Date(); + const fourWeeksLater = addDays(now, 28); + + upcomingEpisodes = metadata.videos.filter(video => { + if (!video.released) return false; + const releaseDate = parseISO(video.released); + return releaseDate > now && releaseDate < fourWeeksLater; + }).map(video => ({ + id: video.id, + title: (video as any).title || (video as any).name || `Episode ${video.episode}`, + season: video.season || 0, + episode: video.episode || 0, + released: video.released, + })); + } + + // If no upcoming episodes from Stremio, try TMDB + if (upcomingEpisodes.length === 0) { + try { + // Extract TMDB ID if it's a TMDB format ID + let tmdbId = seriesId; + if (seriesId.startsWith('tmdb:')) { + tmdbId = seriesId.split(':')[1]; + } + + const tmdbDetails = await tmdbService.getTVShowDetails(parseInt(tmdbId)); + if (tmdbDetails) { + metadata = { + id: seriesId, + type: 'series' as const, + name: tmdbDetails.name, + poster: tmdbService.getImageUrl(tmdbDetails.poster_path) || '', + }; + + // Get upcoming episodes from TMDB + const now = new Date(); + const fourWeeksLater = addDays(now, 28); + + // Check current and next seasons for upcoming episodes + for (let seasonNum = tmdbDetails.number_of_seasons; seasonNum >= Math.max(1, tmdbDetails.number_of_seasons - 2); seasonNum--) { + try { + const seasonDetails = await tmdbService.getSeasonDetails(parseInt(tmdbId), seasonNum); + if (seasonDetails && seasonDetails.episodes) { + const seasonUpcoming = seasonDetails.episodes.filter((episode: any) => { + if (!episode.air_date) return false; + const airDate = parseISO(episode.air_date); + return airDate > now && airDate < fourWeeksLater; + }); + + upcomingEpisodes.push(...seasonUpcoming.map((episode: any) => ({ + id: `${tmdbId}-s${seasonNum}e${episode.episode_number}`, + title: episode.name, + season: seasonNum, + episode: episode.episode_number, + released: episode.air_date, + }))); + } + } catch (seasonError) { + // Continue with other seasons if one fails + } + } + } + } catch (tmdbError) { + logger.warn(`[NotificationService] TMDB fallback failed for ${seriesId}:`, tmdbError); + } + } + + if (!metadata) { + logger.warn(`[NotificationService] No metadata found for series: ${seriesId}`); return; } - // Get upcoming episodes - const now = new Date(); - const fourWeeksLater = addDays(now, 28); - - const upcomingEpisodes = metadata.videos.filter(video => { - if (!video.released) return false; - const releaseDate = parseISO(video.released); - return releaseDate > now && releaseDate < fourWeeksLater; - }); - // Cancel existing notifications for this series + const existingNotifications = await Notifications.getAllScheduledNotificationsAsync(); + for (const notification of existingNotifications) { + if (notification.content.data?.seriesId === seriesId) { + await Notifications.cancelScheduledNotificationAsync(notification.identifier); + } + } + + // Remove from our tracked notifications this.scheduledNotifications = this.scheduledNotifications.filter( notification => notification.seriesId !== seriesId ); - // Schedule new notifications - const notificationItems: NotificationItem[] = upcomingEpisodes.map(episode => ({ - id: episode.id, - seriesId, - seriesName: metadata.name, - episodeTitle: episode.title, - season: episode.season || 0, - episode: episode.episode || 0, - releaseDate: episode.released, - notified: false, - poster: metadata.poster, - })); - - await this.scheduleMultipleEpisodeNotifications(notificationItems); + // Schedule new notifications for upcoming episodes + if (upcomingEpisodes.length > 0) { + const notificationItems: NotificationItem[] = upcomingEpisodes.map(episode => ({ + id: episode.id, + seriesId, + seriesName: metadata.name, + episodeTitle: episode.title, + season: episode.season || 0, + episode: episode.episode || 0, + releaseDate: episode.released, + notified: false, + poster: metadata.poster, + })); + + const scheduledCount = await this.scheduleMultipleEpisodeNotifications(notificationItems); + // Reduced logging verbosity + // logger.log(`[NotificationService] Scheduled ${scheduledCount} notifications for ${metadata.name}`); + } else { + // logger.log(`[NotificationService] No upcoming episodes found for ${metadata.name}`); + } } catch (error) { - logger.error(`Error updating notifications for series ${seriesId}:`, error); + logger.error(`[NotificationService] Error updating notifications for series ${seriesId}:`, error); + } + } + + // Clean up old and expired notifications + private async cleanupOldNotifications(): Promise<void> { + try { + const now = new Date(); + const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + // Remove notifications for episodes that have already aired + const validNotifications = this.scheduledNotifications.filter(notification => { + const releaseDate = parseISO(notification.releaseDate); + return releaseDate > oneDayAgo; + }); + + if (validNotifications.length !== this.scheduledNotifications.length) { + this.scheduledNotifications = validNotifications; + await this.saveScheduledNotifications(); + // Reduced logging verbosity + // logger.log(`[NotificationService] Cleaned up ${this.scheduledNotifications.length - validNotifications.length} old notifications`); + } + } catch (error) { + logger.error('[NotificationService] Error cleaning up notifications:', error); + } + } + + // Public method to manually trigger sync for all library items + public async syncAllNotifications(): Promise<void> { + // Reduced logging verbosity + // logger.log('[NotificationService] Manual sync triggered'); + await this.performBackgroundSync(); + } + + // Public method to get notification stats + public getNotificationStats(): { total: number; upcoming: number; thisWeek: number } { + const now = new Date(); + const oneWeekLater = addDays(now, 7); + + const upcoming = this.scheduledNotifications.filter(notification => { + const releaseDate = parseISO(notification.releaseDate); + return releaseDate > now; + }); + + const thisWeek = upcoming.filter(notification => { + const releaseDate = parseISO(notification.releaseDate); + return releaseDate < oneWeekLater; + }); + + return { + total: this.scheduledNotifications.length, + upcoming: upcoming.length, + thisWeek: thisWeek.length + }; + } + + // Cleanup method for proper disposal + public destroy(): void { + if (this.backgroundSyncInterval) { + clearInterval(this.backgroundSyncInterval); + this.backgroundSyncInterval = null; + } + + if (this.librarySubscription) { + this.librarySubscription(); + this.librarySubscription = null; + } + + if (this.appStateSubscription) { + this.appStateSubscription.remove(); + this.appStateSubscription = null; } } } // Export singleton instance -export const notificationService = NotificationService.getInstance(); \ No newline at end of file +export const notificationService = NotificationService.getInstance(); \ No newline at end of file diff --git a/src/services/robustCalendarCache.ts b/src/services/robustCalendarCache.ts new file mode 100644 index 0000000..b50ebe5 --- /dev/null +++ b/src/services/robustCalendarCache.ts @@ -0,0 +1,102 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { logger } from '../utils/logger'; + +// Define the structure of cached data +interface CachedData<T> { + timestamp: number; + hash: string; + data: T; +} + +// Define the structure for Trakt collections +interface TraktCollections { + watchlist: any[]; + continueWatching: any[]; + watched?: any[]; +} + +const THIS_WEEK_CACHE_KEY = 'this_week_episodes_cache'; +const CALENDAR_CACHE_KEY = 'calendar_data_cache'; +const CACHE_DURATION_MS = 15 * 60 * 1000; // 15 minutes +const ERROR_CACHE_DURATION_MS = 5 * 60 * 1000; // 5 minutes for error recovery + +class RobustCalendarCache { + private generateHash(libraryItems: any[], traktCollections: TraktCollections): string { + const libraryIds = libraryItems.map(item => item.id).sort().join('|'); + const watchlistIds = (traktCollections.watchlist || []).map(item => item.show?.ids?.imdb || '').filter(Boolean).sort().join('|'); + const continueWatchingIds = (traktCollections.continueWatching || []).map(item => item.show?.ids?.imdb || '').filter(Boolean).sort().join('|'); + const watchedIds = (traktCollections.watched || []).map(item => item.show?.ids?.imdb || '').filter(Boolean).sort().join('|'); + + return `${libraryIds}:${watchlistIds}:${continueWatchingIds}:${watchedIds}`; + } + + private async getCachedData<T>(key: string, libraryItems: any[], traktCollections: TraktCollections): Promise<T | null> { + try { + const storedCache = await AsyncStorage.getItem(key); + if (!storedCache) return null; + + const cache: CachedData<T> = JSON.parse(storedCache); + const currentHash = this.generateHash(libraryItems, traktCollections); + + if (cache.hash !== currentHash) { + logger.log(`[Cache] Hash mismatch for key ${key}, cache invalidated`); + return null; + } + + const isCacheExpired = Date.now() - cache.timestamp > CACHE_DURATION_MS; + if (isCacheExpired) { + logger.log(`[Cache] Cache expired for key ${key}`); + return null; + } + + logger.log(`[Cache] Valid cache found for key ${key}`); + return cache.data; + } catch (error) { + logger.error(`[Cache] Error getting cached data for key ${key}:`, error); + return null; + } + } + + private async setCachedData<T>(key: string, data: T, libraryItems: any[], traktCollections: TraktCollections, isErrorRecovery = false): Promise<void> { + try { + const hash = this.generateHash(libraryItems, traktCollections); + const cache: CachedData<T> = { + timestamp: Date.now(), + hash, + data, + }; + + if (isErrorRecovery) { + // Use a shorter cache duration for error states + cache.timestamp = Date.now() - CACHE_DURATION_MS + ERROR_CACHE_DURATION_MS; + logger.log(`[Cache] Saving error recovery cache for key ${key}`); + } else { + logger.log(`[Cache] Saving successful data to cache for key ${key}`); + } + + await AsyncStorage.setItem(key, JSON.stringify(cache)); + } catch (error) { + logger.error(`[Cache] Error setting cached data for key ${key}:`, error); + } + } + + // Methods for This Week section + public async getCachedThisWeekData(libraryItems: any[], traktCollections: TraktCollections): Promise<any[] | null> { + return this.getCachedData<any[]>(THIS_WEEK_CACHE_KEY, libraryItems, traktCollections); + } + + public async setCachedThisWeekData(data: any[], libraryItems: any[], traktCollections: TraktCollections, isErrorRecovery = false): Promise<void> { + await this.setCachedData<any[]>(THIS_WEEK_CACHE_KEY, data, libraryItems, traktCollections, isErrorRecovery); + } + + // Methods for Calendar screen + public async getCachedCalendarData(libraryItems: any[], traktCollections: TraktCollections): Promise<any[] | null> { + return this.getCachedData<any[]>(CALENDAR_CACHE_KEY, libraryItems, traktCollections); + } + + public async setCachedCalendarData(data: any[], libraryItems: any[], traktCollections: TraktCollections, isErrorRecovery = false): Promise<void> { + await this.setCachedData<any[]>(CALENDAR_CACHE_KEY, data, libraryItems, traktCollections, isErrorRecovery); + } +} + +export const robustCalendarCache = new RobustCalendarCache(); \ No newline at end of file diff --git a/src/services/storageService.ts b/src/services/storageService.ts index 0878c3e..9ef38bc 100644 --- a/src/services/storageService.ts +++ b/src/services/storageService.ts @@ -13,7 +13,12 @@ interface WatchProgress { class StorageService { private static instance: StorageService; private readonly WATCH_PROGRESS_KEY = '@watch_progress:'; + private readonly CONTENT_DURATION_KEY = '@content_duration:'; private watchProgressSubscribers: (() => void)[] = []; + private notificationDebounceTimer: NodeJS.Timeout | null = null; + private lastNotificationTime: number = 0; + private readonly NOTIFICATION_DEBOUNCE_MS = 1000; // 1 second debounce + private readonly MIN_NOTIFICATION_INTERVAL = 500; // Minimum 500ms between notifications private constructor() {} @@ -25,7 +30,65 @@ class StorageService { } private getWatchProgressKey(id: string, type: string, episodeId?: string): string { - return this.WATCH_PROGRESS_KEY + `${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + return `${this.WATCH_PROGRESS_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + } + + private getContentDurationKey(id: string, type: string, episodeId?: string): string { + return `${this.CONTENT_DURATION_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`; + } + + public async setContentDuration( + id: string, + type: string, + duration: number, + episodeId?: string + ): Promise<void> { + try { + const key = this.getContentDurationKey(id, type, episodeId); + await AsyncStorage.setItem(key, duration.toString()); + } catch (error) { + logger.error('Error setting content duration:', error); + } + } + + public async getContentDuration( + id: string, + type: string, + episodeId?: string + ): Promise<number | null> { + try { + const key = this.getContentDurationKey(id, type, episodeId); + const data = await AsyncStorage.getItem(key); + return data ? parseFloat(data) : null; + } catch (error) { + logger.error('Error getting content duration:', error); + return null; + } + } + + public async updateProgressDuration( + id: string, + type: string, + newDuration: number, + episodeId?: string + ): Promise<void> { + try { + const existingProgress = await this.getWatchProgress(id, type, episodeId); + if (existingProgress && Math.abs(existingProgress.duration - newDuration) > 60) { + // Calculate the new current time to maintain the same percentage + const progressPercent = (existingProgress.currentTime / existingProgress.duration) * 100; + const updatedProgress: WatchProgress = { + ...existingProgress, + currentTime: (progressPercent / 100) * newDuration, + duration: newDuration, + lastUpdated: Date.now() + }; + await this.setWatchProgress(id, type, updatedProgress, episodeId); + logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration/60).toFixed(0)}min to ${(newDuration/60).toFixed(0)}min`); + } + } catch (error) { + logger.error('Error updating progress duration:', error); + } } public async setWatchProgress( @@ -36,16 +99,56 @@ class StorageService { ): Promise<void> { try { const key = this.getWatchProgressKey(id, type, episodeId); + + // Check if progress has actually changed significantly + const existingProgress = await this.getWatchProgress(id, type, episodeId); + if (existingProgress) { + const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime); + const durationDiff = Math.abs(progress.duration - existingProgress.duration); + + // Only update if there's a significant change (>5 seconds or duration change) + if (timeDiff < 5 && durationDiff < 1) { + return; // Skip update for minor changes + } + } + await AsyncStorage.setItem(key, JSON.stringify(progress)); - // Notify subscribers - this.notifyWatchProgressSubscribers(); + + // Use debounced notification to reduce spam + this.debouncedNotifySubscribers(); } catch (error) { - logger.error('Error saving watch progress:', error); + logger.error('Error setting watch progress:', error); + } + } + + private debouncedNotifySubscribers(): void { + const now = Date.now(); + + // Clear existing timer + if (this.notificationDebounceTimer) { + clearTimeout(this.notificationDebounceTimer); + } + + // If we notified recently, debounce longer + const timeSinceLastNotification = now - this.lastNotificationTime; + if (timeSinceLastNotification < this.MIN_NOTIFICATION_INTERVAL) { + this.notificationDebounceTimer = setTimeout(() => { + this.notifyWatchProgressSubscribers(); + }, this.NOTIFICATION_DEBOUNCE_MS); + } else { + // Notify immediately if enough time has passed + this.notifyWatchProgressSubscribers(); } } private notifyWatchProgressSubscribers(): void { + this.lastNotificationTime = Date.now(); + this.notificationDebounceTimer = null; + + // Only notify if we have subscribers + if (this.watchProgressSubscribers.length > 0) { this.watchProgressSubscribers.forEach(callback => callback()); + } } public subscribeToWatchProgressUpdates(callback: () => void): () => void { @@ -115,16 +218,30 @@ class StorageService { type: string, traktSynced: boolean, traktProgress?: number, - episodeId?: string + episodeId?: string, + exactTime?: number ): Promise<void> { try { const existingProgress = await this.getWatchProgress(id, type, episodeId); if (existingProgress) { + // Preserve the highest Trakt progress and currentTime values to avoid accidental regressions + const highestTraktProgress = (() => { + if (traktProgress === undefined) return existingProgress.traktProgress; + if (existingProgress.traktProgress === undefined) return traktProgress; + return Math.max(traktProgress, existingProgress.traktProgress); + })(); + + const highestCurrentTime = (() => { + if (!exactTime || exactTime <= 0) return existingProgress.currentTime; + return Math.max(exactTime, existingProgress.currentTime); + })(); + const updatedProgress: WatchProgress = { ...existingProgress, traktSynced, traktLastSynced: traktSynced ? Date.now() : existingProgress.traktLastSynced, - traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress + traktProgress: highestTraktProgress, + currentTime: highestCurrentTime, }; await this.setWatchProgress(id, type, updatedProgress, episodeId); } @@ -182,60 +299,127 @@ class StorageService { } /** - * Merge Trakt progress with local progress + * Merge Trakt progress with local progress using exact time when available */ public async mergeWithTraktProgress( id: string, type: string, traktProgress: number, traktPausedAt: string, - episodeId?: string + episodeId?: string, + exactTime?: number // Optional exact time in seconds from Trakt scrobble data ): Promise<void> { try { const localProgress = await this.getWatchProgress(id, type, episodeId); const traktTimestamp = new Date(traktPausedAt).getTime(); if (!localProgress) { - // No local progress, use Trakt data (estimate duration) - const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; // Default 1 hour + // No local progress - use stored duration or estimate + let duration = await this.getContentDuration(id, type, episodeId); + let currentTime: number; + + if (exactTime && exactTime > 0) { + // Use exact time from Trakt if available + currentTime = exactTime; + if (!duration) { + // Calculate duration from exact time and percentage + duration = (exactTime / traktProgress) * 100; + } + } else { + // Fallback to percentage-based calculation + if (!duration) { + // Use reasonable duration estimates as fallback + if (type === 'movie') { + duration = 6600; // 110 minutes for movies + } else if (episodeId) { + duration = 2700; // 45 minutes for TV episodes + } else { + duration = 3600; // 60 minutes default + } + } + currentTime = (traktProgress / 100) * duration; + } + const newProgress: WatchProgress = { - currentTime: (traktProgress / 100) * estimatedDuration, - duration: estimatedDuration, + currentTime, + duration, lastUpdated: traktTimestamp, traktSynced: true, traktLastSynced: Date.now(), traktProgress }; await this.setWatchProgress(id, type, newProgress, episodeId); + + const timeSource = exactTime ? 'exact' : 'calculated'; + const durationSource = await this.getContentDuration(id, type, episodeId) ? 'stored' : 'estimated'; + logger.log(`[StorageService] Created progress from Trakt: ${(currentTime/60).toFixed(1)}min (${timeSource}) of ${(duration/60).toFixed(0)}min (${durationSource})`); } else { - // Always prioritize Trakt progress when merging + // Local progress exists - merge intelligently const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100; - if (localProgress.duration > 0) { - // Use Trakt progress, keeping the existing duration - const updatedProgress: WatchProgress = { - ...localProgress, - currentTime: (traktProgress / 100) * localProgress.duration, - lastUpdated: traktTimestamp, - traktSynced: true, - traktLastSynced: Date.now(), - traktProgress - }; - await this.setWatchProgress(id, type, updatedProgress, episodeId); - logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%)`); + // Only proceed if there's a significant difference (>5% or different completion status) + const progressDiff = Math.abs(traktProgress - localProgressPercent); + if (progressDiff < 5 && traktProgress < 100 && localProgressPercent < 100) { + return; // Skip minor updates + } + + let currentTime: number; + let duration = localProgress.duration; + + if (exactTime && exactTime > 0 && localProgress.duration > 0) { + // Use exact time from Trakt, keep local duration + currentTime = exactTime; + + // If exact time doesn't match the duration well, recalculate duration + const calculatedDuration = (exactTime / traktProgress) * 100; + const durationDiff = Math.abs(calculatedDuration - localProgress.duration); + if (durationDiff > 300) { // More than 5 minutes difference + duration = calculatedDuration; + logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration/60).toFixed(0)}min → ${(duration/60).toFixed(0)}min`); + } + } else if (localProgress.duration > 0) { + // Use percentage calculation with local duration + currentTime = (traktProgress / 100) * localProgress.duration; } else { - // If no duration, estimate it from Trakt progress - const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; + // No local duration, check stored duration + const storedDuration = await this.getContentDuration(id, type, episodeId); + duration = storedDuration || 0; + + if (!duration || duration <= 0) { + if (exactTime && exactTime > 0) { + duration = (exactTime / traktProgress) * 100; + currentTime = exactTime; + } else { + // Final fallback to estimates + if (type === 'movie') { + duration = 6600; // 110 minutes for movies + } else if (episodeId) { + duration = 2700; // 45 minutes for TV episodes + } else { + duration = 3600; // 60 minutes default + } + currentTime = (traktProgress / 100) * duration; + } + } else { + currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration; + } + } + const updatedProgress: WatchProgress = { - currentTime: (traktProgress / 100) * estimatedDuration, - duration: estimatedDuration, + ...localProgress, + currentTime, + duration, lastUpdated: traktTimestamp, traktSynced: true, traktLastSynced: Date.now(), traktProgress }; await this.setWatchProgress(id, type, updatedProgress, episodeId); - logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%) - estimated duration`); + + // Only log significant changes + if (progressDiff > 10 || traktProgress === 100) { + const timeSource = exactTime ? 'exact' : 'calculated'; + logger.log(`[StorageService] Updated progress: ${(currentTime/60).toFixed(1)}min (${timeSource}) = ${traktProgress}%`); } } } catch (error) { diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index 6415c14..6914a62 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -2,6 +2,9 @@ import axios from 'axios'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { logger } from '../utils/logger'; import EventEmitter from 'eventemitter3'; +import { localScraperService } from './localScraperService'; +import { DEFAULT_SETTINGS, AppSettings } from '../hooks/useSettings'; +import { TMDBService } from './tmdbService'; // Create an event emitter for addon changes export const addonEmitter = new EventEmitter(); @@ -175,10 +178,6 @@ class StremioService { private addonOrder: string[] = []; private readonly STORAGE_KEY = 'stremio-addons'; private readonly ADDON_ORDER_KEY = 'stremio-addon-order'; - private readonly DEFAULT_ADDONS = [ - 'https://v3-cinemeta.strem.io/manifest.json', - 'https://opensubtitles-v3.strem.io/manifest.json' - ]; private readonly MAX_CONCURRENT_REQUESTS = 3; private readonly DEFAULT_PAGE_SIZE = 50; private initialized: boolean = false; @@ -214,6 +213,51 @@ class StremioService { } } + // Ensure Cinemeta is always installed as a pre-installed addon + const cinemetaId = 'com.linvo.cinemeta'; + if (!this.installedAddons.has(cinemetaId)) { + const cinemetaManifest: Manifest = { + id: cinemetaId, + name: 'Cinemeta', + version: '3.0.13', + description: 'Provides metadata for movies and series from TheTVDB, TheMovieDB, etc.', + url: 'https://v3-cinemeta.strem.io', + originalUrl: 'https://v3-cinemeta.strem.io/manifest.json', + types: ['movie', 'series'], + catalogs: [ + { + type: 'movie', + id: 'top', + name: 'Top Movies', + extraSupported: ['search', 'genre', 'skip'] + }, + { + type: 'series', + id: 'top', + name: 'Top Series', + extraSupported: ['search', 'genre', 'skip'] + } + ], + resources: [ + { + name: 'catalog', + types: ['movie', 'series'], + idPrefixes: ['tt'] + }, + { + name: 'meta', + types: ['movie', 'series'], + idPrefixes: ['tt'] + } + ], + behaviorHints: { + configurable: false + } + }; + this.installedAddons.set(cinemetaId, cinemetaManifest); + logger.log('✅ Cinemeta pre-installed as default addon'); + } + // Load addon order if exists const storedOrder = await AsyncStorage.getItem(this.ADDON_ORDER_KEY); if (storedOrder) { @@ -222,24 +266,33 @@ class StremioService { this.addonOrder = this.addonOrder.filter(id => this.installedAddons.has(id)); } + // Ensure Cinemeta is first in the order + if (!this.addonOrder.includes(cinemetaId)) { + this.addonOrder.unshift(cinemetaId); + } else { + // Move Cinemeta to the front if it's not already there + const cinemetaIndex = this.addonOrder.indexOf(cinemetaId); + if (cinemetaIndex > 0) { + this.addonOrder.splice(cinemetaIndex, 1); + this.addonOrder.unshift(cinemetaId); + } + } + // 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]; - // If no addons, install defaults - if (this.installedAddons.size === 0) { - await this.installDefaultAddons(); - } - - // Ensure order is saved + // Ensure order and addons are saved await this.saveAddonOrder(); + await this.saveInstalledAddons(); this.initialized = true; } catch (error) { logger.error('Failed to initialize addons:', error); - // Install defaults as fallback - await this.installDefaultAddons(); + // Initialize with empty state on error + this.installedAddons = new Map(); + this.addonOrder = []; this.initialized = true; } } @@ -275,20 +328,6 @@ class StremioService { throw lastError; } - private async installDefaultAddons(): Promise<void> { - try { - for (const url of this.DEFAULT_ADDONS) { - const manifest = await this.getManifest(url); - if (manifest) { - this.installedAddons.set(manifest.id, manifest); - } - } - await this.saveInstalledAddons(); - } catch (error) { - logger.error('Failed to install default addons:', error); - } - } - private async saveInstalledAddons(): Promise<void> { try { const addonsArray = Array.from(this.installedAddons.values()); @@ -355,6 +394,12 @@ class StremioService { } removeAddon(id: string): void { + // Prevent removal of Cinemeta as it's a pre-installed addon + if (id === 'com.linvo.cinemeta') { + logger.warn('❌ Cannot remove Cinemeta - it is a pre-installed addon'); + return; + } + if (this.installedAddons.has(id)) { this.installedAddons.delete(id); // Remove from order @@ -378,6 +423,11 @@ class StremioService { return this.getInstalledAddons(); } + // Check if an addon is pre-installed and cannot be removed + isPreInstalledAddon(id: string): boolean { + return id === 'com.linvo.cinemeta'; + } + private formatId(id: string): string { return id.replace(/[^a-zA-Z0-9]/g, '-').toLowerCase(); } @@ -535,13 +585,12 @@ class StremioService { if (hasMetaSupport) { try { - logger.log(`HTTP GET: ${wouldBeUrl} (preferred addon: ${preferredAddon.name})`); + const response = await this.retryRequest(async () => { return await axios.get(wouldBeUrl, { timeout: 10000 }); }); if (response.data && response.data.meta) { - logger.log(`✅ Metadata fetched successfully from preferred addon: ${wouldBeUrl}`); return response.data.meta; } } catch (error) { @@ -564,13 +613,12 @@ class StremioService { for (const baseUrl of cinemetaUrls) { try { const url = `${baseUrl}/meta/${type}/${id}.json`; - logger.log(`HTTP GET: ${url}`); + const response = await this.retryRequest(async () => { return await axios.get(url, { timeout: 10000 }); }); if (response.data && response.data.meta) { - logger.log(`✅ Metadata fetched successfully from: ${url}`); return response.data.meta; } } catch (error) { @@ -619,7 +667,6 @@ class StremioService { }); if (response.data && response.data.meta) { - logger.log(`✅ Metadata fetched successfully from: ${url}`); return response.data.meta; } } catch (error) { @@ -643,6 +690,89 @@ class StremioService { const addons = this.getInstalledAddons(); logger.log('📌 [getStreams] Installed addons:', addons.map(a => ({ id: a.id, name: a.name, url: a.url }))); + // Check if local scrapers are enabled and execute them first + try { + // Load settings from AsyncStorage directly + const settingsJson = await AsyncStorage.getItem('app_settings'); + const settings: AppSettings = settingsJson ? JSON.parse(settingsJson) : DEFAULT_SETTINGS; + + if (settings.enableLocalScrapers) { + const hasScrapers = await localScraperService.hasScrapers(); + if (hasScrapers) { + logger.log('🔧 [getStreams] Executing local scrapers for', type, id); + + // Map Stremio types to local scraper types + const scraperType = type === 'series' ? 'tv' : type; + + // Parse the Stremio ID to extract IMDb ID and season/episode info + let tmdbId: string | null = null; + let season: number | undefined = undefined; + let episode: number | undefined = undefined; + + try { + const idParts = id.split(':'); + let baseImdbId: string; + + // Handle different episode ID formats + if (idParts[0] === 'series') { + // Format: series:imdbId:season:episode + baseImdbId = idParts[1]; + if (scraperType === 'tv' && idParts.length >= 4) { + season = parseInt(idParts[2], 10); + episode = parseInt(idParts[3], 10); + } + } else if (idParts[0].startsWith('tt')) { + // Format: imdbId:season:episode (direct IMDb ID) + baseImdbId = idParts[0]; + if (scraperType === 'tv' && idParts.length >= 3) { + season = parseInt(idParts[1], 10); + episode = parseInt(idParts[2], 10); + } + } else { + // Fallback: assume first part is the ID + baseImdbId = idParts[0]; + if (scraperType === 'tv' && idParts.length >= 3) { + season = parseInt(idParts[1], 10); + episode = parseInt(idParts[2], 10); + } + } + + // Convert IMDb ID to TMDB ID using TMDBService + const tmdbService = TMDBService.getInstance(); + const tmdbIdNumber = await tmdbService.findTMDBIdByIMDB(baseImdbId); + + if (tmdbIdNumber) { + tmdbId = tmdbIdNumber.toString(); + logger.log(`🔄 [getStreams] Converted IMDb ID ${baseImdbId} to TMDB ID ${tmdbId}${scraperType === 'tv' ? ` (S${season}E${episode})` : ''}`); + } else { + logger.warn(`⚠️ [getStreams] Could not convert IMDb ID ${baseImdbId} to TMDB ID`); + return; // Skip local scrapers if we can't convert the ID + } + } catch (error) { + logger.error(`❌ [getStreams] Failed to parse Stremio ID or convert to TMDB ID:`, error); + return; // Skip local scrapers if ID parsing fails + } + + // Execute local scrapers asynchronously with TMDB ID + localScraperService.getStreams(scraperType, tmdbId, season, episode, (streams, scraperId, scraperName, error) => { + if (error) { + logger.error(`❌ [getStreams] Local scraper ${scraperName} failed:`, error); + if (callback) { + callback(null, scraperId, scraperName, error); + } + } else if (streams && streams.length > 0) { + logger.log(`✅ [getStreams] Local scraper ${scraperName} returned ${streams.length} streams`); + if (callback) { + callback(streams, scraperId, scraperName, null); + } + } + }); + } + } + } catch (error) { + logger.error('❌ [getStreams] Failed to execute local scrapers:', error); + } + // Check specifically for TMDB Embed addon const tmdbEmbed = addons.find(addon => addon.id === 'org.tmdbembedapi'); if (tmdbEmbed) { diff --git a/src/services/torrentService.ts b/src/services/torrentService.ts deleted file mode 100644 index 7660b00..0000000 --- a/src/services/torrentService.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { NativeModules, NativeEventEmitter, EmitterSubscription, Platform } from 'react-native'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import { logger } from '../utils/logger'; - -// Mock implementation for Expo environment -const MockTorrentStreamModule = { - TORRENT_PROGRESS_EVENT: 'torrentProgress', - startStream: async (magnetUri: string): Promise<string> => { - logger.log('[MockTorrentService] Starting mock stream for:', magnetUri); - // Return a fake URL that would look like a file path - return `https://mock-torrent-stream.com/${magnetUri.substring(0, 10)}.mp4`; - }, - stopStream: () => { - logger.log('[MockTorrentService] Stopping mock stream'); - }, - fileExists: async (path: string): Promise<boolean> => { - logger.log('[MockTorrentService] Checking if file exists:', path); - return false; - }, - // Add these methods to satisfy NativeModule interface - addListener: () => {}, - removeListeners: () => {} -}; - -// Create an EventEmitter that doesn't rely on native modules -class MockEventEmitter { - private listeners: Map<string, Function[]> = new Map(); - - addListener(eventName: string, callback: Function): { remove: () => void } { - if (!this.listeners.has(eventName)) { - this.listeners.set(eventName, []); - } - this.listeners.get(eventName)?.push(callback); - - return { - remove: () => { - const eventListeners = this.listeners.get(eventName); - if (eventListeners) { - const index = eventListeners.indexOf(callback); - if (index !== -1) { - eventListeners.splice(index, 1); - } - } - } - }; - } - - emit(eventName: string, ...args: any[]) { - const eventListeners = this.listeners.get(eventName); - if (eventListeners) { - eventListeners.forEach(listener => listener(...args)); - } - } - - removeAllListeners(eventName: string) { - this.listeners.delete(eventName); - } -} - -// Use the mock module and event emitter since we're in Expo -const TorrentStreamModule = Platform.OS === 'web' ? null : MockTorrentStreamModule; -const mockEmitter = new MockEventEmitter(); - -const CACHE_KEY = '@torrent_cache_mapping'; - -export interface TorrentProgress { - bufferProgress: number; - downloadSpeed: number; - progress: number; - seeds: number; -} - -export interface TorrentStreamEvents { - onProgress?: (progress: TorrentProgress) => void; -} - -class TorrentService { - private eventEmitter: NativeEventEmitter | MockEventEmitter; - private progressListener: EmitterSubscription | { remove: () => void } | null = null; - private static TORRENT_PROGRESS_EVENT = TorrentStreamModule?.TORRENT_PROGRESS_EVENT || 'torrentProgress'; - private cachedTorrents: Map<string, string> = new Map(); // Map of magnet URI to cached file path - private initialized: boolean = false; - private mockProgressInterval: NodeJS.Timeout | null = null; - - constructor() { - // Use mock event emitter since we're in Expo - this.eventEmitter = mockEmitter; - this.loadCache(); - } - - private async loadCache() { - try { - const cacheData = await AsyncStorage.getItem(CACHE_KEY); - if (cacheData) { - const cacheMap = JSON.parse(cacheData); - this.cachedTorrents = new Map(Object.entries(cacheMap)); - logger.log('[TorrentService] Loaded cache mapping:', this.cachedTorrents); - } - this.initialized = true; - } catch (error) { - logger.error('[TorrentService] Error loading cache:', error); - this.initialized = true; - } - } - - private async saveCache() { - try { - const cacheData = Object.fromEntries(this.cachedTorrents); - await AsyncStorage.setItem(CACHE_KEY, JSON.stringify(cacheData)); - logger.log('[TorrentService] Saved cache mapping'); - } catch (error) { - logger.error('[TorrentService] Error saving cache:', error); - } - } - - public async startStream(magnetUri: string, events?: TorrentStreamEvents): Promise<string> { - // Wait for cache to be loaded - while (!this.initialized) { - await new Promise(resolve => setTimeout(resolve, 50)); - } - - try { - // First check if we have this torrent cached - const cachedPath = this.cachedTorrents.get(magnetUri); - if (cachedPath) { - logger.log('[TorrentService] Found cached torrent file:', cachedPath); - - // In mock mode, we'll always use the cached path if available - if (!TorrentStreamModule) { - // Still set up progress listeners for cached content - this.setupProgressListener(events); - - // Simulate progress for cached content too - if (events?.onProgress) { - this.startMockProgressUpdates(events.onProgress); - } - - return cachedPath; - } - - // For native implementations, verify the file still exists - try { - const exists = await TorrentStreamModule.fileExists(cachedPath); - if (exists) { - logger.log('[TorrentService] Using cached torrent file'); - - // Setup progress listener if callback provided - this.setupProgressListener(events); - - // Start the stream in cached mode - await TorrentStreamModule.startStream(magnetUri); - return cachedPath; - } else { - logger.log('[TorrentService] Cached file not found, removing from cache'); - this.cachedTorrents.delete(magnetUri); - await this.saveCache(); - } - } catch (error) { - logger.error('[TorrentService] Error checking cached file:', error); - // Continue to download again if there's an error - } - } - - // First stop any existing stream - await this.stopStreamAndWait(); - - // Setup progress listener if callback provided - this.setupProgressListener(events); - - // If we're in mock mode (Expo), simulate progress - if (!TorrentStreamModule) { - logger.log('[TorrentService] Using mock implementation'); - const mockUrl = `https://mock-torrent-stream.com/${magnetUri.substring(0, 10)}.mp4`; - - // Save to cache - this.cachedTorrents.set(magnetUri, mockUrl); - await this.saveCache(); - - // Start mock progress updates if events callback provided - if (events?.onProgress) { - this.startMockProgressUpdates(events.onProgress); - } - - // Return immediately with mock URL - return mockUrl; - } - - // Start the actual stream if native module is available - logger.log('[TorrentService] Starting torrent stream'); - const filePath = await TorrentStreamModule.startStream(magnetUri); - - // Save to cache - if (filePath) { - logger.log('[TorrentService] Adding path to cache:', filePath); - this.cachedTorrents.set(magnetUri, filePath); - await this.saveCache(); - } - - return filePath; - } catch (error) { - logger.error('[TorrentService] Error starting torrent stream:', error); - this.cleanup(); // Clean up on error - throw error; - } - } - - private setupProgressListener(events?: TorrentStreamEvents) { - if (events?.onProgress) { - logger.log('[TorrentService] Setting up progress listener'); - this.progressListener = this.eventEmitter.addListener( - TorrentService.TORRENT_PROGRESS_EVENT, - (progress) => { - logger.log('[TorrentService] Progress event received:', progress); - if (events.onProgress) { - events.onProgress(progress); - } - } - ); - } else { - logger.log('[TorrentService] No progress callback provided'); - } - } - - private startMockProgressUpdates(onProgress: (progress: TorrentProgress) => void) { - // Clear any existing interval - if (this.mockProgressInterval) { - clearInterval(this.mockProgressInterval); - } - - // Start at 0% progress - let mockProgress = 0; - - // Update every second - this.mockProgressInterval = setInterval(() => { - // Increase by 10% each time - mockProgress += 10; - - // Create mock progress object - const progress: TorrentProgress = { - bufferProgress: mockProgress, - downloadSpeed: 1024 * 1024 * (1 + Math.random()), // Random speed around 1MB/s - progress: mockProgress, - seeds: Math.floor(5 + Math.random() * 20), // Random seed count between 5-25 - }; - - // Emit the event instead of directly calling callback - if (this.eventEmitter instanceof MockEventEmitter) { - (this.eventEmitter as MockEventEmitter).emit(TorrentService.TORRENT_PROGRESS_EVENT, progress); - } else { - // Fallback to direct callback if needed - onProgress(progress); - } - - // If we reach 100%, clear the interval - if (mockProgress >= 100) { - if (this.mockProgressInterval) { - clearInterval(this.mockProgressInterval); - this.mockProgressInterval = null; - } - } - }, 1000); - } - - public async stopStreamAndWait(): Promise<void> { - logger.log('[TorrentService] Stopping stream and waiting for cleanup'); - this.cleanup(); - - if (TorrentStreamModule) { - try { - TorrentStreamModule.stopStream(); - // Wait a moment to ensure native side has cleaned up - await new Promise(resolve => setTimeout(resolve, 500)); - } catch (error) { - logger.error('[TorrentService] Error stopping torrent stream:', error); - } - } - } - - public stopStream(): void { - try { - logger.log('[TorrentService] Stopping stream and cleaning up'); - this.cleanup(); - - if (TorrentStreamModule) { - TorrentStreamModule.stopStream(); - } - } catch (error) { - logger.error('[TorrentService] Error stopping torrent stream:', error); - // Still attempt cleanup even if stop fails - this.cleanup(); - } - } - - private cleanup(): void { - logger.log('[TorrentService] Cleaning up event listeners and intervals'); - - // Clean up progress listener - if (this.progressListener) { - try { - this.progressListener.remove(); - } catch (error) { - logger.error('[TorrentService] Error removing progress listener:', error); - } finally { - this.progressListener = null; - } - } - - // Clean up mock progress interval - if (this.mockProgressInterval) { - clearInterval(this.mockProgressInterval); - this.mockProgressInterval = null; - } - } -} - -export const torrentService = new TorrentService(); \ No newline at end of file diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 071deec..2b3d3ee 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -64,7 +64,7 @@ export interface TraktWatchlistItem { }; show?: { title: string; - year: number; + year: number ids: { trakt: number; slug: string; @@ -271,37 +271,93 @@ export class TraktService { // Track currently watching sessions to avoid duplicate starts private currentlyWatching: Set<string> = new Set(); - private lastSyncTime: number = 0; + private lastSyncTimes: Map<string, number> = new Map(); private readonly SYNC_DEBOUNCE_MS = 60000; // 60 seconds - // Enhanced deduplication for stop calls + // Debounce for stop calls private lastStopCalls: Map<string, number> = new Map(); private readonly STOP_DEBOUNCE_MS = 10000; // 10 seconds debounce for stop calls + + // Default completion threshold (overridden by user settings) + private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80% private constructor() { - // Initialization happens in initialize method + // Increased cleanup interval from 5 minutes to 15 minutes to reduce heating + setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes - // Cleanup old stop call records every 5 minutes - setInterval(() => { - this.cleanupOldStopCalls(); - }, 5 * 60 * 1000); + // Load user settings + this.loadCompletionThreshold(); } /** - * Cleanup old stop call records to prevent memory leaks + * Load user-configured completion threshold from AsyncStorage + */ + private async loadCompletionThreshold(): Promise<void> { + try { + const thresholdStr = await AsyncStorage.getItem('@trakt_completion_threshold'); + if (thresholdStr) { + const threshold = parseInt(thresholdStr, 10); + if (!isNaN(threshold) && threshold >= 50 && threshold <= 100) { + logger.log(`[TraktService] Loaded user completion threshold: ${threshold}%`); + this.completionThreshold = threshold; + } + } + } catch (error) { + logger.error('[TraktService] Error loading completion threshold:', error); + } + } + + /** + * Get the current completion threshold (user-configured or default) + */ + private get completionThreshold(): number { + return this._completionThreshold || this.DEFAULT_COMPLETION_THRESHOLD; + } + + /** + * Set the completion threshold + */ + private set completionThreshold(value: number) { + this._completionThreshold = value; + } + + // Backing field for completion threshold + private _completionThreshold: number | null = null; + + /** + * Clean up old stop call records to prevent memory leaks */ private cleanupOldStopCalls(): void { const now = Date.now(); - const cutoff = now - (this.STOP_DEBOUNCE_MS * 2); // Keep records for 2x the debounce time + let cleanupCount = 0; + // Remove stop calls older than the debounce window for (const [key, timestamp] of this.lastStopCalls.entries()) { - if (timestamp < cutoff) { + if (now - timestamp > this.STOP_DEBOUNCE_MS) { this.lastStopCalls.delete(key); + cleanupCount++; } } - if (this.lastStopCalls.size > 0) { - logger.log(`[TraktService] Cleaned up old stop call records. Remaining: ${this.lastStopCalls.size}`); + // Also clean up old scrobbled timestamps + for (const [key, timestamp] of this.scrobbledTimestamps.entries()) { + if (now - timestamp > this.SCROBBLE_EXPIRY_MS) { + this.scrobbledTimestamps.delete(key); + this.scrobbledItems.delete(key); + cleanupCount++; + } + } + + // Clean up old sync times that haven't been updated in a while + for (const [key, timestamp] of this.lastSyncTimes.entries()) { + if (now - timestamp > 24 * 60 * 60 * 1000) { // 24 hours + this.lastSyncTimes.delete(key); + cleanupCount++; + } + } + + if (cleanupCount > 0) { + logger.log(`[TraktService] Cleaned up ${cleanupCount} old tracking entries`); } } @@ -619,8 +675,22 @@ export class TraktService { throw new Error(`API request failed: ${response.status}`); } - const responseData = await response.json() as T; - + // Handle "No Content" responses (204/205) which have no JSON body + if (response.status === 204 || response.status === 205) { + // Return null casted to expected type to satisfy caller's generic + return null as unknown as T; + } + + // Some endpoints (e.g., DELETE) may also return empty body with 200. Attempt safe parse. + let responseData: T; + try { + responseData = await response.json() as T; + } catch (parseError) { + // If body is empty, return null instead of throwing + logger.warn(`[TraktService] Empty JSON body for ${endpoint}, returning null`); + return null as unknown as T; + } + // Debug log successful scrobble responses if (endpoint.includes('/scrobble/')) { logger.log(`[TraktService] DEBUG API Success for ${endpoint}:`, responseData); @@ -1109,6 +1179,19 @@ export class TraktService { payload.show.ids.imdb = cleanShowImdbId; } + // Add episode IMDB ID if available (for specific episode IDs) + if (contentData.imdbId && contentData.imdbId !== contentData.showImdbId) { + const cleanEpisodeImdbId = contentData.imdbId.startsWith('tt') + ? contentData.imdbId.substring(2) + : contentData.imdbId; + + if (!payload.episode.ids) { + payload.episode.ids = {}; + } + + payload.episode.ids.imdb = cleanEpisodeImdbId; + } + logger.log('[TraktService] DEBUG episode payload:', JSON.stringify(payload, null, 2)); return payload; } @@ -1250,12 +1333,15 @@ export class TraktService { const now = Date.now(); + const watchingKey = this.getWatchingKey(contentData); + const lastSync = this.lastSyncTimes.get(watchingKey) || 0; + // Debounce API calls unless forced - if (!force && (now - this.lastSyncTime) < this.SYNC_DEBOUNCE_MS) { + if (!force && (now - lastSync) < this.SYNC_DEBOUNCE_MS) { return true; // Skip this sync, but return success } - this.lastSyncTime = now; + this.lastSyncTimes.set(watchingKey, now); const result = await this.queueRequest(async () => { return await this.pauseWatching(contentData, progress); @@ -1309,7 +1395,7 @@ export class TraktService { this.currentlyWatching.delete(watchingKey); // Mark as scrobbled if >= 80% to prevent future duplicates and restarts - if (progress >= 80) { + if (progress >= this.completionThreshold) { this.scrobbledItems.add(watchingKey); this.scrobbledTimestamps.set(watchingKey, Date.now()); logger.log(`[TraktService] Marked as scrobbled to prevent restarts: ${watchingKey}`); @@ -1317,7 +1403,7 @@ export class TraktService { // The stop endpoint automatically handles the 80%+ completion logic // and will mark as scrobbled if >= 80%, or pause if < 80% - const action = progress >= 80 ? 'scrobbled' : 'paused'; + const action = progress >= this.completionThreshold ? 'scrobbled' : 'paused'; logger.log(`[TraktService] Stopped watching ${contentData.type}: ${contentData.title} (${progress.toFixed(1)}% - ${action})`); return true; @@ -1432,6 +1518,73 @@ export class TraktService { logger.error('[TraktService] Debug image cache failed:', error); } } + + /** + * Delete a playback progress entry on Trakt by its playback `id`. + * Returns true if the request succeeded (204). + */ + public async deletePlaybackItem(playbackId: number): Promise<boolean> { + try { + if (!this.accessToken) return false; + await this.apiRequest<null>(`/sync/playback/${playbackId}`, 'DELETE'); + return true; // trakt returns 204 no-content on success + } catch (error) { + logger.error('[TraktService] Failed to delete playback item:', error); + return false; + } + } + + /** + * Convenience helper: find a playback entry matching imdb id (and optional season/episode) and delete it. + */ + public async deletePlaybackForContent(imdbId: string, type: 'movie' | 'series', season?: number, episode?: number): Promise<boolean> { + try { + if (!this.accessToken) return false; + const progressItems = await this.getPlaybackProgress(); + const target = progressItems.find(item => { + if (type === 'movie' && item.type === 'movie' && item.movie?.ids.imdb === imdbId) { + return true; + } + if (type === 'series' && item.type === 'episode' && item.show?.ids.imdb === imdbId) { + if (season !== undefined && episode !== undefined) { + return item.episode?.season === season && item.episode?.number === episode; + } + return true; // match any episode of the show if specific not provided + } + return false; + }); + if (target) { + return await this.deletePlaybackItem(target.id); + } + return false; + } catch (error) { + logger.error('[TraktService] Error deleting playback for content:', error); + return false; + } + } + + public async getWatchedEpisodesHistory(page: number = 1, limit: number = 100): Promise<any[]> { + await this.ensureInitialized(); + + const cacheKey = `history_episodes_${page}_${limit}`; + const lastSync = this.lastSyncTimes.get(cacheKey) || 0; + const now = Date.now(); + if (now - lastSync < this.SYNC_DEBOUNCE_MS) { + // Return cached result if we fetched recently + return (this as any)[cacheKey] || []; + } + + const endpoint = `/sync/history/episodes?page=${page}&limit=${limit}`; + try { + const data = await this.apiRequest<any[]>(endpoint, 'GET'); + (this as any)[cacheKey] = data; + this.lastSyncTimes.set(cacheKey, now); + return data; + } catch (error) { + logger.error('[TraktService] Failed to fetch watched episodes history:', error); + return []; + } + } } // Export a singleton instance diff --git a/src/services/xprimeService.ts b/src/services/xprimeService.ts deleted file mode 100644 index 704982f..0000000 --- a/src/services/xprimeService.ts +++ /dev/null @@ -1,298 +0,0 @@ -import { logger } from '../utils/logger'; -import { Stream } from '../types/metadata'; -import { tmdbService } from './tmdbService'; -import axios from 'axios'; -import AsyncStorage from '@react-native-async-storage/async-storage'; -import * as FileSystem from 'expo-file-system'; -import * as Crypto from 'expo-crypto'; - -// Use node-fetch if available, otherwise fallback to global fetch -let fetchImpl: typeof fetch; -try { - // @ts-ignore - fetchImpl = require('node-fetch'); -} catch { - fetchImpl = fetch; -} - -// Constants -const MAX_RETRIES_XPRIME = 3; -const RETRY_DELAY_MS_XPRIME = 1000; - -// Use app's cache directory for React Native -const CACHE_DIR = `${FileSystem.cacheDirectory}xprime/`; - -const BROWSER_HEADERS_XPRIME = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', - 'Accept': '*/*', - 'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8', - 'Cache-Control': 'no-cache', - 'Pragma': 'no-cache', - 'Sec-Ch-Ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"', - 'Sec-Ch-Ua-Mobile': '?0', - 'Sec-Ch-Ua-Platform': '"Windows"', - 'Connection': 'keep-alive' -}; - -interface XprimeStream { - url: string; - quality: string; - title: string; - provider: string; - codecs: string[]; - size: string; -} - -class XprimeService { - private MAX_RETRIES = 3; - private RETRY_DELAY = 1000; // 1 second - - // Ensure cache directories exist - private async ensureCacheDir(dirPath: string) { - try { - const dirInfo = await FileSystem.getInfoAsync(dirPath); - if (!dirInfo.exists) { - await FileSystem.makeDirectoryAsync(dirPath, { intermediates: true }); - } - } catch (error) { - logger.error(`[XPRIME] Warning: Could not create cache directory ${dirPath}:`, error); - } - } - - // Cache helpers - private async getFromCache(cacheKey: string, subDir: string = ''): Promise<any> { - try { - const fullPath = `${CACHE_DIR}${subDir}/${cacheKey}`; - const fileInfo = await FileSystem.getInfoAsync(fullPath); - - if (fileInfo.exists) { - const data = await FileSystem.readAsStringAsync(fullPath); - logger.log(`[XPRIME] CACHE HIT for: ${subDir}/${cacheKey}`); - try { - return JSON.parse(data); - } catch (e) { - return data; - } - } - return null; - } catch (error) { - logger.error(`[XPRIME] CACHE READ ERROR for ${cacheKey}:`, error); - return null; - } - } - - private async saveToCache(cacheKey: string, content: any, subDir: string = '') { - try { - const fullSubDir = `${CACHE_DIR}${subDir}/`; - await this.ensureCacheDir(fullSubDir); - - const fullPath = `${fullSubDir}${cacheKey}`; - const dataToSave = typeof content === 'string' ? content : JSON.stringify(content, null, 2); - - await FileSystem.writeAsStringAsync(fullPath, dataToSave); - logger.log(`[XPRIME] SAVED TO CACHE: ${subDir}/${cacheKey}`); - } catch (error) { - logger.error(`[XPRIME] CACHE WRITE ERROR for ${cacheKey}:`, error); - } - } - - // Helper function to fetch stream size using a HEAD request - private async fetchStreamSize(url: string): Promise<string> { - const cacheSubDir = 'xprime_stream_sizes'; - - // Create a hash of the URL to use as the cache key - const urlHash = await Crypto.digestStringAsync( - Crypto.CryptoDigestAlgorithm.MD5, - url, - { encoding: Crypto.CryptoEncoding.HEX } - ); - const urlCacheKey = `${urlHash}.txt`; - - const cachedSize = await this.getFromCache(urlCacheKey, cacheSubDir); - if (cachedSize !== null) { - return cachedSize; - } - - try { - // For m3u8, Content-Length is for the playlist file, not the stream segments - if (url.toLowerCase().includes('.m3u8')) { - await this.saveToCache(urlCacheKey, 'Playlist (size N/A)', cacheSubDir); - return 'Playlist (size N/A)'; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); // 5-second timeout - - try { - const response = await fetchImpl(url, { - method: 'HEAD', - signal: controller.signal - }); - clearTimeout(timeoutId); - - const contentLength = response.headers.get('content-length'); - if (contentLength) { - const sizeInBytes = parseInt(contentLength, 10); - if (!isNaN(sizeInBytes)) { - let formattedSize; - if (sizeInBytes < 1024) formattedSize = `${sizeInBytes} B`; - else if (sizeInBytes < 1024 * 1024) formattedSize = `${(sizeInBytes / 1024).toFixed(2)} KB`; - else if (sizeInBytes < 1024 * 1024 * 1024) formattedSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`; - else formattedSize = `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`; - - await this.saveToCache(urlCacheKey, formattedSize, cacheSubDir); - return formattedSize; - } - } - await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir); - return 'Unknown size'; - } finally { - clearTimeout(timeoutId); - } - } catch (error) { - logger.error(`[XPRIME] Could not fetch size for ${url.substring(0, 50)}...`, error); - await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir); - return 'Unknown size'; - } - } - - private async fetchWithRetry(url: string, options: any, maxRetries: number = MAX_RETRIES_XPRIME) { - let lastError; - - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - const response = await fetchImpl(url, options); - if (!response.ok) { - let errorBody = ''; - try { - errorBody = await response.text(); - } catch (e) { - // ignore - } - - const httpError = new Error(`HTTP error! Status: ${response.status} ${response.statusText}. Body: ${errorBody.substring(0, 200)}`); - (httpError as any).status = response.status; - throw httpError; - } - return response; - } catch (error: any) { - lastError = error; - logger.error(`[XPRIME] Fetch attempt ${attempt}/${maxRetries} failed for ${url}:`, error); - - // If it's a 403 error, stop retrying immediately - if (error.status === 403) { - logger.log(`[XPRIME] Encountered 403 Forbidden for ${url}. Halting retries.`); - throw lastError; - } - - if (attempt < maxRetries) { - await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS_XPRIME * Math.pow(2, attempt - 1))); - } - } - } - - logger.error(`[XPRIME] All fetch attempts failed for ${url}. Last error:`, lastError); - if (lastError) throw lastError; - else throw new Error(`[XPRIME] All fetch attempts failed for ${url} without a specific error captured.`); - } - - async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> { - // XPRIME service has been removed from internal providers - logger.log('[XPRIME] Service has been removed from internal providers'); - return []; - } - - private async getXprimeStreams(title: string, year: number, type: string, seasonNum?: number, episodeNum?: number): Promise<XprimeStream[]> { - let rawXprimeStreams: XprimeStream[] = []; - - try { - logger.log(`[XPRIME] Fetch attempt for '${title}' (${year}). Type: ${type}, S: ${seasonNum}, E: ${episodeNum}`); - - const xprimeName = encodeURIComponent(title); - let xprimeApiUrl: string; - - // type here is tmdbTypeFromId which is 'movie' or 'tv'/'series' - if (type === 'movie') { - xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}`; - } else if (type === 'tv' || type === 'series') { // Accept both 'tv' and 'series' for compatibility - if (seasonNum !== null && seasonNum !== undefined && episodeNum !== null && episodeNum !== undefined) { - xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}&season=${seasonNum}&episode=${episodeNum}`; - } else { - logger.log('[XPRIME] Skipping series request: missing season/episode numbers.'); - return []; - } - } else { - logger.log(`[XPRIME] Skipping request: unknown type '${type}'.`); - return []; - } - - let xprimeResult: any; - - // Direct fetch only - logger.log(`[XPRIME] Fetching directly: ${xprimeApiUrl}`); - const xprimeResponse = await this.fetchWithRetry(xprimeApiUrl, { - headers: { - ...BROWSER_HEADERS_XPRIME, - 'Origin': 'https://pstream.org', - 'Referer': 'https://pstream.org/', - 'Sec-Fetch-Mode': 'cors', - 'Sec-Fetch-Site': 'cross-site', - 'Sec-Fetch-Dest': 'empty' - } - }); - xprimeResult = await xprimeResponse.json(); - - // Process the result - this.processXprimeResult(xprimeResult, rawXprimeStreams, title, type, seasonNum, episodeNum); - - // Fetch stream sizes concurrently for all Xprime streams - if (rawXprimeStreams.length > 0) { - logger.log('[XPRIME] Fetching stream sizes...'); - const sizePromises = rawXprimeStreams.map(async (stream) => { - stream.size = await this.fetchStreamSize(stream.url); - return stream; - }); - await Promise.all(sizePromises); - logger.log(`[XPRIME] Found ${rawXprimeStreams.length} streams with sizes.`); - } - - return rawXprimeStreams; - - } catch (xprimeError) { - logger.error('[XPRIME] Error fetching or processing streams:', xprimeError); - return []; - } - } - - // Helper function to process Xprime API response - private processXprimeResult(xprimeResult: any, rawXprimeStreams: XprimeStream[], title: string, type: string, seasonNum?: number, episodeNum?: number) { - const processXprimeItem = (item: any) => { - if (item && typeof item === 'object' && !item.error && item.streams && typeof item.streams === 'object') { - Object.entries(item.streams).forEach(([quality, fileUrl]) => { - if (fileUrl && typeof fileUrl === 'string') { - rawXprimeStreams.push({ - url: fileUrl, - quality: quality || 'Unknown', - title: `${title} - ${(type === 'tv' || type === 'series') ? `S${String(seasonNum).padStart(2,'0')}E${String(episodeNum).padStart(2,'0')} ` : ''}${quality}`, - provider: 'XPRIME', - codecs: [], - size: 'Unknown size' - }); - } - }); - } else { - logger.log('[XPRIME] Skipping item due to missing/invalid streams or an error was reported by Xprime API:', item && item.error); - } - }; - - if (Array.isArray(xprimeResult)) { - xprimeResult.forEach(processXprimeItem); - } else if (xprimeResult) { - processXprimeItem(xprimeResult); - } else { - logger.log('[XPRIME] No result from Xprime API to process.'); - } - } -} - -export const xprimeService = new XprimeService(); \ No newline at end of file diff --git a/src/styles/screens/discoverStyles.ts b/src/styles/screens/discoverStyles.ts deleted file mode 100644 index 0e00c21..0000000 --- a/src/styles/screens/discoverStyles.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { StyleSheet, Dimensions } from 'react-native'; -import { useTheme } from '../../contexts/ThemeContext'; - -const useDiscoverStyles = () => { - const { width } = Dimensions.get('window'); - const { currentTheme } = useTheme(); - - return StyleSheet.create({ - container: { - flex: 1, - backgroundColor: currentTheme.colors.darkBackground, - }, - headerBackground: { - position: 'absolute', - top: 0, - left: 0, - right: 0, - backgroundColor: currentTheme.colors.darkBackground, - zIndex: 1, - }, - contentContainer: { - flex: 1, - backgroundColor: currentTheme.colors.darkBackground, - }, - header: { - paddingHorizontal: 20, - justifyContent: 'flex-end', - paddingBottom: 8, - backgroundColor: 'transparent', - zIndex: 2, - }, - headerContent: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - headerTitle: { - fontSize: 32, - fontWeight: '800', - color: currentTheme.colors.white, - letterSpacing: 0.3, - }, - searchButton: { - padding: 10, - borderRadius: 24, - backgroundColor: 'rgba(255,255,255,0.08)', - }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - }, - emptyContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingTop: 80, - paddingBottom: 90, - }, - emptyText: { - color: currentTheme.colors.mediumGray, - fontSize: 16, - textAlign: 'center', - paddingHorizontal: 32, - }, - }); -}; - -export default useDiscoverStyles; \ No newline at end of file diff --git a/src/testHDRezka.js b/src/testHDRezka.js deleted file mode 100644 index 19154c7..0000000 --- a/src/testHDRezka.js +++ /dev/null @@ -1,61 +0,0 @@ -// Test script for HDRezka service -const { hdrezkaService } = require('./services/hdrezkaService'); - -// Enable more detailed console logging -const originalConsoleLog = console.log; -console.log = function(...args) { - const timestamp = new Date().toISOString(); - originalConsoleLog(`[${timestamp}]`, ...args); -}; - -// Test function to get streams from HDRezka -async function testHDRezka() { - console.log('Testing HDRezka service...'); - - // Test a popular movie - "Deadpool & Wolverine" (2024) - const movieId = 'tt6263850'; - console.log(`Testing movie ID: ${movieId}`); - - try { - const streams = await hdrezkaService.getStreams(movieId, 'movie'); - console.log('Streams found:', streams.length); - if (streams.length > 0) { - console.log('First stream:', { - name: streams[0].name, - title: streams[0].title, - url: streams[0].url.substring(0, 100) + '...' // Only show part of the URL - }); - } else { - console.log('No streams found.'); - } - } catch (error) { - console.error('Error testing HDRezka:', error); - } - - // Test a TV show - "House of the Dragon" with a specific episode - const showId = 'tt11198330'; - console.log(`\nTesting TV show ID: ${showId}, Season 2 Episode 1`); - - try { - const streams = await hdrezkaService.getStreams(showId, 'series', 2, 1); - console.log('Streams found:', streams.length); - if (streams.length > 0) { - console.log('First stream:', { - name: streams[0].name, - title: streams[0].title, - url: streams[0].url.substring(0, 100) + '...' // Only show part of the URL - }); - } else { - console.log('No streams found.'); - } - } catch (error) { - console.error('Error testing HDRezka TV show:', error); - } -} - -// Run the test -testHDRezka().then(() => { - console.log('Test completed.'); -}).catch(error => { - console.error('Test failed:', error); -}); \ No newline at end of file diff --git a/src/types/metadata.ts b/src/types/metadata.ts index aff9d85..de2338a 100644 --- a/src/types/metadata.ts +++ b/src/types/metadata.ts @@ -1,4 +1,5 @@ import { TMDBEpisode } from '../services/tmdbService'; +import { StreamingContent } from '../services/catalogService'; // Types for route params export type RouteParams = { @@ -74,46 +75,7 @@ export interface Cast { known_for_department?: string; } -// Streaming content type -export interface StreamingContent { - id: string; - type: string; - name: string; - description?: string; - poster?: string; - posterShape?: string; - banner?: string; - logo?: string; - year?: string | number; - runtime?: string; - imdbRating?: string; - genres?: string[]; - director?: string; - writer?: string[]; - cast?: string[]; - releaseInfo?: string; - directors?: string[]; - creators?: string[]; - certification?: string; - released?: string; - trailerStreams?: any[]; - videos?: any[]; - inLibrary?: boolean; - // Enhanced metadata from addons - country?: string; - links?: Array<{ - name: string; - category: string; - url: string; - }>; - behaviorHints?: { - defaultVideoId?: string; - hasScheduledVideos?: boolean; - [key: string]: any; - }; - imdb_id?: string; - slug?: string; -} +// Streaming content type - REMOVED AND IMPORTED FROM catalogService.ts // Navigation types export type RootStackParamList = {