diff --git a/.gitignore b/.gitignore
index f7c2b63..bf6e7ea 100644
--- a/.gitignore
+++ b/.gitignore
@@ -36,3 +36,5 @@ yarn-error.*
# typescript
*.tsbuildinfo
+plan.md
+release_announcement.md
\ No newline at end of file
diff --git a/App.tsx b/App.tsx
index df02cd8..bf19654 100644
--- a/App.tsx
+++ b/App.tsx
@@ -5,7 +5,7 @@
* @format
*/
-import React from 'react';
+import React, { useState } from 'react';
import {
View,
StyleSheet
@@ -24,6 +24,7 @@ import { CatalogProvider } from './src/contexts/CatalogContext';
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';
// This fixes many navigation layout issues by using native screen containers
enableScreens(true);
@@ -31,6 +32,7 @@ enableScreens(true);
// Inner app component that uses the theme context
const ThemedApp = () => {
const { currentTheme } = useTheme();
+ const [isAppReady, setIsAppReady] = useState(false);
// Create custom themes based on current theme
const customDarkTheme = {
@@ -50,6 +52,11 @@ const ThemedApp = () => {
background: currentTheme.colors.darkBackground,
}
};
+
+ // Handler for splash screen completion
+ const handleSplashComplete = () => {
+ setIsAppReady(true);
+ };
return (
@@ -62,7 +69,8 @@ const ThemedApp = () => {
-
+ {!isAppReady && }
+ {isAppReady && }
diff --git a/app.json b/app.json
index 07c328d..5cb6c50 100644
--- a/app.json
+++ b/app.json
@@ -5,13 +5,13 @@
"version": "1.0.0",
"orientation": "default",
"icon": "./assets/icon.png",
- "userInterfaceStyle": "light",
+ "userInterfaceStyle": "dark",
"scheme": "stremioexpo",
"newArchEnabled": true,
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
- "backgroundColor": "#ffffff"
+ "backgroundColor": "#020404"
},
"ios": {
"supportsTablet": true,
@@ -41,15 +41,21 @@
},
"android": {
"adaptiveIcon": {
- "foregroundImage": "./assets/adaptive-icon.png",
- "backgroundColor": "#ffffff"
+ "foregroundImage": "./assets/icon.png",
+ "backgroundColor": "#020404",
+ "monochromeImage": "./assets/icon.png"
},
"permissions": [
"INTERNET",
"WAKE_LOCK"
],
"package": "com.nuvio.app",
- "enableSplitAPKs": true
+ "enableSplitAPKs": true,
+ "versionCode": 1,
+ "enableProguardInReleaseBuilds": true,
+ "enableHermes": true,
+ "enableSeparateBuildPerCPUArchitecture": true,
+ "enableVectorDrawables": true
},
"web": {
"favicon": "./assets/favicon.png"
diff --git a/components/AndroidVideoPlayer.tsx b/components/AndroidVideoPlayer.tsx
new file mode 100644
index 0000000..8865071
--- /dev/null
+++ b/components/AndroidVideoPlayer.tsx
@@ -0,0 +1,119 @@
+import React, { useEffect, useRef, useState } from 'react';
+import { Platform } from 'react-native';
+import Video, { VideoRef, SelectedTrack, BufferingStrategyType } from 'react-native-video';
+
+interface VideoPlayerProps {
+ src: string;
+ paused: boolean;
+ volume: number;
+ currentTime: number;
+ selectedAudioTrack?: SelectedTrack;
+ selectedTextTrack?: SelectedTrack;
+ onProgress?: (data: { currentTime: number; playableDuration: number }) => void;
+ onLoad?: (data: { duration: number }) => void;
+ onError?: (error: any) => void;
+ onBuffer?: (data: { isBuffering: boolean }) => void;
+ onSeek?: (data: { currentTime: number; seekTime: number }) => void;
+ onEnd?: () => void;
+}
+
+export const AndroidVideoPlayer: React.FC = ({
+ src,
+ paused,
+ volume,
+ currentTime,
+ selectedAudioTrack,
+ selectedTextTrack,
+ onProgress,
+ onLoad,
+ onError,
+ onBuffer,
+ onSeek,
+ onEnd,
+}) => {
+ const videoRef = useRef(null);
+ const [isLoaded, setIsLoaded] = useState(false);
+ const [isSeeking, setIsSeeking] = useState(false);
+ const [lastSeekTime, setLastSeekTime] = useState(0);
+
+ // Only render on Android
+ if (Platform.OS !== 'android') {
+ return null;
+ }
+
+ useEffect(() => {
+ if (isLoaded && !isSeeking && Math.abs(currentTime - lastSeekTime) > 1) {
+ setIsSeeking(true);
+ videoRef.current?.seek(currentTime);
+ setLastSeekTime(currentTime);
+ }
+ }, [currentTime, isLoaded, isSeeking, lastSeekTime]);
+
+ const handleLoad = (data: any) => {
+ setIsLoaded(true);
+ onLoad?.(data);
+ };
+
+ const handleProgress = (data: any) => {
+ if (!isSeeking) {
+ onProgress?.(data);
+ }
+ };
+
+ const handleSeek = (data: any) => {
+ setIsSeeking(false);
+ onSeek?.(data);
+ };
+
+ const handleBuffer = (data: any) => {
+ onBuffer?.(data);
+ };
+
+ const handleError = (error: any) => {
+ console.error('Video playback error:', error);
+ onError?.(error);
+ };
+
+ const handleEnd = () => {
+ onEnd?.();
+ };
+
+ return (
+
+ );
+};
+
+export default AndroidVideoPlayer;
\ No newline at end of file
diff --git a/eas.json b/eas.json
index 8a48076..b208a76 100644
--- a/eas.json
+++ b/eas.json
@@ -12,7 +12,13 @@
"distribution": "internal"
},
"production": {
- "autoIncrement": true
+ "autoIncrement": true,
+ "extends": "apk",
+ "android": {
+ "buildType": "apk",
+ "gradleCommand": ":app:assembleRelease",
+ "image": "latest"
+ }
},
"release": {
"distribution": "store",
@@ -22,7 +28,8 @@
},
"apk": {
"android": {
- "buildType": "apk"
+ "buildType": "apk",
+ "gradleCommand": ":app:assembleRelease"
}
}
},
diff --git a/hdrezkas.js b/hdrezkas.js
new file mode 100644
index 0000000..77749ae
--- /dev/null
+++ b/hdrezkas.js
@@ -0,0 +1,516 @@
+// 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 Media type (movie or show)
+ --year, -y Release year
+ --season, -s Season number (for shows)
+ --episode, -e 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: [1080p]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(/\[]*>([^<]+)/);
+ // 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 = /([^<]+)<\/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/metro.config.js b/metro.config.js
index 6218690..79fe23f 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -1,28 +1,31 @@
const { getDefaultConfig } = require('expo/metro-config');
-module.exports = (() => {
- const config = getDefaultConfig(__dirname);
+const config = getDefaultConfig(__dirname);
- const { transformer, resolver } = config;
-
- config.transformer = {
- ...transformer,
- babelTransformerPath: require.resolve('react-native-svg-transformer'),
- minifierConfig: {
- compress: {
- // Remove console.* statements in release builds
- drop_console: true,
- // Keep error logging for critical issues
- pure_funcs: ['console.info', 'console.log', 'console.debug', 'console.warn'],
- },
+// Enable tree shaking and better minification
+config.transformer = {
+ ...config.transformer,
+ babelTransformerPath: require.resolve('react-native-svg-transformer'),
+ minifierConfig: {
+ ecma: 8,
+ keep_fnames: true,
+ mangle: {
+ keep_fnames: true,
},
- };
+ compress: {
+ drop_console: true,
+ drop_debugger: true,
+ pure_funcs: ['console.log', 'console.info', 'console.debug'],
+ },
+ },
+};
- config.resolver = {
- ...resolver,
- assetExts: resolver.assetExts.filter((ext) => ext !== 'svg'),
- sourceExts: [...resolver.sourceExts, 'svg'],
- };
+// Optimize resolver for better tree shaking and SVG support
+config.resolver = {
+ ...config.resolver,
+ assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
+ sourceExts: [...config.resolver.sourceExts, 'svg'],
+ resolverMainFields: ['react-native', 'browser', 'main'],
+};
- return 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 183de35..b49aae1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,11 +7,11 @@
"": {
"name": "nuvio",
"version": "1.0.0",
- "hasInstallScript": true,
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.2",
+ "@movie-web/providers": "^2.4.13",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6",
@@ -24,8 +24,10 @@
"@shopify/flash-list": "1.7.3",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
- "axios": "^1.8.4",
+ "axios": "^1.10.0",
"base64-js": "^1.5.1",
+ "cheerio": "^1.1.0",
+ "cors": "^2.8.5",
"date-fns": "^4.1.0",
"eventemitter3": "^5.0.1",
"expo": "~52.0.43",
@@ -43,7 +45,10 @@
"expo-status-bar": "~2.0.1",
"expo-system-ui": "^4.0.9",
"expo-web-browser": "~14.0.2",
+ "express": "^5.1.0",
"lodash": "^4.17.21",
+ "node-fetch": "^2.6.7",
+ "puppeteer": "^24.10.1",
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-awesome-slider": "^2.9.0",
@@ -60,6 +65,7 @@
"react-native-tab-view": "^4.0.10",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.12.0",
+ "react-native-vlc-media-player": "^1.0.87",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1",
"subsrt": "^1.1.1"
@@ -3248,6 +3254,59 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@movie-web/providers": {
+ "version": "2.4.13",
+ "resolved": "https://registry.npmjs.org/@movie-web/providers/-/providers-2.4.13.tgz",
+ "integrity": "sha512-UDnAdFR6cIy1RxGF1DQa43aF5w1sPhp3H90j2zKPKit7xODspf7PxgJnlnRdg4XhUpHOaEV/eT9W2oaeDMA2VA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "abort-controller": "^3.0.0",
+ "cheerio": "^1.0.0-rc.12",
+ "cookie": "^0.6.0",
+ "crypto-js": "^4.2.0",
+ "form-data": "^4.0.0",
+ "hls-parser": "^0.13.2",
+ "iso-639-1": "^3.1.2",
+ "nanoid": "^3.3.7",
+ "node-fetch": "^3.3.2",
+ "set-cookie-parser": "^2.6.0",
+ "unpacker": "^1.0.1"
+ }
+ },
+ "node_modules/@movie-web/providers/node_modules/form-data": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz",
+ "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==",
+ "license": "MIT",
+ "dependencies": {
+ "asynckit": "^0.4.0",
+ "combined-stream": "^1.0.8",
+ "es-set-tostringtag": "^2.1.0",
+ "mime-types": "^2.1.12"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@movie-web/providers/node_modules/node-fetch": {
+ "version": "3.3.2",
+ "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz",
+ "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==",
+ "license": "MIT",
+ "dependencies": {
+ "data-uri-to-buffer": "^4.0.0",
+ "fetch-blob": "^3.1.4",
+ "formdata-polyfill": "^4.0.10"
+ },
+ "engines": {
+ "node": "^12.20.0 || ^14.13.1 || >=16.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/node-fetch"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -3317,6 +3376,39 @@
"node": ">=14"
}
},
+ "node_modules/@puppeteer/browsers": {
+ "version": "2.10.5",
+ "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.10.5.tgz",
+ "integrity": "sha512-eifa0o+i8dERnngJwKrfp3dEq7ia5XFyoqB17S4gK8GhsQE4/P8nxOfQSE0zQHxzzLo/cmF+7+ywEQ7wK7Fb+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "debug": "^4.4.1",
+ "extract-zip": "^2.0.1",
+ "progress": "^2.0.3",
+ "proxy-agent": "^6.5.0",
+ "semver": "^7.7.2",
+ "tar-fs": "^3.0.8",
+ "yargs": "^17.7.2"
+ },
+ "bin": {
+ "browsers": "lib/cjs/main-cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@puppeteer/browsers/node_modules/semver": {
+ "version": "7.7.2",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz",
+ "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@react-native-async-storage/async-storage": {
"version": "1.23.1",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz",
@@ -4407,6 +4499,12 @@
"node": ">=4"
}
},
+ "node_modules/@tootallnate/quickjs-emscripten": {
+ "version": "0.23.0",
+ "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
+ "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==",
+ "license": "MIT"
+ },
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
@@ -4593,6 +4691,16 @@
"integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==",
"license": "MIT"
},
+ "node_modules/@types/yauzl": {
+ "version": "2.10.3",
+ "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
+ "integrity": "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==",
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@urql/core": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@urql/core/-/core-5.1.1.tgz",
@@ -4663,6 +4771,15 @@
"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",
+ "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/aggregate-error": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz",
@@ -4872,9 +4989,9 @@
}
},
"node_modules/axios": {
- "version": "1.8.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz",
- "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==",
+ "version": "1.10.0",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.10.0.tgz",
+ "integrity": "sha512-/1xYAC4MP/HEG+3duIhFr4ZQXR4sQXOIe+o6sdqzeykGLx6Upp/1p8MHqhINOvGeP7xyNHe7tsiJByc4SSVUxw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
@@ -4897,6 +5014,12 @@
"node": ">= 6"
}
},
+ "node_modules/b4a": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz",
+ "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==",
+ "license": "Apache-2.0"
+ },
"node_modules/babel-core": {
"version": "7.0.0-bridge.0",
"resolved": "https://registry.npmjs.org/babel-core/-/babel-core-7.0.0-bridge.0.tgz",
@@ -5126,6 +5249,78 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
+ "node_modules/bare-events": {
+ "version": "2.5.4",
+ "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz",
+ "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==",
+ "license": "Apache-2.0",
+ "optional": true
+ },
+ "node_modules/bare-fs": {
+ "version": "4.1.5",
+ "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.5.tgz",
+ "integrity": "sha512-1zccWBMypln0jEE05LzZt+V/8y8AQsQQqxtklqaIyg5nu6OAYFhZxPXinJTSG+kU5qyNmeLgcn9AW7eHiCHVLA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-events": "^2.5.4",
+ "bare-path": "^3.0.0",
+ "bare-stream": "^2.6.4"
+ },
+ "engines": {
+ "bare": ">=1.16.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/bare-os": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz",
+ "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "engines": {
+ "bare": ">=1.14.0"
+ }
+ },
+ "node_modules/bare-path": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz",
+ "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "bare-os": "^3.0.1"
+ }
+ },
+ "node_modules/bare-stream": {
+ "version": "2.6.5",
+ "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz",
+ "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==",
+ "license": "Apache-2.0",
+ "optional": true,
+ "dependencies": {
+ "streamx": "^2.21.0"
+ },
+ "peerDependencies": {
+ "bare-buffer": "*",
+ "bare-events": "*"
+ },
+ "peerDependenciesMeta": {
+ "bare-buffer": {
+ "optional": true
+ },
+ "bare-events": {
+ "optional": true
+ }
+ }
+ },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -5146,6 +5341,15 @@
],
"license": "MIT"
},
+ "node_modules/basic-ftp": {
+ "version": "5.0.5",
+ "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.0.5.tgz",
+ "integrity": "sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/better-opn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/better-opn/-/better-opn-3.0.2.tgz",
@@ -5184,6 +5388,38 @@
"node": ">=0.6"
}
},
+ "node_modules/body-parser": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz",
+ "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^1.0.5",
+ "debug": "^4.4.0",
+ "http-errors": "^2.0.0",
+ "iconv-lite": "^0.6.3",
+ "on-finished": "^2.4.1",
+ "qs": "^6.14.0",
+ "raw-body": "^3.0.0",
+ "type-is": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/body-parser/node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -5314,6 +5550,15 @@
"integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==",
"license": "MIT"
},
+ "node_modules/buffer-crc32": {
+ "version": "0.2.13",
+ "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
+ "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==",
+ "license": "MIT",
+ "engines": {
+ "node": "*"
+ }
+ },
"node_modules/buffer-fill": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz",
@@ -5498,6 +5743,57 @@
"node": "*"
}
},
+ "node_modules/cheerio": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.0.tgz",
+ "integrity": "sha512-+0hMx9eYhJvWbgpKV9hN7jg0JcwydpopZE4hgi+KvQtByZXPp04NiCWU0LzcAbP63abZckIHkTQaXVF52mX3xQ==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.0",
+ "htmlparser2": "^10.0.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.10.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=18.17"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/cheerio/node_modules/undici": {
+ "version": "7.10.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.10.0.tgz",
+ "integrity": "sha512-u5otvFBOBZvmdjWLVW+5DAc9Nkq8f24g0O9oY7qw2JVIF1VocIFoyz9JFkuVOS2j41AufeO0xnlweJ2RLT8nGw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
@@ -5525,6 +5821,19 @@
"node": ">=12.13.0"
}
},
+ "node_modules/chromium-bidi": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-5.1.0.tgz",
+ "integrity": "sha512-9MSRhWRVoRPDG0TgzkHrshFSJJNZzfY5UFqUMuksg7zL1yoZIZ3jLB0YAgHclbiAxPI86pBnwDX1tbzoiV8aFw==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "mitt": "^3.0.1",
+ "zod": "^3.24.1"
+ },
+ "peerDependencies": {
+ "devtools-protocol": "*"
+ }
+ },
"node_modules/chromium-edge-launcher": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/chromium-edge-launcher/-/chromium-edge-launcher-0.2.0.tgz",
@@ -5835,12 +6144,51 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
+ "node_modules/content-disposition": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz",
+ "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"license": "MIT"
},
+ "node_modules/cookie": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
+ "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
"node_modules/core-js-compat": {
"version": "3.41.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.41.0.tgz",
@@ -5854,6 +6202,19 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/cors": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
+ "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
"node_modules/cosmiconfig": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz",
@@ -5901,6 +6262,12 @@
"node": "*"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
"node_modules/crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@@ -6011,6 +6378,15 @@
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
},
+ "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",
+ "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 12"
+ }
+ },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
@@ -6022,9 +6398,9 @@
}
},
"node_modules/debug": {
- "version": "4.4.0",
- "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
- "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
+ "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -6133,6 +6509,32 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/degenerator": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
+ "integrity": "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ast-types": "^0.13.4",
+ "escodegen": "^2.1.0",
+ "esprima": "^4.0.1"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/degenerator/node_modules/ast-types": {
+ "version": "0.13.4",
+ "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz",
+ "integrity": "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==",
+ "license": "MIT",
+ "dependencies": {
+ "tslib": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/del": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/del/-/del-6.1.1.tgz",
@@ -6195,6 +6597,12 @@
"node": ">=0.10"
}
},
+ "node_modules/devtools-protocol": {
+ "version": "0.0.1452169",
+ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1452169.tgz",
+ "integrity": "sha512-FOFDVMGrAUNp0dDKsAU1TorWJUx2JOU1k9xdgBKKJF3IBh/Uhl2yswG5r3TEAOrCiGY2QRp1e6LVDQrCsTKO4g==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@@ -6347,6 +6755,19 @@
"node": ">= 0.8"
}
},
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz",
+ "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
"node_modules/end-of-stream": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz",
@@ -6377,6 +6798,15 @@
"node": ">=8"
}
},
+ "node_modules/env-paths": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz",
+ "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -6467,6 +6897,37 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/escodegen": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-2.1.0.tgz",
+ "integrity": "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "esprima": "^4.0.1",
+ "estraverse": "^5.2.0",
+ "esutils": "^2.0.2"
+ },
+ "bin": {
+ "escodegen": "bin/escodegen.js",
+ "esgenerate": "bin/esgenerate.js"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "optionalDependencies": {
+ "source-map": "~0.6.1"
+ }
+ },
+ "node_modules/escodegen/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/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
@@ -6480,12 +6941,20 @@
"node": ">=4"
}
},
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -7076,12 +7545,231 @@
"integrity": "sha512-8QxYTVXUkuy7fIIoitQkPwGonB8F3Zj8eEO8Sqg9Zv/bkI7RJAzowee4gr81Hak/dUTpA2Z7VfQgoijjPNlUZA==",
"license": "Apache-2.0"
},
+ "node_modules/express": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz",
+ "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.0",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express/node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/express/node_modules/finalhandler": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz",
+ "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/express/node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/express/node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express/node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/express/node_modules/send": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz",
+ "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.3.5",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "mime-types": "^3.0.1",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/express/node_modules/serve-static": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
+ "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/express/node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/extract-zip": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",
+ "integrity": "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "debug": "^4.1.1",
+ "get-stream": "^5.1.0",
+ "yauzl": "^2.10.0"
+ },
+ "bin": {
+ "extract-zip": "cli.js"
+ },
+ "engines": {
+ "node": ">= 10.17.0"
+ },
+ "optionalDependencies": {
+ "@types/yauzl": "^2.9.1"
+ }
+ },
+ "node_modules/extract-zip/node_modules/get-stream": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
+ "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
+ "node_modules/fast-fifo": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz",
+ "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==",
+ "license": "MIT"
+ },
"node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -7158,6 +7846,38 @@
"integrity": "sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ==",
"license": "MIT"
},
+ "node_modules/fd-slicer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
+ "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
+ "license": "MIT",
+ "dependencies": {
+ "pend": "~1.2.0"
+ }
+ },
+ "node_modules/fetch-blob": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz",
+ "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "paypal",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "node-domexception": "^1.0.0",
+ "web-streams-polyfill": "^3.0.3"
+ },
+ "engines": {
+ "node": "^12.20 || >= 14.13"
+ }
+ },
"node_modules/fetch-retry": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-4.1.1.tgz",
@@ -7335,6 +8055,27 @@
"node": ">= 6"
}
},
+ "node_modules/formdata-polyfill": {
+ "version": "4.0.10",
+ "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz",
+ "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==",
+ "license": "MIT",
+ "dependencies": {
+ "fetch-blob": "^3.1.2"
+ },
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/freeport-async": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/freeport-async/-/freeport-async-2.0.0.tgz",
@@ -7484,6 +8225,29 @@
"node": ">=6"
}
},
+ "node_modules/get-uri": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-6.0.4.tgz",
+ "integrity": "sha512-E1b1lFFLvLgak2whF2xDBcOy6NLVGZBqqjJjsIhvopKfWWEi64pLVTWWehV8KlLerZkfNTA95sTe2OdJKm1OzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "basic-ftp": "^5.0.2",
+ "data-uri-to-buffer": "^6.0.2",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/get-uri/node_modules/data-uri-to-buffer": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz",
+ "integrity": "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/getenv": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/getenv/-/getenv-1.0.0.tgz",
@@ -7671,6 +8435,12 @@
"hermes-estree": "0.23.1"
}
},
+ "node_modules/hls-parser": {
+ "version": "0.13.5",
+ "resolved": "https://registry.npmjs.org/hls-parser/-/hls-parser-0.13.5.tgz",
+ "integrity": "sha512-UJyTCcNZwOdBmEJo86vViEpgtaUhxrgAsBb65+Lk6fjzyOfIDVF9Y0TyE0KJ2Gc5YHfHx7xevuj/RR0itP3vaA==",
+ "license": "MIT"
+ },
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -7704,6 +8474,37 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
+ "node_modules/htmlparser2": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.0.0.tgz",
+ "integrity": "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.1",
+ "entities": "^6.0.0"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/http-errors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
@@ -7729,6 +8530,32 @@
"node": ">= 0.8"
}
},
+ "node_modules/http-proxy-agent": {
+ "version": "7.0.2",
+ "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz",
+ "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.0",
+ "debug": "^4.3.4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/https-proxy-agent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
+ "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/human-signals": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -7744,6 +8571,18 @@
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
"license": "BSD-3-Clause"
},
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -7883,6 +8722,25 @@
"loose-envify": "^1.0.0"
}
},
+ "node_modules/ip-address": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz",
+ "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==",
+ "license": "MIT",
+ "dependencies": {
+ "jsbn": "1.1.0",
+ "sprintf-js": "^1.1.3"
+ },
+ "engines": {
+ "node": ">= 12"
+ }
+ },
+ "node_modules/ip-address/node_modules/sprintf-js": {
+ "version": "1.1.3",
+ "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
+ "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/ip-regex": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/ip-regex/-/ip-regex-2.1.0.tgz",
@@ -8092,6 +8950,12 @@
"node": ">=0.10.0"
}
},
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
"node_modules/is-regex": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -8152,6 +9016,15 @@
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC"
},
+ "node_modules/iso-639-1": {
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/iso-639-1/-/iso-639-1-3.1.5.tgz",
+ "integrity": "sha512-gXkz5+KN7HrG0Q5UGqSMO2qB9AsbEeyLP54kF1YrMsIxmu+g4BdB7rflReZTSTZGpfj8wywu6pfPBCylPIzGQA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0"
+ }
+ },
"node_modules/isobject": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz",
@@ -8414,6 +9287,12 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jsbn": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz",
+ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==",
+ "license": "MIT"
+ },
"node_modules/jsc-android": {
"version": "250231.0.0",
"resolved": "https://registry.npmjs.org/jsc-android/-/jsc-android-250231.0.0.tgz",
@@ -8481,7 +9360,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz",
"integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==",
- "dev": true,
"license": "MIT"
},
"node_modules/json-schema-traverse": {
@@ -9022,12 +9900,33 @@
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==",
"license": "CC0-1.0"
},
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
"license": "MIT"
},
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
"node_modules/merge-options": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/merge-options/-/merge-options-3.0.4.tgz",
@@ -9619,6 +10518,12 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
"node_modules/mkdirp": {
"version": "0.5.6",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz",
@@ -9687,6 +10592,15 @@
"integrity": "sha512-SrQrok4CATudVzBS7coSz26QRSmlK9TzzoFbeKfcPBUFPjcQM9Rqvr/DlJkOrwI/0KcgvMub1n1g5Jt9EgRn4A==",
"license": "MIT"
},
+ "node_modules/netmask": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
+ "integrity": "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
"node_modules/nice-try": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz",
@@ -9716,6 +10630,26 @@
"node": ">= 0.10.5"
}
},
+ "node_modules/node-domexception": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+ "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+ "deprecated": "Use your platform's native DOMException instead",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/jimmywarting"
+ },
+ {
+ "type": "github",
+ "url": "https://paypal.me/jimmywarting"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.5.0"
+ }
+ },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -9853,6 +10787,18 @@
"node": ">=0.10.0"
}
},
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/object-is": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
@@ -10128,6 +11074,38 @@
"node": ">=6"
}
},
+ "node_modules/pac-proxy-agent": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/pac-proxy-agent/-/pac-proxy-agent-7.2.0.tgz",
+ "integrity": "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tootallnate/quickjs-emscripten": "^0.23.0",
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "get-uri": "^6.0.1",
+ "http-proxy-agent": "^7.0.0",
+ "https-proxy-agent": "^7.0.6",
+ "pac-resolver": "^7.0.1",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/pac-resolver": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/pac-resolver/-/pac-resolver-7.0.1.tgz",
+ "integrity": "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==",
+ "license": "MIT",
+ "dependencies": {
+ "degenerator": "^5.0.0",
+ "netmask": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
@@ -10138,7 +11116,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
"integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"callsites": "^3.0.0"
@@ -10151,7 +11128,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
"integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -10182,6 +11158,55 @@
"node": ">=10"
}
},
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz",
+ "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -10253,6 +11278,15 @@
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"license": "ISC"
},
+ "node_modules/path-to-regexp": {
+ "version": "8.2.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz",
+ "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=16"
+ }
+ },
"node_modules/path-type": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz",
@@ -10262,6 +11296,12 @@
"node": ">=8"
}
},
+ "node_modules/pend": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
+ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
+ "license": "MIT"
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -10550,6 +11590,47 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/proxy-agent": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz",
+ "integrity": "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "http-proxy-agent": "^7.0.1",
+ "https-proxy-agent": "^7.0.6",
+ "lru-cache": "^7.14.1",
+ "pac-proxy-agent": "^7.1.0",
+ "proxy-from-env": "^1.1.0",
+ "socks-proxy-agent": "^8.0.5"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/proxy-agent/node_modules/lru-cache": {
+ "version": "7.18.3",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz",
+ "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
@@ -10575,6 +11656,131 @@
"node": ">=6"
}
},
+ "node_modules/puppeteer": {
+ "version": "24.10.2",
+ "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.10.2.tgz",
+ "integrity": "sha512-+k26rCz6akFZntx0hqUoFjCojgOLIxZs6p2k53LmEicwsT8F/FMBKfRfiBw1sitjiCvlR/15K7lBqfjXa251FA==",
+ "hasInstallScript": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "2.10.5",
+ "chromium-bidi": "5.1.0",
+ "cosmiconfig": "^9.0.0",
+ "devtools-protocol": "0.0.1452169",
+ "puppeteer-core": "24.10.2",
+ "typed-query-selector": "^2.12.0"
+ },
+ "bin": {
+ "puppeteer": "lib/cjs/puppeteer/node/cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/puppeteer-core": {
+ "version": "24.10.2",
+ "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.10.2.tgz",
+ "integrity": "sha512-CnzhOgrZj8DvkDqI+Yx+9or33i3Y9uUYbKyYpP4C13jWwXx/keQ38RMTMmxuLCWQlxjZrOH0Foq7P2fGP7adDQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@puppeteer/browsers": "2.10.5",
+ "chromium-bidi": "5.1.0",
+ "debug": "^4.4.1",
+ "devtools-protocol": "0.0.1452169",
+ "typed-query-selector": "^2.12.0",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/puppeteer/node_modules/argparse": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
+ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+ "license": "Python-2.0"
+ },
+ "node_modules/puppeteer/node_modules/cosmiconfig": {
+ "version": "9.0.0",
+ "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
+ "integrity": "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==",
+ "license": "MIT",
+ "dependencies": {
+ "env-paths": "^2.2.1",
+ "import-fresh": "^3.3.0",
+ "js-yaml": "^4.1.0",
+ "parse-json": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/d-fischer"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.9.5"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/puppeteer/node_modules/import-fresh": {
+ "version": "3.3.1",
+ "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
+ "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==",
+ "license": "MIT",
+ "dependencies": {
+ "parent-module": "^1.0.0",
+ "resolve-from": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/puppeteer/node_modules/js-yaml": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
+ "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
+ "license": "MIT",
+ "dependencies": {
+ "argparse": "^2.0.1"
+ },
+ "bin": {
+ "js-yaml": "bin/js-yaml.js"
+ }
+ },
+ "node_modules/puppeteer/node_modules/parse-json": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz",
+ "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.0.0",
+ "error-ex": "^1.3.1",
+ "json-parse-even-better-errors": "^2.3.0",
+ "lines-and-columns": "^1.1.6"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/puppeteer/node_modules/resolve-from": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
+ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/qrcode-terminal": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz",
@@ -10583,6 +11789,21 @@
"qrcode-terminal": "bin/qrcode-terminal.js"
}
},
+ "node_modules/qs": {
+ "version": "6.14.0",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
+ "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/query-string": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
@@ -10639,6 +11860,21 @@
"node": ">= 0.6"
}
},
+ "node_modules/raw-body": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz",
+ "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "http-errors": "2.0.0",
+ "iconv-lite": "0.6.3",
+ "unpipe": "1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/rc": {
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -11011,6 +12247,15 @@
"react-native": "*"
}
},
+ "node_modules/react-native-slider": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/react-native-slider/-/react-native-slider-0.11.0.tgz",
+ "integrity": "sha512-jV9K87eu9uWr0uJIyrSpBLnCKvVlOySC2wynq9TFCdV9oGgjt7Niq8Q1A8R8v+5GHsuBw/s8vEj1AAkkUi+u+w==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.5.6"
+ }
+ },
"node_modules/react-native-svg": {
"version": "15.11.2",
"resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.11.2.tgz",
@@ -11172,6 +12417,100 @@
"react-native": "*"
}
},
+ "node_modules/react-native-vlc-media-player": {
+ "version": "1.0.87",
+ "resolved": "https://registry.npmjs.org/react-native-vlc-media-player/-/react-native-vlc-media-player-1.0.87.tgz",
+ "integrity": "sha512-b05fW2WXVEFoatUcEcszi49FyiBF6ca9HZNQgpJYahL79obLHXRUMejh1RMlxC511UKS+TsDIe2pMJfi8NFbaA==",
+ "license": "MIT",
+ "dependencies": {
+ "react-native-slider": "^0.11.0",
+ "react-native-vector-icons": "^9.2.0"
+ }
+ },
+ "node_modules/react-native-vlc-media-player/node_modules/cliui": {
+ "version": "7.0.4",
+ "resolved": "https://registry.npmjs.org/cliui/-/cliui-7.0.4.tgz",
+ "integrity": "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==",
+ "license": "ISC",
+ "dependencies": {
+ "string-width": "^4.2.0",
+ "strip-ansi": "^6.0.0",
+ "wrap-ansi": "^7.0.0"
+ }
+ },
+ "node_modules/react-native-vlc-media-player/node_modules/emoji-regex": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
+ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
+ "license": "MIT"
+ },
+ "node_modules/react-native-vlc-media-player/node_modules/react-native-vector-icons": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-native-vector-icons/-/react-native-vector-icons-9.2.0.tgz",
+ "integrity": "sha512-wKYLaFuQST/chH3AJRjmOLoLy3JEs1JR6zMNgTaemFpNoXs0ztRnTxcxFD9xhX7cJe1/zoN5BpQYe7kL0m5yyA==",
+ "license": "MIT",
+ "dependencies": {
+ "prop-types": "^15.7.2",
+ "yargs": "^16.1.1"
+ },
+ "bin": {
+ "fa5-upgrade": "bin/fa5-upgrade.sh",
+ "generate-icon": "bin/generate-icon.js"
+ }
+ },
+ "node_modules/react-native-vlc-media-player/node_modules/string-width": {
+ "version": "4.2.3",
+ "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
+ "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
+ "license": "MIT",
+ "dependencies": {
+ "emoji-regex": "^8.0.0",
+ "is-fullwidth-code-point": "^3.0.0",
+ "strip-ansi": "^6.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/react-native-vlc-media-player/node_modules/strip-ansi": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
+ "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
+ "license": "MIT",
+ "dependencies": {
+ "ansi-regex": "^5.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/react-native-vlc-media-player/node_modules/yargs": {
+ "version": "16.2.0",
+ "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
+ "integrity": "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==",
+ "license": "MIT",
+ "dependencies": {
+ "cliui": "^7.0.2",
+ "escalade": "^3.1.1",
+ "get-caller-file": "^2.0.5",
+ "require-directory": "^2.1.1",
+ "string-width": "^4.2.0",
+ "y18n": "^5.0.5",
+ "yargs-parser": "^20.2.2"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/react-native-vlc-media-player/node_modules/yargs-parser": {
+ "version": "20.2.9",
+ "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz",
+ "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/react-native-web": {
"version": "0.19.13",
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
@@ -11578,6 +12917,22 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -11638,6 +12993,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
@@ -11846,6 +13207,12 @@
"node": ">= 0.8"
}
},
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.1",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
+ "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
+ "license": "MIT"
+ },
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -11920,6 +13287,78 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/side-channel": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+ "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3",
+ "side-channel-list": "^1.0.0",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
+ "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -12003,6 +13442,16 @@
"node": ">=8.0.0"
}
},
+ "node_modules/smart-buffer": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
+ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 6.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
"node_modules/snake-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz",
@@ -12014,6 +13463,34 @@
"tslib": "^2.0.3"
}
},
+ "node_modules/socks": {
+ "version": "2.8.5",
+ "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.5.tgz",
+ "integrity": "sha512-iF+tNDQla22geJdTyJB1wM/qrX9DMRwWrciEPwWLPRWAUEM8sQiyxgckLxWT1f7+9VabJS0jTGGr4QgBuvi6Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "ip-address": "^9.0.5",
+ "smart-buffer": "^4.2.0"
+ },
+ "engines": {
+ "node": ">= 10.0.0",
+ "npm": ">= 3.0.0"
+ }
+ },
+ "node_modules/socks-proxy-agent": {
+ "version": "8.0.5",
+ "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz",
+ "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "^7.1.2",
+ "debug": "^4.3.4",
+ "socks": "^2.8.3"
+ },
+ "engines": {
+ "node": ">= 14"
+ }
+ },
"node_modules/source-map": {
"version": "0.5.7",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",
@@ -12147,6 +13624,19 @@
"node": ">= 0.10.0"
}
},
+ "node_modules/streamx": {
+ "version": "2.22.1",
+ "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz",
+ "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-fifo": "^1.3.2",
+ "text-decoder": "^1.1.0"
+ },
+ "optionalDependencies": {
+ "bare-events": "^2.2.0"
+ }
+ },
"node_modules/strict-uri-encode": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
@@ -12440,6 +13930,31 @@
"node": ">=10"
}
},
+ "node_modules/tar-fs": {
+ "version": "3.0.10",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.0.10.tgz",
+ "integrity": "sha512-C1SwlQGNLe/jPNqapK8epDsXME7CAJR5RL3GcE6KWx1d9OUByzoHVcbu1VPI8tevg9H8Alae0AApHHFGzrD5zA==",
+ "license": "MIT",
+ "dependencies": {
+ "pump": "^3.0.0",
+ "tar-stream": "^3.1.5"
+ },
+ "optionalDependencies": {
+ "bare-fs": "^4.0.1",
+ "bare-path": "^3.0.0"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "3.1.7",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz",
+ "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==",
+ "license": "MIT",
+ "dependencies": {
+ "b4a": "^1.6.4",
+ "fast-fifo": "^1.2.0",
+ "streamx": "^2.15.0"
+ }
+ },
"node_modules/tar/node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
@@ -12664,6 +14179,15 @@
"url": "https://github.com/sponsors/isaacs"
}
},
+ "node_modules/text-decoder": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz",
+ "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "b4a": "^1.6.4"
+ }
+ },
"node_modules/thenify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -12766,11 +14290,43 @@
"node": ">=8"
}
},
+ "node_modules/type-is": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
+ "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^1.0.5",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/type-is/node_modules/mime-types": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz",
+ "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/typed-query-selector": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.0.tgz",
+ "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==",
+ "license": "MIT"
+ },
"node_modules/typescript": {
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
- "dev": true,
+ "devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -12906,6 +14462,12 @@
"node": ">= 4.0.0"
}
},
+ "node_modules/unpacker": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/unpacker/-/unpacker-1.0.1.tgz",
+ "integrity": "sha512-0HTljwp8+JBdITpoHcK1LWi7X9U2BspUmWv78UWZh7NshYhbh1nec8baY/iSbe2OQTZ2bhAtVdnr6/BTD0DKVg==",
+ "license": "MIT"
+ },
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -13066,12 +14628,33 @@
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
+ "node_modules/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
@@ -13270,9 +14853,9 @@
"license": "ISC"
},
"node_modules/ws": {
- "version": "8.18.1",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz",
- "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==",
+ "version": "8.18.2",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.2.tgz",
+ "integrity": "sha512-DMricUmwGZUVr++AEAe2uiVM7UoO9MAVZMDu05UQOaUII0lp+zOzLLU4Xqh/JvTqklB1T4uELaaPBKyjE1r4fQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
@@ -13417,6 +15000,16 @@
"node": ">=8"
}
},
+ "node_modules/yauzl": {
+ "version": "2.10.0",
+ "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
+ "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-crc32": "~0.2.3",
+ "fd-slicer": "~1.1.0"
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
@@ -13428,6 +15021,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
+ },
+ "node_modules/zod": {
+ "version": "3.25.67",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.67.tgz",
+ "integrity": "sha512-idA2YXwpCdqUSKRCACDE6ItZD9TZzy3OZMtpfLoh6oPR47lipysRrJfjzMqFxQ3uJuUPyUeWe1r9vLH33xO/Qw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
}
}
}
diff --git a/package.json b/package.json
index 116472a..6aae894 100644
--- a/package.json
+++ b/package.json
@@ -6,13 +6,13 @@
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
- "web": "expo start --web",
- "postinstall": "node patch-package.js"
+ "web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.2",
+ "@movie-web/providers": "^2.4.13",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6",
@@ -25,8 +25,10 @@
"@shopify/flash-list": "1.7.3",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
- "axios": "^1.8.4",
+ "axios": "^1.10.0",
"base64-js": "^1.5.1",
+ "cheerio": "^1.1.0",
+ "cors": "^2.8.5",
"date-fns": "^4.1.0",
"eventemitter3": "^5.0.1",
"expo": "~52.0.43",
@@ -44,7 +46,10 @@
"expo-status-bar": "~2.0.1",
"expo-system-ui": "^4.0.9",
"expo-web-browser": "~14.0.2",
+ "express": "^5.1.0",
"lodash": "^4.17.21",
+ "node-fetch": "^2.6.7",
+ "puppeteer": "^24.10.1",
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-awesome-slider": "^2.9.0",
@@ -61,6 +66,7 @@
"react-native-tab-view": "^4.0.10",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.12.0",
+ "react-native-vlc-media-player": "^1.0.87",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1",
"subsrt": "^1.1.1"
diff --git a/patch-package.js b/patch-package.js
deleted file mode 100644
index 6998999..0000000
--- a/patch-package.js
+++ /dev/null
@@ -1,42 +0,0 @@
-const fs = require('fs');
-const path = require('path');
-const { execSync } = require('child_process');
-
-// Directory containing patches
-const patchesDir = path.join(__dirname, 'src/patches');
-
-// Check if the directory exists
-if (!fs.existsSync(patchesDir)) {
- console.error(`Patches directory not found: ${patchesDir}`);
- process.exit(1);
-}
-
-// Get all patch files
-const patches = fs.readdirSync(patchesDir).filter(file => file.endsWith('.patch'));
-
-if (patches.length === 0) {
- console.log('No patch files found.');
- process.exit(0);
-}
-
-console.log(`Found ${patches.length} patch files.`);
-
-// Apply each patch
-patches.forEach(patchFile => {
- const patchPath = path.join(patchesDir, patchFile);
- console.log(`Applying patch: ${patchFile}`);
-
- try {
- // Use the patch command to apply the patch file
- execSync(`patch -p1 < ${patchPath}`, {
- stdio: 'inherit',
- cwd: process.cwd()
- });
- console.log(`✅ Successfully applied patch: ${patchFile}`);
- } catch (error) {
- console.error(`❌ Failed to apply patch ${patchFile}:`, error.message);
- // Continue with other patches even if one fails
- }
-});
-
-console.log('Patch process completed.');
\ No newline at end of file
diff --git a/scripts/test-hdrezka.js b/scripts/test-hdrezka.js
new file mode 100644
index 0000000..1d190aa
--- /dev/null
+++ b/scripts/test-hdrezka.js
@@ -0,0 +1,434 @@
+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(/\[]*>([^<]+)/);
+ 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 = /([^<]+)<\/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/NuvioHeader.tsx b/src/components/NuvioHeader.tsx
index d6c10c3..77ea5ff 100644
--- a/src/components/NuvioHeader.tsx
+++ b/src/components/NuvioHeader.tsx
@@ -81,7 +81,7 @@ const styles = StyleSheet.create({
},
headerContainer: {
height: Platform.OS === 'ios' ? 100 : 90,
- paddingTop: Platform.OS === 'ios' ? 35 : 20,
+ paddingTop: Platform.OS === 'ios' ? 35 : 35,
backgroundColor: 'rgba(0,0,0,0.3)',
},
blurOverlay: {
diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx
new file mode 100644
index 0000000..fabe7b8
--- /dev/null
+++ b/src/components/SplashScreen.tsx
@@ -0,0 +1,59 @@
+import React, { useEffect } from 'react';
+import { View, Image, StyleSheet, Animated } from 'react-native';
+import { colors } from '../styles/colors';
+
+interface SplashScreenProps {
+ onFinish: () => void;
+}
+
+const SplashScreen = ({ onFinish }: SplashScreenProps) => {
+ // Animation value for opacity
+ const fadeAnim = new Animated.Value(1);
+
+ useEffect(() => {
+ // Wait for a short period then start fade out animation
+ const timer = setTimeout(() => {
+ Animated.timing(fadeAnim, {
+ toValue: 0,
+ duration: 800,
+ useNativeDriver: true,
+ }).start(() => {
+ // Call onFinish when animation completes
+ onFinish();
+ });
+ }, 1500); // Show splash for 1.5 seconds
+
+ return () => clearTimeout(timer);
+ }, [fadeAnim, onFinish]);
+
+ return (
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ justifyContent: 'center',
+ alignItems: 'center',
+ backgroundColor: colors.darkBackground,
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ zIndex: 10,
+ },
+ image: {
+ width: '70%',
+ height: '70%',
+ },
+});
+
+export default SplashScreen;
\ No newline at end of file
diff --git a/src/components/discover/CatalogsList.tsx b/src/components/discover/CatalogsList.tsx
index 5b07495..6a8bcdf 100644
--- a/src/components/discover/CatalogsList.tsx
+++ b/src/components/discover/CatalogsList.tsx
@@ -37,6 +37,7 @@ const CatalogsList = ({ catalogs, selectedCategory }: CatalogsListProps) => {
const styles = StyleSheet.create({
container: {
paddingVertical: 8,
+ paddingBottom: 90,
},
});
diff --git a/src/components/discover/ContentItem.tsx b/src/components/discover/ContentItem.tsx
index 015db8c..de22a61 100644
--- a/src/components/discover/ContentItem.tsx
+++ b/src/components/discover/ContentItem.tsx
@@ -51,7 +51,7 @@ const styles = StyleSheet.create({
marginHorizontal: 0,
},
posterContainer: {
- borderRadius: 16,
+ borderRadius: 8,
overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.03)',
elevation: 5,
diff --git a/src/components/home/CatalogSection.tsx b/src/components/home/CatalogSection.tsx
index 8f4ed81..1a2987d 100644
--- a/src/components/home/CatalogSection.tsx
+++ b/src/components/home/CatalogSection.tsx
@@ -14,14 +14,53 @@ interface CatalogSectionProps {
}
const { width } = Dimensions.get('window');
-const POSTER_WIDTH = (width - 50) / 3;
+
+// Dynamic poster calculation based on screen width - show 1/4 of next poster
+const calculatePosterLayout = (screenWidth: number) => {
+ const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
+ const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
+ const LEFT_PADDING = 16; // Left padding
+ const SPACING = 8; // Space between posters
+
+ // Calculate available width for posters (reserve space for left padding)
+ const availableWidth = screenWidth - LEFT_PADDING;
+
+ // Try different numbers of full posters to find the best fit
+ let bestLayout = { numFullPosters: 3, posterWidth: 120 };
+
+ for (let n = 3; n <= 6; n++) {
+ // Calculate poster width needed for N full posters + 0.25 partial poster
+ // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
+ // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
+ // We'll use minimal right padding (8px) to maximize space
+ 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`);
+ }
+ }
+
+ return {
+ numFullPosters: bestLayout.numFullPosters,
+ posterWidth: bestLayout.posterWidth,
+ spacing: SPACING,
+ partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
+ };
+};
+
+const posterLayout = calculatePosterLayout(width);
+const POSTER_WIDTH = posterLayout.posterWidth;
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const navigation = useNavigation>();
const { currentTheme } = useTheme();
const handleContentPress = (id: string, type: string) => {
- navigation.navigate('Metadata', { id, type });
+ navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
};
const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => {
@@ -73,18 +112,18 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
keyExtractor={(item) => `${item.id}-${item.type}`}
horizontal
showsHorizontalScrollIndicator={false}
- contentContainerStyle={styles.catalogList}
- snapToInterval={POSTER_WIDTH + 12}
+ contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]}
+ snapToInterval={POSTER_WIDTH + 8}
decelerationRate="fast"
snapToAlignment="start"
- ItemSeparatorComponent={() => }
+ ItemSeparatorComponent={() => }
initialNumToRender={4}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
getItemLayout={(data, index) => ({
- length: POSTER_WIDTH + 12,
- offset: (POSTER_WIDTH + 12) * index,
+ length: POSTER_WIDTH + 8,
+ offset: (POSTER_WIDTH + 8) * index,
index,
})}
/>
@@ -107,19 +146,19 @@ const styles = StyleSheet.create({
position: 'relative',
},
catalogTitle: {
- fontSize: 18,
- fontWeight: '800',
- textTransform: 'uppercase',
- letterSpacing: 0.5,
- marginBottom: 6,
+ fontSize: 19,
+ fontWeight: '700',
+ letterSpacing: 0.2,
+ marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
- bottom: -4,
+ bottom: -2,
left: 0,
- width: 60,
- height: 3,
- borderRadius: 1.5,
+ width: 35,
+ height: 2,
+ borderRadius: 1,
+ opacity: 0.8,
},
seeAllButton: {
flexDirection: 'row',
diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx
index c116271..860a301 100644
--- a/src/components/home/ContentItem.tsx
+++ b/src/components/home/ContentItem.tsx
@@ -12,7 +12,46 @@ interface ContentItemProps {
}
const { width } = Dimensions.get('window');
-const POSTER_WIDTH = (width - 50) / 3;
+
+// Dynamic poster calculation based on screen width - show 1/4 of next poster
+const calculatePosterLayout = (screenWidth: number) => {
+ const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
+ const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
+ const LEFT_PADDING = 16; // Left padding
+ const SPACING = 8; // Space between posters
+
+ // Calculate available width for posters (reserve space for left padding)
+ const availableWidth = screenWidth - LEFT_PADDING;
+
+ // Try different numbers of full posters to find the best fit
+ let bestLayout = { numFullPosters: 3, posterWidth: 120 };
+
+ for (let n = 3; n <= 6; n++) {
+ // Calculate poster width needed for N full posters + 0.25 partial poster
+ // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
+ // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
+ // We'll use minimal right padding (8px) to maximize space
+ 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`);
+ }
+ }
+
+ return {
+ numFullPosters: bestLayout.numFullPosters,
+ posterWidth: bestLayout.posterWidth,
+ spacing: SPACING,
+ partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
+ };
+};
+
+const posterLayout = calculatePosterLayout(width);
+const POSTER_WIDTH = posterLayout.posterWidth;
const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
@@ -132,28 +171,28 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
- borderRadius: 16,
+ borderRadius: 4,
overflow: 'hidden',
position: 'relative',
- elevation: 8,
+ elevation: 6,
shadowColor: '#000',
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.3,
- shadowRadius: 8,
- borderWidth: 1,
- borderColor: 'rgba(255,255,255,0.08)',
+ shadowOffset: { width: 0, height: 3 },
+ shadowOpacity: 0.25,
+ shadowRadius: 6,
+ borderWidth: 0.5,
+ borderColor: 'rgba(255,255,255,0.12)',
},
contentItemContainer: {
width: '100%',
height: '100%',
- borderRadius: 16,
+ borderRadius: 4,
overflow: 'hidden',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
- borderRadius: 16,
+ borderRadius: 4,
},
loadingOverlay: {
position: 'absolute',
@@ -163,7 +202,7 @@ const styles = StyleSheet.create({
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
- borderRadius: 16,
+ borderRadius: 8,
},
watchedIndicator: {
position: 'absolute',
diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx
index d0d156f..7aeda06 100644
--- a/src/components/home/ContinueWatchingSection.tsx
+++ b/src/components/home/ContinueWatchingSection.tsx
@@ -9,6 +9,7 @@ import {
AppState,
AppStateStatus
} from 'react-native';
+import Animated, { FadeIn } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@@ -33,8 +34,39 @@ interface ContinueWatchingRef {
refresh: () => Promise;
}
+// Dynamic poster calculation based on screen width for Continue Watching section
+const calculatePosterLayout = (screenWidth: number) => {
+ const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items
+ const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section
+ const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins
+
+ // Calculate how many posters can fit (fewer items for continue watching)
+ const availableWidth = screenWidth - HORIZONTAL_PADDING;
+ const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
+
+ // Limit to reasonable number of columns (2-5 for continue watching)
+ const numColumns = Math.min(Math.max(maxColumns, 2), 5);
+
+ // Calculate actual poster width
+ const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
+
+ return {
+ numColumns,
+ posterWidth,
+ spacing: 12 // Space between posters
+ };
+};
+
const { width } = Dimensions.get('window');
-const POSTER_WIDTH = (width - 40) / 2.7;
+const posterLayout = calculatePosterLayout(width);
+const POSTER_WIDTH = posterLayout.posterWidth;
+
+// Function to validate IMDB ID format
+const isValidImdbId = (id: string): boolean => {
+ // IMDB IDs should start with 'tt' followed by 7-10 digits
+ const imdbPattern = /^tt\d{7,10}$/;
+ return imdbPattern.test(id);
+};
// Create a proper imperative handle with React.forwardRef and updated type
const ContinueWatchingSection = React.forwardRef((props, ref) => {
@@ -50,6 +82,7 @@ const ContinueWatchingSection = React.forwardRef((props, re
try {
setLoading(true);
const allProgress = await storageService.getAllWatchProgress();
+
if (Object.keys(allProgress).length === 0) {
setContinueWatchingItems([]);
return;
@@ -62,19 +95,29 @@ const ContinueWatchingSection = React.forwardRef((props, re
// Process each saved progress
for (const key in allProgress) {
// Parse the key to get type and id
- const [type, id, episodeId] = key.split(':');
+ 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 95% complete (effectively finished)
+ // Skip items that are more than 85% complete (effectively finished)
const progressPercent = (progress.currentTime / progress.duration) * 100;
- if (progressPercent >= 95) continue;
+
+ if (progressPercent >= 85) {
+ continue;
+ }
const contentPromise = (async () => {
try {
+ // Validate IMDB ID format before attempting to fetch
+ if (!isValidImdbId(id)) {
+ return;
+ }
+
let content: StreamingContent | null = null;
- // Get content details using catalogService
- content = await catalogService.getContentDetails(type, id);
+ // 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
@@ -83,11 +126,28 @@ const ContinueWatchingSection = React.forwardRef((props, re
let episodeTitle: string | undefined;
if (episodeId && type === 'series') {
- const match = episodeId.match(/s(\d+)e(\d+)/i);
+ // Try different episode ID formats
+ let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1
if (match) {
season = parseInt(match[1], 10);
episode = parseInt(match[2], 10);
episodeTitle = `Episode ${episode}`;
+ } else {
+ // 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;
+ episode = episodeNum;
+ episodeTitle = `Episode ${episode}`;
+ }
+ }
}
}
@@ -128,7 +188,9 @@ const ContinueWatchingSection = React.forwardRef((props, re
progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated);
// Limit to 10 items
- setContinueWatchingItems(progressItems.slice(0, 10));
+ const finalItems = progressItems.slice(0, 10);
+
+ setContinueWatchingItems(finalItems);
} catch (error) {
logger.error('Failed to load continue watching items:', error);
} finally {
@@ -197,7 +259,8 @@ const ContinueWatchingSection = React.forwardRef((props, re
refresh: async () => {
await loadContinueWatching();
// Return whether there are items to help parent determine visibility
- return continueWatchingItems.length > 0;
+ const hasItems = continueWatchingItems.length > 0;
+ return hasItems;
}
}));
@@ -206,12 +269,12 @@ const ContinueWatchingSection = React.forwardRef((props, re
}, [navigation]);
// If no continue watching items, don't render anything
- if (continueWatchingItems.length === 0 && !loading) {
+ if (continueWatchingItems.length === 0) {
return null;
}
return (
-
+
Continue Watching
@@ -228,41 +291,82 @@ const ContinueWatchingSection = React.forwardRef((props, re
data={continueWatchingItems}
renderItem={({ item }) => (
handleContentPress(item.id, item.type)}
>
-
+ {/* Poster Image */}
+
- {item.type === 'series' && item.season && item.episode && (
-
-
- S{item.season.toString().padStart(2, '0')}E{item.episode.toString().padStart(2, '0')}
-
- {item.episodeTitle && (
-
- {item.episodeTitle}
-
- )}
+
+
+ {/* Content Details */}
+
+
+
+ {item.name}
+
+
+ {Math.round(item.progress)}%
- )}
- {/* Progress bar indicator */}
-
+
+
+ {/* Episode Info or Year */}
+ {(() => {
+ if (item.type === 'series' && item.season && item.episode) {
+ return (
+
+
+ Season {item.season}
+
+ {item.episodeTitle && (
+
+ {item.episodeTitle}
+
+ )}
+
+ );
+ } else {
+ return (
+
+ {item.year} • {item.type === 'movie' ? 'Movie' : 'Series'}
+
+ );
+ }
+ })()}
+
+ {/* Progress Bar */}
+
+
+
+
+ {Math.round(item.progress)}% watched
+
@@ -270,13 +374,13 @@ const ContinueWatchingSection = React.forwardRef((props, re
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
horizontal
showsHorizontalScrollIndicator={false}
- contentContainerStyle={styles.list}
- snapToInterval={POSTER_WIDTH + 10}
+ contentContainerStyle={styles.wideList}
+ snapToInterval={280 + 16} // Card width + margin
decelerationRate="fast"
snapToAlignment="start"
- ItemSeparatorComponent={() => }
+ ItemSeparatorComponent={() => }
/>
-
+
);
});
@@ -291,26 +395,116 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
- marginBottom: 8,
+ marginBottom: 12,
},
titleContainer: {
position: 'relative',
},
title: {
- fontSize: 18,
- fontWeight: '800',
- textTransform: 'uppercase',
- letterSpacing: 0.5,
- marginBottom: 6,
+ fontSize: 20,
+ fontWeight: '700',
+ letterSpacing: 0.3,
+ marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
- bottom: -4,
+ bottom: -2,
left: 0,
- width: 60,
- height: 3,
- borderRadius: 1.5,
+ width: 40,
+ height: 2,
+ borderRadius: 1,
+ opacity: 0.8,
},
+ wideList: {
+ paddingHorizontal: 16,
+ paddingBottom: 8,
+ paddingTop: 4,
+ },
+ wideContentItem: {
+ width: 280,
+ height: 120,
+ flexDirection: 'row',
+ borderRadius: 12,
+ overflow: 'hidden',
+ elevation: 6,
+ shadowOffset: { width: 0, height: 3 },
+ shadowOpacity: 0.2,
+ shadowRadius: 6,
+ borderWidth: 1,
+ },
+ posterContainer: {
+ width: 80,
+ height: '100%',
+ },
+ widePoster: {
+ width: '100%',
+ height: '100%',
+ borderTopLeftRadius: 12,
+ borderBottomLeftRadius: 12,
+ },
+ contentDetails: {
+ flex: 1,
+ padding: 12,
+ justifyContent: 'space-between',
+ },
+ titleRow: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ marginBottom: 4,
+ },
+ contentTitle: {
+ fontSize: 16,
+ fontWeight: '700',
+ flex: 1,
+ marginRight: 8,
+ },
+ progressBadge: {
+ paddingHorizontal: 8,
+ paddingVertical: 3,
+ borderRadius: 12,
+ minWidth: 44,
+ alignItems: 'center',
+ },
+ progressText: {
+ fontSize: 12,
+ fontWeight: '700',
+ color: '#FFFFFF',
+ },
+ episodeRow: {
+ marginBottom: 8,
+ },
+ episodeText: {
+ fontSize: 13,
+ fontWeight: '600',
+ marginBottom: 2,
+ },
+ episodeTitle: {
+ fontSize: 12,
+ },
+ yearText: {
+ fontSize: 13,
+ fontWeight: '500',
+ marginBottom: 8,
+ },
+ wideProgressContainer: {
+ marginTop: 'auto',
+ },
+ wideProgressTrack: {
+ height: 4,
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ borderRadius: 2,
+ marginBottom: 4,
+ },
+ wideProgressBar: {
+ height: '100%',
+ borderRadius: 2,
+ },
+ progressLabel: {
+ fontSize: 11,
+ fontWeight: '500',
+ },
+ // Keep old styles for backward compatibility
list: {
paddingHorizontal: 16,
paddingBottom: 8,
@@ -320,7 +514,7 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
- borderRadius: 12,
+ borderRadius: 8,
overflow: 'hidden',
position: 'relative',
elevation: 8,
@@ -332,14 +526,14 @@ const styles = StyleSheet.create({
contentItemContainer: {
width: '100%',
height: '100%',
- borderRadius: 12,
+ borderRadius: 8,
overflow: 'hidden',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
- borderRadius: 12,
+ borderRadius: 8,
},
episodeInfoContainer: {
position: 'absolute',
@@ -353,9 +547,6 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: 'bold',
},
- episodeTitle: {
- fontSize: 10,
- },
progressBarContainer: {
position: 'absolute',
bottom: 0,
diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx
index eb4467f..89389d8 100644
--- a/src/components/home/FeaturedContent.tsx
+++ b/src/components/home/FeaturedContent.tsx
@@ -64,9 +64,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
// Add a ref to track logo fetch in progress
const logoFetchInProgress = useRef(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,
+ transform: [
+ { scale: posterScale.value },
+ { translateY: posterTranslateY.value }
+ ],
}));
const logoAnimatedStyle = useAnimatedStyle(() => ({
@@ -84,6 +93,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
opacity: buttonsOpacity.value,
}));
+ const overlayAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: overlayOpacity.value,
+ }));
+
// Preload the image
const preloadImage = async (url: string): Promise => {
if (!url) return false;
@@ -122,153 +135,132 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
if (!featuredContent || logoFetchInProgress.current) return;
const fetchLogo = async () => {
- // Set fetch in progress flag
logoFetchInProgress.current = true;
try {
const contentId = featuredContent.id;
+ const contentData = featuredContent; // Use a clearer variable name
+ const currentLogo = contentData.logo;
- // Get logo source preference from settings
- const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set
- const preferredLanguage = settings.tmdbLanguagePreference || 'en'; // Get preferred language
+ // Get preferences
+ const logoPreference = settings.logoSourcePreference || 'metahub';
+ const preferredLanguage = settings.tmdbLanguagePreference || 'en';
- // Check if current logo matches preferences
- const currentLogo = featuredContent.logo;
- if (currentLogo) {
- const isCurrentMetahub = isMetahubUrl(currentLogo);
- const isCurrentTmdb = isTmdbUrl(currentLogo);
-
- // If logo already matches preference, use it
- if ((logoPreference === 'metahub' && isCurrentMetahub) ||
- (logoPreference === 'tmdb' && isCurrentTmdb)) {
- setLogoUrl(currentLogo);
- logoFetchInProgress.current = false;
- return;
- }
+ // Reset state for new fetch
+ setLogoUrl(null);
+ setLogoLoadError(false);
+
+ // Extract IDs
+ let imdbId: string | null = null;
+ if (contentData.id.startsWith('tt')) {
+ imdbId = contentData.id;
+ } else if ((contentData as any).imdbId) {
+ imdbId = (contentData as any).imdbId;
+ } else if ((contentData as any).externalIds?.imdb_id) {
+ imdbId = (contentData as any).externalIds.imdb_id;
}
- // Extract IMDB ID if available
- let imdbId = null;
- if (featuredContent.id.startsWith('tt')) {
- // If the ID itself is an IMDB ID
- imdbId = featuredContent.id;
- } else if ((featuredContent as any).imdbId) {
- // Try to get IMDB ID from the content object if available
- imdbId = (featuredContent as any).imdbId;
+ 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);
}
- // Extract TMDB ID if available
- let tmdbId = null;
- if (contentId.startsWith('tmdb:')) {
- tmdbId = contentId.split(':')[1];
- }
-
- // First source based on preference
- if (logoPreference === 'metahub' && imdbId) {
- // Try to get logo from Metahub first
- const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
-
+ // If we only have IMDB ID, try to find TMDB ID proactively
+ if (imdbId && !tmdbId) {
try {
- const response = await fetch(metahubUrl, { method: 'HEAD' });
- if (response.ok) {
- setLogoUrl(metahubUrl);
- logoFetchInProgress.current = false;
- return; // Exit if Metahub logo was found
+ const tmdbService = TMDBService.getInstance();
+ const foundData = await tmdbService.findTMDBIdByIMDB(imdbId);
+ if (foundData) {
+ tmdbId = String(foundData);
}
- } catch (error) {
- // Removed logger.warn
+ } catch (findError) {
+ // logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError);
}
-
- // Fall back to TMDB if Metahub fails and we have a TMDB ID
- if (tmdbId) {
- const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie';
- try {
- const tmdbService = TMDBService.getInstance();
- const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
-
- if (logoUrl) {
- setLogoUrl(logoUrl);
- } else if (currentLogo) {
- // If TMDB fails too, use existing logo if any
- setLogoUrl(currentLogo);
- }
- } catch (error) {
- // Removed logger.error
- if (currentLogo) setLogoUrl(currentLogo);
- }
- } else if (currentLogo) {
- // Use existing logo if we don't have TMDB ID
- setLogoUrl(currentLogo);
- }
- } else if (logoPreference === 'tmdb') {
- // Try to get logo from TMDB first
- if (tmdbId) {
- const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie';
- try {
- const tmdbService = TMDBService.getInstance();
- const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
-
- if (logoUrl) {
- setLogoUrl(logoUrl);
- logoFetchInProgress.current = false;
- return; // Exit if TMDB logo was found
- }
- } catch (error) {
- // Removed logger.error
- }
- } else if (imdbId) {
- // If we have IMDB ID but no TMDB ID, try to find TMDB ID
- try {
- const tmdbService = TMDBService.getInstance();
- const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId);
-
- if (foundTmdbId) {
- const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie';
- const logoUrl = await tmdbService.getContentLogo(tmdbType, foundTmdbId.toString(), preferredLanguage);
-
- if (logoUrl) {
- setLogoUrl(logoUrl);
- logoFetchInProgress.current = false;
- return; // Exit if TMDB logo was found
- }
- }
- } catch (error) {
- // Removed logger.error
- }
- }
-
- // Fall back to Metahub if TMDB fails and we have an IMDB ID
+ }
+
+ 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) {
+ primaryAttempted = true;
const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
-
try {
const response = await fetch(metahubUrl, { method: 'HEAD' });
if (response.ok) {
- setLogoUrl(metahubUrl);
- } else if (currentLogo) {
- // If Metahub fails too, use existing logo if any
- setLogoUrl(currentLogo);
+ finalLogoUrl = metahubUrl;
}
- } catch (error) {
- // Removed logger.warn
- if (currentLogo) setLogoUrl(currentLogo);
- }
- } else if (currentLogo) {
- // Use existing logo if we don't have IMDB ID
- setLogoUrl(currentLogo);
+ } catch (error) { /* Log if needed */ }
+ }
+
+ // Fallback: TMDB (needs tmdbId)
+ if (!finalLogoUrl && tmdbId) {
+ fallbackAttempted = true;
+ try {
+ const tmdbService = TMDBService.getInstance();
+ const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
+ if (logoUrl) {
+ finalLogoUrl = logoUrl;
+ }
+ } catch (error) { /* Log if needed */ }
+ }
+
+ } else { // logoPreference === 'tmdb'
+ // Primary: TMDB (needs tmdbId)
+ if (tmdbId) {
+ primaryAttempted = true;
+ try {
+ const tmdbService = TMDBService.getInstance();
+ const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
+ if (logoUrl) {
+ finalLogoUrl = logoUrl;
+ }
+ } catch (error) { /* Log if needed */ }
+ }
+
+ // Fallback: Metahub (needs imdbId)
+ if (!finalLogoUrl && imdbId) {
+ fallbackAttempted = true;
+ const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
+ try {
+ const response = await fetch(metahubUrl, { method: 'HEAD' });
+ if (response.ok) {
+ finalLogoUrl = metahubUrl;
+ }
+ } catch (error) { /* Log if needed */ }
}
}
+
+ // --- Set Final Logo ---
+ if (finalLogoUrl) {
+ setLogoUrl(finalLogoUrl);
+ } else if (currentLogo) {
+ // Use existing logo only if primary and fallback failed or weren't applicable
+ setLogoUrl(currentLogo);
+ } else {
+ // No logo found from any source
+ setLogoLoadError(true);
+ // logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`);
+ }
+
} catch (error) {
- // Removed logger.error
- // Optionally set a fallback logo or handle the error state
- setLogoUrl(featuredContent.logo ?? null); // Fallback to initial logo or null
+ // logger.error('[FeaturedContent] Error in fetchLogo:', error);
+ setLogoLoadError(true);
} finally {
logoFetchInProgress.current = false;
}
};
+ // Trigger fetch when content changes
fetchLogo();
- }, [featuredContent?.id, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
+ }, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
// Load poster and logo
useEffect(() => {
@@ -276,41 +268,92 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
const posterUrl = featuredContent.banner || featuredContent.poster;
const contentId = featuredContent.id;
+ const isContentChange = contentId !== prevContentIdRef.current;
- // Reset states for new content
- if (contentId !== prevContentIdRef.current) {
- posterOpacity.value = 0;
+ // Enhanced content change detection and animations
+ if (isContentChange) {
+ // Animate out current content
+ if (prevContentIdRef.current) {
+ posterOpacity.value = withTiming(0, {
+ duration: 300,
+ easing: Easing.out(Easing.cubic)
+ });
+ posterScale.value = withTiming(0.95, {
+ duration: 300,
+ easing: Easing.out(Easing.cubic)
+ });
+ overlayOpacity.value = withTiming(0.6, {
+ duration: 300,
+ easing: Easing.out(Easing.cubic)
+ });
+ contentOpacity.value = withTiming(0.3, {
+ duration: 200,
+ easing: Easing.out(Easing.cubic)
+ });
+ buttonsOpacity.value = withTiming(0.3, {
+ duration: 200,
+ easing: Easing.out(Easing.cubic)
+ });
+ } else {
+ // Initial load - start from 0
+ posterOpacity.value = 0;
+ posterScale.value = 1.1;
+ overlayOpacity.value = 0;
+ contentOpacity.value = 0;
+ buttonsOpacity.value = 0;
+ }
logoOpacity.value = 0;
}
prevContentIdRef.current = contentId;
- // Set poster URL immediately for instant display
+ // Set poster URL for immediate display
if (posterUrl) setBannerUrl(posterUrl);
- // Load images in background
+ // Load images with enhanced animations
const loadImages = async () => {
- // Load poster
+ // 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);
if (posterSuccess) {
- posterOpacity.value = withTiming(1, {
- duration: 600,
- easing: Easing.bezier(0.25, 0.1, 0.25, 1)
+ // Animate in new poster with scale and fade
+ posterScale.value = withTiming(1, {
+ duration: 800,
+ easing: Easing.out(Easing.cubic)
});
+ posterOpacity.value = withTiming(1, {
+ duration: 700,
+ easing: Easing.out(Easing.cubic)
+ });
+ overlayOpacity.value = withTiming(0.15, {
+ duration: 600,
+ easing: Easing.out(Easing.cubic)
+ });
+
+ // Animate content back in with delay
+ contentOpacity.value = withDelay(200, withTiming(1, {
+ duration: 600,
+ easing: Easing.out(Easing.cubic)
+ }));
+ buttonsOpacity.value = withDelay(400, withTiming(1, {
+ duration: 500,
+ easing: Easing.out(Easing.cubic)
+ }));
}
}
- // Load logo if available
+ // Load logo if available with enhanced timing
if (logoUrl) {
const logoSuccess = await preloadImage(logoUrl);
if (logoSuccess) {
- logoOpacity.value = withDelay(300, withTiming(1, {
- duration: 500,
- easing: Easing.bezier(0.25, 0.1, 0.25, 1)
+ logoOpacity.value = withDelay(500, withTiming(1, {
+ duration: 600,
+ easing: Easing.out(Easing.cubic)
}));
} else {
- // If prefetch fails, mark as error to show title text instead
setLogoLoadError(true);
console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`);
}
@@ -325,131 +368,149 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
}
return (
- {
- navigation.navigate('Metadata', {
- id: featuredContent.id,
- type: featuredContent.type
- });
- }}
- style={styles.featuredContainer as ViewStyle}
+
-
-
- {
+ navigation.navigate('Metadata', {
+ id: featuredContent.id,
+ type: featuredContent.type
+ });
+ }}
+ style={styles.featuredContainer as ViewStyle}
+ >
+
+
-
+
+
- {logoUrl && !logoLoadError ? (
-
- {
- console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`);
- setLogoLoadError(true);
- }}
+
+ {logoUrl && !logoLoadError ? (
+
+ {
+ console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`);
+ setLogoLoadError(true);
+ }}
+ />
+
+ ) : (
+
+ {featuredContent.name}
+
+ )}
+
+ {featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
+
+
+ {genre}
+
+ {index < array.length - 1 && (
+ •
+ )}
+
+ ))}
+
+
+
+
+
+
-
- ) : (
-
- {featuredContent.name}
-
- )}
-
- {featuredContent.genres?.slice(0, 3).map((genre, index, array) => (
-
-
- {genre}
-
- {index < array.length - 1 && (
- •
- )}
-
- ))}
-
-
+
+ {isSaved ? "Saved" : "Save"}
+
+
+
+ {
+ if (featuredContent) {
+ navigation.navigate('Streams', {
+ id: featuredContent.id,
+ type: featuredContent.type
+ });
+ }
+ }}
+ activeOpacity={0.8}
+ >
+
+
+ Play
+
+
-
-
-
-
- {isSaved ? "Saved" : "Save"}
-
-
-
- {
- if (featuredContent) {
- navigation.navigate('Streams', {
- id: featuredContent.id,
- type: featuredContent.type
- });
- }
- }}
- >
-
-
- Play
-
-
-
- {
- if (featuredContent) {
- navigation.navigate('Metadata', {
- id: featuredContent.id,
- type: featuredContent.type
- });
- }
- }}
- >
-
-
- Info
-
-
-
-
-
-
-
+ {
+ if (featuredContent) {
+ navigation.navigate('Metadata', {
+ id: featuredContent.id,
+ type: featuredContent.type
+ });
+ }
+ }}
+ activeOpacity={0.7}
+ >
+
+
+ Info
+
+
+
+
+
+
+
+
);
};
const styles = StyleSheet.create({
featuredContainer: {
width: '100%',
- height: height * 0.48,
+ height: height * 0.55, // Slightly taller for better proportions
marginTop: 0,
- marginBottom: 8,
+ marginBottom: 12,
position: 'relative',
+ borderRadius: 12,
+ overflow: 'hidden',
+ elevation: 8,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 8,
},
imageContainer: {
width: '100%',
@@ -464,6 +525,7 @@ const styles = StyleSheet.create({
featuredImage: {
width: '100%',
height: '100%',
+ transform: [{ scale: 1.05 }], // Subtle zoom for depth
},
backgroundFallback: {
position: 'absolute',
@@ -479,12 +541,14 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
justifyContent: 'space-between',
+ paddingTop: 20,
},
featuredContentContainer: {
flex: 1,
justifyContent: 'flex-end',
- paddingHorizontal: 16,
- paddingBottom: 4,
+ paddingHorizontal: 20,
+ paddingBottom: 8,
+ paddingTop: 40,
},
featuredLogo: {
width: width * 0.7,
@@ -523,19 +587,20 @@ const styles = StyleSheet.create({
},
featuredButtons: {
flexDirection: 'row',
- alignItems: 'flex-end',
+ alignItems: 'center',
justifyContent: 'space-evenly',
width: '100%',
- flex: 1,
- maxHeight: 55,
- paddingTop: 0,
+ minHeight: 70,
+ paddingTop: 12,
+ paddingBottom: 20,
+ paddingHorizontal: 8,
},
playButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
- paddingVertical: 14,
- paddingHorizontal: 32,
+ paddingVertical: 12,
+ paddingHorizontal: 28,
borderRadius: 30,
elevation: 4,
shadowColor: '#000',
@@ -543,7 +608,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.3,
shadowRadius: 4,
flex: 0,
- width: 150,
+ width: 140,
},
myListButton: {
flexDirection: 'column',
@@ -578,6 +643,16 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: '500',
},
+ contentOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0,0,0,0.15)',
+ zIndex: 1,
+ pointerEvents: 'none',
+ },
});
export default FeaturedContent;
\ No newline at end of file
diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx
index d9eb511..30ebfe4 100644
--- a/src/components/home/ThisWeekSection.tsx
+++ b/src/components/home/ThisWeekSection.tsx
@@ -303,8 +303,9 @@ const styles = StyleSheet.create({
marginBottom: 12,
},
title: {
- fontSize: 18,
- fontWeight: 'bold',
+ fontSize: 19,
+ fontWeight: '700',
+ letterSpacing: 0.2,
},
viewAllButton: {
flexDirection: 'row',
@@ -329,7 +330,7 @@ const styles = StyleSheet.create({
episodeItem: {
width: '100%',
height: '100%',
- borderRadius: 12,
+ borderRadius: 8,
overflow: 'hidden',
},
poster: {
diff --git a/src/components/loading/MetadataLoadingScreen.tsx b/src/components/loading/MetadataLoadingScreen.tsx
new file mode 100644
index 0000000..c0a8ebf
--- /dev/null
+++ b/src/components/loading/MetadataLoadingScreen.tsx
@@ -0,0 +1,294 @@
+import React, { useEffect, useRef } from 'react';
+import {
+ View,
+ Text,
+ StyleSheet,
+ Dimensions,
+ Animated,
+ StatusBar,
+} from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { LinearGradient } from 'expo-linear-gradient';
+import { useTheme } from '../../contexts/ThemeContext';
+
+const { width, height } = Dimensions.get('window');
+
+interface MetadataLoadingScreenProps {
+ type?: 'movie' | 'series';
+}
+
+export const MetadataLoadingScreen: React.FC = ({
+ type = 'movie'
+}) => {
+ const { currentTheme } = useTheme();
+
+ // Animation values - removed fadeAnim since parent handles transitions
+ const pulseAnim = useRef(new Animated.Value(0.3)).current;
+ const shimmerAnim = useRef(new Animated.Value(0)).current;
+
+ useEffect(() => {
+ // Continuous pulse animation for skeleton elements
+ const pulseAnimation = Animated.loop(
+ Animated.sequence([
+ Animated.timing(pulseAnim, {
+ toValue: 1,
+ duration: 1200,
+ useNativeDriver: true,
+ }),
+ Animated.timing(pulseAnim, {
+ toValue: 0.3,
+ duration: 1200,
+ useNativeDriver: true,
+ }),
+ ])
+ );
+
+ // Shimmer effect for skeleton elements
+ const shimmerAnimation = Animated.loop(
+ Animated.timing(shimmerAnim, {
+ toValue: 1,
+ duration: 1500,
+ useNativeDriver: true,
+ })
+ );
+
+ pulseAnimation.start();
+ shimmerAnimation.start();
+
+ return () => {
+ pulseAnimation.stop();
+ shimmerAnimation.stop();
+ };
+ }, []);
+
+ const shimmerTranslateX = shimmerAnim.interpolate({
+ inputRange: [0, 1],
+ outputRange: [-width, width],
+ });
+
+ const SkeletonElement = ({
+ width: elementWidth,
+ height: elementHeight,
+ borderRadius = 8,
+ marginBottom = 8,
+ style = {},
+ }: {
+ width: number | string;
+ height: number;
+ borderRadius?: number;
+ marginBottom?: number;
+ style?: any;
+ }) => (
+
+
+
+
+
+
+ );
+
+ return (
+
+
+
+
+ {/* Hero Skeleton */}
+
+
+
+ {/* Overlay content on hero */}
+
+
+
+ {/* Bottom hero content skeleton */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Content Section Skeletons */}
+
+ {/* Synopsis skeleton */}
+
+
+
+
+
+
+
+ {/* Cast section skeleton */}
+
+
+
+ {[1, 2, 3, 4].map((item) => (
+
+
+
+
+
+ ))}
+
+
+
+ {/* Episodes/Details skeleton based on type */}
+ {type === 'series' ? (
+
+
+
+ {[1, 2, 3].map((item) => (
+
+
+
+
+
+
+
+
+ ))}
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ content: {
+ flex: 1,
+ },
+ heroSection: {
+ height: height * 0.6,
+ position: 'relative',
+ },
+ heroOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ justifyContent: 'flex-end',
+ },
+ heroBottomContent: {
+ position: 'absolute',
+ bottom: 20,
+ left: 20,
+ right: 20,
+ },
+ genresRow: {
+ flexDirection: 'row',
+ marginBottom: 16,
+ },
+ buttonsRow: {
+ flexDirection: 'row',
+ marginBottom: 8,
+ },
+ contentSection: {
+ padding: 20,
+ },
+ synopsisSection: {
+ marginBottom: 32,
+ },
+ castSection: {
+ marginBottom: 32,
+ },
+ castRow: {
+ flexDirection: 'row',
+ marginTop: 16,
+ },
+ castItem: {
+ alignItems: 'center',
+ marginRight: 16,
+ },
+ episodesSection: {
+ marginBottom: 32,
+ },
+ episodeItem: {
+ flexDirection: 'row',
+ marginBottom: 16,
+ alignItems: 'center',
+ },
+ episodeInfo: {
+ flex: 1,
+ },
+ detailsSection: {
+ marginBottom: 32,
+ },
+ detailsGrid: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ marginTop: 16,
+ },
+});
+
+export default MetadataLoadingScreen;
\ No newline at end of file
diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx
index cef9ec4..80d15ab 100644
--- a/src/components/metadata/HeroSection.tsx
+++ b/src/components/metadata/HeroSection.tsx
@@ -1,49 +1,60 @@
-import React from 'react';
+import React, { useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
Dimensions,
TouchableOpacity,
+ Platform,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image';
+import { BlurView as ExpoBlurView } from 'expo-blur';
+import { BlurView as CommunityBlurView } from '@react-native-community/blur';
+import Constants, { ExecutionEnvironment } from 'expo-constants';
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolate,
+ useSharedValue,
+ withTiming,
+ runOnJS,
+ withRepeat,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
+import { useTraktContext } from '../../contexts/TraktContext';
import { logger } from '../../utils/logger';
import { TMDBService } from '../../services/tmdbService';
const { width, height } = Dimensions.get('window');
-// Types
+// Ultra-optimized animation constants
+const PARALLAX_FACTOR = 0.3;
+const SCALE_FACTOR = 1.02;
+const FADE_THRESHOLD = 200;
+
+// Types - streamlined
interface HeroSectionProps {
metadata: any;
bannerImage: string | null;
loadingBanner: boolean;
logoLoadError: boolean;
scrollY: Animated.SharedValue;
- dampedScrollY: Animated.SharedValue;
heroHeight: Animated.SharedValue;
heroOpacity: Animated.SharedValue;
- heroScale: Animated.SharedValue;
logoOpacity: Animated.SharedValue;
- logoScale: Animated.SharedValue;
- genresOpacity: Animated.SharedValue;
- genresTranslateY: Animated.SharedValue;
buttonsOpacity: Animated.SharedValue;
buttonsTranslateY: Animated.SharedValue;
watchProgressOpacity: Animated.SharedValue;
- watchProgressScaleY: Animated.SharedValue;
+ watchProgressWidth: Animated.SharedValue;
watchProgress: {
currentTime: number;
duration: number;
lastUpdated: number;
episodeId?: string;
+ traktSynced?: boolean;
+ traktProgress?: number;
} | null;
type: 'movie' | 'series';
getEpisodeDetails: (episodeId: string) => { seasonNumber: string; episodeNumber: string; episodeName: string } | null;
@@ -57,7 +68,7 @@ interface HeroSectionProps {
setLogoLoadError: (error: boolean) => void;
}
-// Memoized ActionButtons Component
+// Ultra-optimized ActionButtons Component - minimal re-renders
const ActionButtons = React.memo(({
handleShowStreams,
toggleLibrary,
@@ -66,7 +77,9 @@ const ActionButtons = React.memo(({
id,
navigation,
playButtonText,
- animatedStyle
+ animatedStyle,
+ isWatched,
+ watchProgress
}: {
handleShowStreams: () => void;
toggleLibrary: () => void;
@@ -76,28 +89,107 @@ const ActionButtons = React.memo(({
navigation: any;
playButtonText: string;
animatedStyle: any;
+ isWatched: boolean;
+ watchProgress: any;
}) => {
const { currentTheme } = useTheme();
+
+ // Memoized navigation handler
+ const handleRatingsPress = useMemo(() => async () => {
+ let finalTmdbId: number | null = null;
+
+ if (id?.startsWith('tmdb:')) {
+ const numericPart = id.split(':')[1];
+ const parsedId = parseInt(numericPart, 10);
+ if (!isNaN(parsedId)) {
+ finalTmdbId = parsedId;
+ }
+ } else if (id?.startsWith('tt')) {
+ try {
+ const tmdbService = TMDBService.getInstance();
+ const convertedId = await tmdbService.findTMDBIdByIMDB(id);
+ if (convertedId) {
+ finalTmdbId = convertedId;
+ }
+ } catch (error) {
+ logger.error(`[HeroSection] Error converting IMDb ID ${id}:`, error);
+ }
+ } else if (id) {
+ const parsedId = parseInt(id, 10);
+ if (!isNaN(parsedId)) {
+ finalTmdbId = parsedId;
+ }
+ }
+
+ if (finalTmdbId !== null) {
+ navigation.navigate('ShowRatings', { showId: finalTmdbId });
+ }
+ }, [id, navigation]);
+
+ // Determine play button style and text based on watched status
+ const playButtonStyle = useMemo(() => {
+ if (isWatched) {
+ return [styles.actionButton, styles.playButton, styles.watchedPlayButton];
+ }
+ return [styles.actionButton, styles.playButton];
+ }, [isWatched]);
+
+ const playButtonTextStyle = useMemo(() => {
+ if (isWatched) {
+ return [styles.playButtonText, styles.watchedPlayButtonText];
+ }
+ return styles.playButtonText;
+ }, [isWatched]);
+
+ const finalPlayButtonText = useMemo(() => {
+ if (isWatched) {
+ return 'Watch Again';
+ }
+ return playButtonText;
+ }, [isWatched, playButtonText]);
+
return (
-
- {playButtonText}
-
+ {finalPlayButtonText}
+
+ {/* Subtle watched indicator in play button */}
+ {isWatched && (
+
+
+
+ )}
+ {Platform.OS === 'ios' ? (
+
+ ) : (
+ Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? (
+
+ ) : (
+
+ )
+ )}
{
- let finalTmdbId: number | null = null;
-
- if (id && id.startsWith('tmdb:')) {
- const numericPart = id.split(':')[1];
- const parsedId = parseInt(numericPart, 10);
- if (!isNaN(parsedId)) {
- finalTmdbId = parsedId;
- } else {
- logger.error(`[HeroSection] Failed to parse TMDB ID from: ${id}`);
- }
- } else if (id && id.startsWith('tt')) {
- // It's an IMDb ID, convert it
- logger.log(`[HeroSection] Detected IMDb ID: ${id}, attempting conversion to TMDB ID.`);
- try {
- const tmdbService = TMDBService.getInstance();
- const convertedId = await tmdbService.findTMDBIdByIMDB(id);
- if (convertedId) {
- finalTmdbId = convertedId;
- logger.log(`[HeroSection] Successfully converted IMDb ID ${id} to TMDB ID: ${finalTmdbId}`);
- } else {
- logger.error(`[HeroSection] Could not convert IMDb ID ${id} to TMDB ID.`);
- }
- } catch (error) {
- logger.error(`[HeroSection] Error converting IMDb ID ${id}:`, error);
- }
- } else if (id) {
- // Assume it might be a raw TMDB ID (numeric string)
- const parsedId = parseInt(id, 10);
- if (!isNaN(parsedId)) {
- finalTmdbId = parsedId;
- } else {
- logger.error(`[HeroSection] Unrecognized ID format or invalid numeric ID: ${id}`);
- }
- }
-
- // Navigate if we have a valid TMDB ID
- if (finalTmdbId !== null) {
- navigation.navigate('ShowRatings', { showId: finalTmdbId });
- } else {
- logger.error(`[HeroSection] Could not navigate to ShowRatings, failed to obtain a valid TMDB ID from original id: ${id}`);
- // Optionally show an error message to the user here
- }
- }}
+ style={styles.iconButton}
+ onPress={handleRatingsPress}
+ activeOpacity={0.85}
>
+ {Platform.OS === 'ios' ? (
+
+ ) : (
+ Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? (
+
+ ) : (
+
+ )
+ )}
{ seasonNumber: string; episodeNumber: string; episodeName: string } | null;
animatedStyle: any;
+ isWatched: boolean;
}) => {
const { currentTheme } = useTheme();
- if (!watchProgress || watchProgress.duration === 0) {
- return null;
- }
-
- const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
- const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString();
- let episodeInfo = '';
-
- if (type === 'series' && watchProgress.episodeId) {
- const details = getEpisodeDetails(watchProgress.episodeId);
- if (details) {
- episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`;
+ const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext();
+
+ // Animated values for enhanced effects
+ const completionGlow = useSharedValue(0);
+ const celebrationScale = useSharedValue(1);
+ const progressPulse = useSharedValue(1);
+ const progressBoxOpacity = useSharedValue(0);
+ const progressBoxScale = useSharedValue(0.8);
+ const progressBoxTranslateY = useSharedValue(20);
+
+ // Handle manual Trakt sync
+ const handleTraktSync = useMemo(() => async () => {
+ if (isTraktAuthenticated && forceSyncTraktProgress) {
+ logger.log('[HeroSection] Manual Trakt sync requested');
+ try {
+ const success = await forceSyncTraktProgress();
+ logger.log(`[HeroSection] Manual Trakt sync ${success ? 'successful' : 'failed'}`);
+ } catch (error) {
+ logger.error('[HeroSection] Manual Trakt sync error:', error);
+ }
}
- }
+ }, [isTraktAuthenticated, forceSyncTraktProgress]);
+
+ // Memoized progress calculation with Trakt integration
+ const progressData = useMemo(() => {
+ // If content is fully watched, show watched status instead of progress
+ if (isWatched) {
+ let episodeInfo = '';
+ if (type === 'series' && watchProgress?.episodeId) {
+ const details = getEpisodeDetails(watchProgress.episodeId);
+ if (details) {
+ episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`;
+ }
+ }
+
+ const watchedDate = watchProgress?.lastUpdated
+ ? new Date(watchProgress.lastUpdated).toLocaleDateString()
+ : new Date().toLocaleDateString();
+
+ // Determine if watched via Trakt or local
+ const watchedViaTrakt = isTraktAuthenticated &&
+ watchProgress?.traktProgress !== undefined &&
+ watchProgress.traktProgress >= 95;
+
+ return {
+ progressPercent: 100,
+ formattedTime: watchedDate,
+ episodeInfo,
+ displayText: watchedViaTrakt ? 'Watched on Trakt' : 'Watched',
+ syncStatus: isTraktAuthenticated && watchProgress?.traktSynced ? '' : '', // Clean look for watched
+ isTraktSynced: watchProgress?.traktSynced && isTraktAuthenticated,
+ isWatched: true
+ };
+ }
+
+ if (!watchProgress || watchProgress.duration === 0) return null;
+
+ // Determine which progress to show - prioritize Trakt if available and authenticated
+ let progressPercent;
+ let isUsingTraktProgress = false;
+
+ if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) {
+ progressPercent = watchProgress.traktProgress;
+ isUsingTraktProgress = true;
+ } else {
+ progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
+ }
+ const formattedTime = new Date(watchProgress.lastUpdated).toLocaleDateString();
+ let episodeInfo = '';
+
+ if (type === 'series' && watchProgress.episodeId) {
+ const details = getEpisodeDetails(watchProgress.episodeId);
+ if (details) {
+ episodeInfo = ` • S${details.seasonNumber}:E${details.episodeNumber}${details.episodeName ? ` - ${details.episodeName}` : ''}`;
+ }
+ }
+
+ // Enhanced display text with Trakt integration
+ let displayText = progressPercent >= 85 ? 'Watched' : `${Math.round(progressPercent)}% watched`;
+ let syncStatus = '';
+
+ // Show Trakt sync status if user is authenticated
+ if (isTraktAuthenticated) {
+ if (isUsingTraktProgress) {
+ syncStatus = ' • Using Trakt progress';
+ if (watchProgress.traktSynced) {
+ syncStatus = ' • Synced with Trakt';
+ }
+ } else if (watchProgress.traktSynced) {
+ syncStatus = ' • Synced with Trakt';
+ // If we have specific Trakt progress that differs from local, mention it
+ if (watchProgress.traktProgress !== undefined &&
+ Math.abs(progressPercent - watchProgress.traktProgress) > 5) {
+ displayText = `${Math.round(progressPercent)}% watched (${Math.round(watchProgress.traktProgress)}% on Trakt)`;
+ }
+ } else {
+ syncStatus = ' • Sync pending';
+ }
+ }
+
+ return {
+ progressPercent,
+ formattedTime,
+ episodeInfo,
+ displayText,
+ syncStatus,
+ isTraktSynced: watchProgress.traktSynced && isTraktAuthenticated,
+ isWatched: false
+ };
+ }, [watchProgress, type, getEpisodeDetails, isTraktAuthenticated, isWatched]);
+
+ // Trigger appearance and completion animations
+ useEffect(() => {
+ if (progressData) {
+ // Smooth entrance animation for the glassmorphic box
+ progressBoxOpacity.value = withTiming(1, { duration: 400 });
+ progressBoxScale.value = withTiming(1, { duration: 400 });
+ progressBoxTranslateY.value = withTiming(0, { duration: 400 });
+
+ if (progressData.isWatched || (progressData.progressPercent && progressData.progressPercent >= 85)) {
+ // Celebration animation sequence
+ celebrationScale.value = withRepeat(
+ withTiming(1.05, { duration: 200 }),
+ 2,
+ true
+ );
+
+ // Glow effect
+ completionGlow.value = withRepeat(
+ withTiming(1, { duration: 1500 }),
+ -1,
+ true
+ );
+ } else {
+ // Subtle progress pulse for ongoing content
+ progressPulse.value = withRepeat(
+ withTiming(1.02, { duration: 2000 }),
+ -1,
+ true
+ );
+ }
+ } else {
+ // Hide animation when no progress data
+ progressBoxOpacity.value = withTiming(0, { duration: 300 });
+ progressBoxScale.value = withTiming(0.8, { duration: 300 });
+ progressBoxTranslateY.value = withTiming(20, { duration: 300 });
+ }
+ }, [progressData]);
+
+ // Animated styles for enhanced effects
+ const celebrationAnimatedStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: celebrationScale.value }],
+ }));
+
+ const glowAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: interpolate(completionGlow.value, [0, 1], [0.3, 0.8], Extrapolate.CLAMP),
+ }));
+
+ const progressPulseStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: progressPulse.value }],
+ }));
+
+ const progressBoxAnimatedStyle = useAnimatedStyle(() => ({
+ opacity: progressBoxOpacity.value,
+ transform: [
+ { scale: progressBoxScale.value },
+ { translateY: progressBoxTranslateY.value }
+ ],
+ }));
+
+ if (!progressData) return null;
+
+ const isCompleted = progressData.isWatched || progressData.progressPercent >= 85;
return (
+ {/* Glass morphism background with entrance animation */}
+
+ {Platform.OS === 'ios' ? (
+
+ ) : (
+
+ )}
+
+ {/* Enhanced progress bar with glow effects */}
+
-
+ )}
+
+
+
+ {/* Shimmer effect for active progress */}
+ {!isCompleted && progressData.progressPercent > 0 && (
+
+ )}
+
+
+
+ {/* Enhanced text container with better typography */}
+
+
+
+ {progressData.displayText}
+
+
+ {/* Progress percentage badge */}
+ {!isCompleted && (
+
+
+ {Math.round(progressData.progressPercent)}%
+
+
+ )}
-
- {progressPercent >= 95 ? 'Watched' : `${Math.round(progressPercent)}% watched`}{episodeInfo} • Last watched on {formattedTime}
-
+
+
+ {progressData.episodeInfo} • Last watched {progressData.formattedTime}
+
+
+ {/* Trakt sync status with enhanced styling */}
+ {progressData.syncStatus && (
+
+
+
+ {progressData.syncStatus}
+
+
+ {/* Enhanced manual Trakt sync button - moved inline */}
+ {isTraktAuthenticated && forceSyncTraktProgress && (
+
+
+
+
+
+ )}
+
+ )}
+
+
);
});
@@ -221,18 +546,12 @@ const HeroSection: React.FC = ({
loadingBanner,
logoLoadError,
scrollY,
- dampedScrollY,
heroHeight,
heroOpacity,
- heroScale,
logoOpacity,
- logoScale,
- genresOpacity,
- genresTranslateY,
buttonsOpacity,
buttonsTranslateY,
watchProgressOpacity,
- watchProgressScaleY,
watchProgress,
type,
getEpisodeDetails,
@@ -246,179 +565,266 @@ const HeroSection: React.FC = ({
setLogoLoadError,
}) => {
const { currentTheme } = useTheme();
- // Animated styles
- const heroAnimatedStyle = useAnimatedStyle(() => ({
- width: '100%',
- height: heroHeight.value,
- backgroundColor: currentTheme.colors.black,
- transform: [{ scale: heroScale.value }],
- opacity: heroOpacity.value,
- }));
+ const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
+
+ // Enhanced state for smooth image loading
+ const [imageError, setImageError] = useState(false);
+ const [imageLoaded, setImageLoaded] = useState(false);
+ const imageOpacity = useSharedValue(1);
+ const imageLoadOpacity = useSharedValue(0);
+ const shimmerOpacity = useSharedValue(0.3);
+
+ // Memoized image source
+ const imageSource = useMemo(() =>
+ bannerImage || metadata.banner || metadata.poster
+ , [bannerImage, metadata.banner, metadata.poster]);
+
+ // Start shimmer animation for loading state
+ useEffect(() => {
+ if (!imageLoaded && imageSource) {
+ // Start shimmer animation
+ shimmerOpacity.value = withRepeat(
+ withTiming(0.8, { duration: 1200 }),
+ -1,
+ true
+ );
+ } else {
+ // Stop shimmer when loaded
+ shimmerOpacity.value = withTiming(0.3, { duration: 300 });
+ }
+ }, [imageLoaded, imageSource]);
+
+ // Reset loading state when image source changes
+ useEffect(() => {
+ if (imageSource) {
+ setImageLoaded(false);
+ imageLoadOpacity.value = 0;
+ }
+ }, [imageSource]);
+
+ // Enhanced image handlers with smooth transitions
+ const handleImageError = () => {
+ setImageError(true);
+ setImageLoaded(false);
+ imageOpacity.value = withTiming(0.6, { duration: 150 });
+ imageLoadOpacity.value = withTiming(0, { duration: 150 });
+ runOnJS(() => {
+ if (bannerImage !== metadata.banner) {
+ setBannerImage(metadata.banner || metadata.poster);
+ }
+ })();
+ };
- const logoAnimatedStyle = useAnimatedStyle(() => ({
+ const handleImageLoad = () => {
+ setImageError(false);
+ setImageLoaded(true);
+ imageOpacity.value = withTiming(1, { duration: 150 });
+ // Smooth fade-in for the loaded image
+ imageLoadOpacity.value = withTiming(1, { duration: 400 });
+ };
+
+ // Ultra-optimized animated styles - single calculations
+ const heroAnimatedStyle = useAnimatedStyle(() => ({
+ height: heroHeight.value,
+ opacity: heroOpacity.value,
+ }), []);
+
+ const logoAnimatedStyle = useAnimatedStyle(() => {
+ // Determine if progress bar should be shown
+ const hasProgress = watchProgress && watchProgress.duration > 0;
+
+ // Scale down logo when progress bar is present
+ const logoScale = hasProgress ? 0.85 : 1;
+
+ return {
opacity: logoOpacity.value,
- transform: [{ scale: logoScale.value }]
- }));
+ transform: [
+ {
+ translateY: interpolate(
+ scrollY.value,
+ [0, 100],
+ [0, -20],
+ Extrapolate.CLAMP
+ )
+ },
+ { scale: withTiming(logoScale, { duration: 300 }) }
+ ]
+ };
+ }, [watchProgress]);
const watchProgressAnimatedStyle = useAnimatedStyle(() => ({
opacity: watchProgressOpacity.value,
- transform: [
- {
- translateY: interpolate(
- watchProgressScaleY.value,
- [0, 1],
- [-8, 0],
- Extrapolate.CLAMP
- )
- },
- { scaleY: watchProgressScaleY.value }
- ]
- }));
+ }), []);
- const genresAnimatedStyle = useAnimatedStyle(() => ({
- opacity: genresOpacity.value,
- transform: [{ translateY: genresTranslateY.value }]
- }));
+ // Enhanced backdrop with smooth loading animation
+ const backdropImageStyle = useAnimatedStyle(() => {
+ 'worklet';
+ const translateY = scrollY.value * PARALLAX_FACTOR;
+ const scale = 1 + (scrollY.value * 0.0001); // Micro scale effect
+
+ return {
+ opacity: imageOpacity.value * imageLoadOpacity.value,
+ transform: [
+ { translateY: -Math.min(translateY, 100) }, // Cap translation
+ { scale: Math.min(scale, SCALE_FACTOR) } // Cap scale
+ ],
+ };
+ }, []);
+ // Simplified buttons animation
const buttonsAnimatedStyle = useAnimatedStyle(() => ({
opacity: buttonsOpacity.value,
- transform: [{ translateY: buttonsTranslateY.value }]
- }));
+ transform: [{
+ translateY: interpolate(
+ buttonsTranslateY.value,
+ [0, 20],
+ [0, 20],
+ Extrapolate.CLAMP
+ )
+ }]
+ }), []);
- const parallaxImageStyle = useAnimatedStyle(() => ({
- width: '100%',
- height: '120%',
- top: '-10%',
- transform: [
- {
- translateY: interpolate(
- dampedScrollY.value,
- [0, 100, 300],
- [20, -20, -60],
- Extrapolate.CLAMP
- )
- },
- {
- scale: interpolate(
- dampedScrollY.value,
- [0, 150, 300],
- [1.1, 1.02, 0.95],
- Extrapolate.CLAMP
- )
- }
- ],
- }));
+ // Ultra-optimized genre rendering
+ const genreElements = useMemo(() => {
+ if (!metadata?.genres?.length) return null;
- // Render genres
- const renderGenres = () => {
- if (!metadata?.genres || !Array.isArray(metadata.genres) || metadata.genres.length === 0) {
- return null;
- }
-
- const genresToDisplay: string[] = metadata.genres as string[];
-
- return genresToDisplay.slice(0, 4).map((genreName, index, array) => (
-
+ const genresToDisplay = metadata.genres.slice(0, 3); // Reduced to 3 for performance
+ return genresToDisplay.map((genreName: string, index: number, array: string[]) => (
+
{genreName}
{index < array.length - 1 && (
-
- •
-
+ •
)}
));
- };
+ }, [metadata.genres, currentTheme.colors.text]);
+
+ // Memoized play button text
+ const playButtonText = useMemo(() => getPlayButtonText(), [getPlayButtonText]);
+
+ // Calculate if content is watched (>=85% progress) - check both local and Trakt progress
+ const isWatched = useMemo(() => {
+ if (!watchProgress) return false;
+
+ // Check Trakt progress first if available and user is authenticated
+ if (isTraktAuthenticated && watchProgress.traktProgress !== undefined) {
+ const traktWatched = watchProgress.traktProgress >= 95;
+ logger.log(`[HeroSection] Trakt authenticated: ${isTraktAuthenticated}, Trakt progress: ${watchProgress.traktProgress}%, Watched: ${traktWatched}`);
+ return traktWatched;
+ }
+
+ // Fall back to local progress
+ if (watchProgress.duration === 0) return false;
+ const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
+ const localWatched = progressPercent >= 85;
+ logger.log(`[HeroSection] Local progress: ${progressPercent.toFixed(1)}%, Watched: ${localWatched}`);
+ return localWatched;
+ }, [watchProgress, isTraktAuthenticated]);
return (
-
-
- {loadingBanner ? (
-
- ) : (
- {
- logger.warn(`[HeroSection] Banner failed to load: ${bannerImage}`);
- if (bannerImage !== metadata.banner) {
- setBannerImage(metadata.banner || metadata.poster);
- }
- }}
+
+ {/* Optimized Background */}
+
+
+ {/* Loading placeholder for smooth transition */}
+ {((imageSource && !imageLoaded) || loadingBanner) && (
+
+
- )}
-
-
- {/* Title/Logo */}
-
-
- {metadata.logo && !logoLoadError ? (
- {
- logger.warn(`[HeroSection] Logo failed to load: ${metadata.logo}`);
- setLogoLoadError(true);
- }}
- />
- ) : (
- {metadata.name}
- )}
-
-
+
+ )}
+
+ {/* Enhanced Background Image with smooth loading */}
+ {imageSource && !loadingBanner && (
+
+ )}
- {/* Watch Progress */}
-
-
- {/* Genre Tags */}
-
-
- {renderGenres()}
-
+ {/* Simplified Gradient */}
+
+
+ {/* Optimized Title/Logo */}
+
+
+ {metadata.logo && !logoLoadError ? (
+ {
+ runOnJS(setLogoLoadError)(true);
+ }}
+ />
+ ) : (
+
+ {metadata.name}
+
+ )}
-
- {/* Action Buttons */}
-
-
-
+
+ {/* Enhanced Watch Progress with Trakt integration */}
+
+
+ {/* Optimized Genres */}
+ {genreElements && (
+
+ {genreElements}
+
+ )}
+
+ {/* Optimized Action Buttons */}
+
+
+
);
};
+// Ultra-optimized styles
const styles = StyleSheet.create({
heroSection: {
width: '100%',
- height: height * 0.5,
backgroundColor: '#000',
overflow: 'hidden',
},
@@ -432,17 +838,18 @@ const styles = StyleSheet.create({
heroGradient: {
flex: 1,
justifyContent: 'flex-end',
- paddingBottom: 24,
+ paddingBottom: 20,
},
heroContent: {
padding: 16,
- paddingTop: 12,
- paddingBottom: 12,
+ paddingTop: 8,
+ paddingBottom: 8,
},
logoContainer: {
alignItems: 'center',
justifyContent: 'center',
width: '100%',
+ marginBottom: 4,
},
titleLogoContainer: {
alignItems: 'center',
@@ -450,120 +857,375 @@ const styles = StyleSheet.create({
width: '100%',
},
titleLogo: {
- width: width * 0.8,
- height: 100,
- marginBottom: 0,
+ width: width * 0.75,
+ height: 90,
alignSelf: 'center',
},
heroTitle: {
- fontSize: 28,
+ fontSize: 26,
fontWeight: '900',
- marginBottom: 12,
- textShadowColor: 'rgba(0,0,0,0.75)',
- textShadowOffset: { width: 0, height: 2 },
- textShadowRadius: 4,
- letterSpacing: -0.5,
+ marginBottom: 8,
+ textShadowColor: 'rgba(0,0,0,0.8)',
+ textShadowOffset: { width: 0, height: 1 },
+ textShadowRadius: 3,
+ letterSpacing: -0.3,
+ textAlign: 'center',
},
genreContainer: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'center',
alignItems: 'center',
- marginTop: 8,
- marginBottom: 16,
- gap: 4,
+ marginTop: 6,
+ marginBottom: 14,
+ gap: 6,
},
genreText: {
fontSize: 12,
fontWeight: '500',
+ opacity: 0.9,
},
genreDot: {
fontSize: 12,
fontWeight: '500',
- marginHorizontal: 4,
+ opacity: 0.6,
+ marginHorizontal: 2,
},
actionButtons: {
flexDirection: 'row',
gap: 8,
alignItems: 'center',
- marginBottom: -12,
justifyContent: 'center',
width: '100%',
+ position: 'relative',
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
- paddingVertical: 10,
- borderRadius: 100,
- elevation: 4,
- shadowColor: '#000',
- shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.3,
- shadowRadius: 4,
+ paddingVertical: 11,
+ paddingHorizontal: 16,
+ borderRadius: 26,
flex: 1,
},
playButton: {
backgroundColor: '#fff',
- },
- infoButton: {
- backgroundColor: 'rgba(255,255,255,0.2)',
- borderWidth: 2,
- borderColor: '#fff',
- },
- iconButton: {
- width: 48,
- height: 48,
- borderRadius: 24,
- backgroundColor: 'rgba(255,255,255,0.2)',
- borderWidth: 2,
- borderColor: '#fff',
- alignItems: 'center',
- justifyContent: 'center',
- elevation: 4,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
- shadowOpacity: 0.3,
+ shadowOpacity: 0.25,
shadowRadius: 4,
+ elevation: 4,
+ },
+ infoButton: {
+ borderWidth: 1.5,
+ borderColor: 'rgba(255,255,255,0.7)',
+ overflow: 'hidden',
+ },
+ iconButton: {
+ width: 50,
+ height: 50,
+ borderRadius: 25,
+ borderWidth: 1.5,
+ borderColor: 'rgba(255,255,255,0.7)',
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
},
playButtonText: {
color: '#000',
- fontWeight: '600',
- marginLeft: 8,
- fontSize: 16,
+ fontWeight: '700',
+ marginLeft: 6,
+ fontSize: 15,
},
infoButtonText: {
color: '#fff',
- marginLeft: 8,
+ marginLeft: 6,
fontWeight: '600',
- fontSize: 16,
+ fontSize: 15,
},
watchProgressContainer: {
- marginTop: 6,
- marginBottom: 8,
+ marginTop: 4,
+ marginBottom: 4,
width: '100%',
alignItems: 'center',
+ minHeight: 36,
+ position: 'relative',
+ },
+ progressGlassBackground: {
+ width: '75%',
+ backgroundColor: 'rgba(255,255,255,0.08)',
+ borderRadius: 12,
+ padding: 8,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.1)',
overflow: 'hidden',
- height: 48,
+ },
+ androidProgressBlur: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 16,
+ backgroundColor: 'rgba(0,0,0,0.3)',
+ },
+ watchProgressBarContainer: {
+ position: 'relative',
+ marginBottom: 6,
},
watchProgressBar: {
- width: '75%',
+ width: '100%',
height: 3,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
borderRadius: 1.5,
overflow: 'hidden',
- marginBottom: 6
+ position: 'relative',
},
watchProgressFill: {
height: '100%',
- borderRadius: 1.5,
+ borderRadius: 1.25,
+ },
+ traktSyncIndicator: {
+ position: 'absolute',
+ right: 2,
+ top: -2,
+ bottom: -2,
+ width: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ traktSyncIndicatorEnhanced: {
+ position: 'absolute',
+ right: 4,
+ top: -2,
+ bottom: -2,
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ alignItems: 'center',
+ justifyContent: 'center',
+ overflow: 'hidden',
+ },
+ watchedProgressIndicator: {
+ position: 'absolute',
+ right: 2,
+ top: -1,
+ bottom: -1,
+ width: 10,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ watchProgressTextContainer: {
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ width: '100%',
},
watchProgressText: {
- fontSize: 12,
+ fontSize: 11,
textAlign: 'center',
- opacity: 0.9,
- letterSpacing: 0.2
+ opacity: 0.85,
+ letterSpacing: 0.1,
+ flex: 1,
},
+ traktSyncButton: {
+ padding: 4,
+ borderRadius: 12,
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ },
+ blurBackground: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 20,
+ },
+ androidFallbackBlur: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 20,
+ backgroundColor: 'rgba(255,255,255,0.15)',
+ },
+ blurBackgroundRound: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 25,
+ },
+ androidFallbackBlurRound: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 25,
+ backgroundColor: 'rgba(255,255,255,0.15)',
+ },
+ watchedIndicator: {
+ position: 'absolute',
+ top: 4,
+ right: 4,
+ backgroundColor: 'rgba(0,0,0,0.6)',
+ borderRadius: 8,
+ width: 16,
+ height: 16,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ watchedPlayButton: {
+ backgroundColor: '#1e1e1e',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.3)',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.25,
+ shadowRadius: 4,
+ elevation: 4,
+ },
+ watchedPlayButtonText: {
+ color: '#fff',
+ fontWeight: '700',
+ marginLeft: 6,
+ fontSize: 15,
+ },
+ // Enhanced progress indicator styles
+ progressShimmer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 2,
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ },
+ completionGlow: {
+ position: 'absolute',
+ top: -2,
+ left: -2,
+ right: -2,
+ bottom: -2,
+ borderRadius: 4,
+ backgroundColor: 'rgba(0,255,136,0.2)',
+ },
+ completionIndicator: {
+ position: 'absolute',
+ right: 4,
+ top: -6,
+ bottom: -6,
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ completionGradient: {
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ sparkleContainer: {
+ position: 'absolute',
+ top: -10,
+ left: 0,
+ right: 0,
+ bottom: -10,
+ borderRadius: 2,
+ },
+ sparkle: {
+ position: 'absolute',
+ width: 8,
+ height: 8,
+ borderRadius: 4,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ progressInfoMain: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginBottom: 2,
+ },
+ watchProgressMainText: {
+ fontSize: 11,
+ 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',
+ opacity: 0.8,
+ marginBottom: 1,
+ },
+ syncStatusContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ marginTop: 2,
+ width: '100%',
+ flexWrap: 'wrap',
+ },
+ syncStatusText: {
+ fontSize: 9,
+ marginLeft: 4,
+ fontWeight: '500',
+ },
+ traktSyncButtonEnhanced: {
+ position: 'absolute',
+ top: 8,
+ right: 8,
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ overflow: 'hidden',
+ },
+ traktSyncButtonInline: {
+ marginLeft: 8,
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ overflow: 'hidden',
+ },
+ syncButtonGradient: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ syncButtonGradientInline: {
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ traktIndicatorGradient: {
+ width: 16,
+ height: 16,
+ borderRadius: 8,
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
});
export default React.memo(HeroSection);
\ No newline at end of file
diff --git a/src/components/metadata/MoreLikeThisSection.tsx b/src/components/metadata/MoreLikeThisSection.tsx
index f69cc69..f2df2e4 100644
--- a/src/components/metadata/MoreLikeThisSection.tsx
+++ b/src/components/metadata/MoreLikeThisSection.tsx
@@ -19,7 +19,32 @@ import { TMDBService } from '../../services/tmdbService';
import { catalogService } from '../../services/catalogService';
const { width } = Dimensions.get('window');
-const POSTER_WIDTH = (width - 48) / 3.5; // Adjust number for desired items visible
+
+// Dynamic poster calculation based on screen width for More Like This section
+const calculatePosterLayout = (screenWidth: number) => {
+ const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section
+ const MAX_POSTER_WIDTH = 130; // Maximum poster width
+ const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins
+
+ // Calculate how many posters can fit (aim for slightly more items than main sections)
+ const availableWidth = screenWidth - HORIZONTAL_PADDING;
+ const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
+
+ // Limit to reasonable number of columns (3-7 for this section)
+ const numColumns = Math.min(Math.max(maxColumns, 3), 7);
+
+ // Calculate actual poster width
+ const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
+
+ return {
+ numColumns,
+ posterWidth,
+ spacing: 12 // Space between posters
+ };
+};
+
+const posterLayout = calculatePosterLayout(width);
+const POSTER_WIDTH = posterLayout.posterWidth;
const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
interface MoreLikeThisSectionProps {
diff --git a/src/components/metadata/MovieContent.tsx b/src/components/metadata/MovieContent.tsx
index 95836e1..458caa8 100644
--- a/src/components/metadata/MovieContent.tsx
+++ b/src/components/metadata/MovieContent.tsx
@@ -10,7 +10,7 @@ interface MovieContentProps {
export const MovieContent: React.FC = ({ metadata }) => {
const { currentTheme } = useTheme();
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0;
- const castDisplay = hasCast ? (metadata.cast as string[]).slice(0, 5).join(', ') : '';
+ const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : '';
return (
@@ -23,12 +23,6 @@ export const MovieContent: React.FC = ({ metadata }) => {
)}
- {metadata.writer && (
-
- Writer:
- {metadata.writer}
-
- )}
{hasCast && (
diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx
index 3ad9b8e..5f5feb5 100644
--- a/src/components/metadata/SeriesContent.tsx
+++ b/src/components/metadata/SeriesContent.tsx
@@ -2,7 +2,9 @@ import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
import { Image } from 'expo-image';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
+import { LinearGradient } from 'expo-linear-gradient';
import { useTheme } from '../../contexts/ThemeContext';
+import { useSettings } from '../../hooks/useSettings';
import { Episode } from '../../types/metadata';
import { tmdbService } from '../../services/tmdbService';
import { storageService } from '../../services/storageService';
@@ -34,19 +36,21 @@ export const SeriesContent: React.FC = ({
metadata
}) => {
const { currentTheme } = useTheme();
+ const { settings } = useSettings();
const { width } = useWindowDimensions();
const isTablet = width > 768;
const isDarkMode = useColorScheme() === 'dark';
- const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number } }>({});
+ const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({});
- // Add ref for the season selector ScrollView
+ // Add refs for the scroll views
const seasonScrollViewRef = useRef(null);
+ const episodeScrollViewRef = useRef(null);
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
const allProgress = await storageService.getAllWatchProgress();
- const progress: { [key: string]: { currentTime: number; duration: number } } = {};
+ const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {};
episodes.forEach(episode => {
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
@@ -54,7 +58,8 @@ export const SeriesContent: React.FC = ({
if (allProgress[key]) {
progress[episodeId] = {
currentTime: allProgress[key].currentTime,
- duration: allProgress[key].duration
+ duration: allProgress[key].duration,
+ lastUpdated: allProgress[key].lastUpdated
};
}
});
@@ -62,6 +67,67 @@ export const SeriesContent: React.FC = ({
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'
+ });
+ return;
+ }
+
+ const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
+ if (currentSeasonEpisodes.length === 0) {
+ console.log('[SeriesContent] No episodes in current season:', selectedSeason);
+ return;
+ }
+
+ // Find the most recently watched episode in the current season
+ let mostRecentEpisodeIndex = -1;
+ let mostRecentTimestamp = 0;
+ let mostRecentEpisodeName = '';
+
+ currentSeasonEpisodes.forEach((episode, index) => {
+ const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
+ const progress = episodeProgress[episodeId];
+
+ if (progress && progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
+ mostRecentTimestamp = progress.lastUpdated;
+ mostRecentEpisodeIndex = index;
+ mostRecentEpisodeName = episode.name;
+ }
+ });
+
+ 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
+ });
+ }, 500); // Delay to ensure the season has loaded
+ }
+ };
+
// Initial load of watch progress
useEffect(() => {
loadEpisodesProgress();
@@ -93,6 +159,13 @@ export const SeriesContent: React.FC = ({
}
}, [selectedSeason, groupedEpisodes]);
+ // Add effect to scroll to most recently watched episode when season changes or progress loads
+ useEffect(() => {
+ if (Object.keys(episodeProgress).length > 0 && selectedSeason) {
+ scrollToMostRecentEpisode();
+ }
+ }, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]);
+
if (loadingSeasons) {
return (
@@ -159,6 +232,7 @@ export const SeriesContent: React.FC = ({
= ({
);
};
- const renderEpisodeCard = (episode: Episode) => {
+ // Vertical layout episode card (traditional)
+ const renderVerticalEpisodeCard = (episode: Episode) => {
let episodeImage = EPISODE_PLACEHOLDER;
if (episode.still_path) {
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
@@ -210,15 +285,15 @@ export const SeriesContent: React.FC = ({
const progress = episodeProgress[episodeId];
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
- // Don't show progress bar if episode is complete (>= 95%)
- const showProgress = progress && progressPercent < 95;
+ // Don't show progress bar if episode is complete (>= 85%)
+ const showProgress = progress && progressPercent < 85;
return (
onSelectEpisode(episode)}
@@ -243,7 +318,7 @@ export const SeriesContent: React.FC = ({
/>
)}
- {progressPercent >= 95 && (
+ {progressPercent >= 85 && (
@@ -291,6 +366,170 @@ export const SeriesContent: React.FC = ({
);
};
+ // Horizontal layout episode card (Netflix-style)
+ const renderHorizontalEpisodeCard = (episode: Episode) => {
+ let episodeImage = EPISODE_PLACEHOLDER;
+ if (episode.still_path) {
+ const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
+ if (tmdbUrl) episodeImage = tmdbUrl;
+ } else if (metadata?.poster) {
+ episodeImage = metadata.poster;
+ }
+
+ const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
+ const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
+ const episodeString = seasonNumber && episodeNumber ? `EPISODE ${episodeNumber}` : '';
+
+ const formatRuntime = (runtime: number) => {
+ if (!runtime) return null;
+ const hours = Math.floor(runtime / 60);
+ const minutes = runtime % 60;
+ if (hours > 0) {
+ return `${hours}h ${minutes}m`;
+ }
+ return `${minutes}m`;
+ };
+
+ // Get episode progress
+ const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
+ const progress = episodeProgress[episodeId];
+ const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
+
+ // Don't show progress bar if episode is complete (>= 85%)
+ const showProgress = progress && progressPercent < 85;
+
+ return (
+ onSelectEpisode(episode)}
+ activeOpacity={0.85}
+ >
+ {/* Gradient Border Container */}
+
+
+
+
+ {/* Background Image */}
+
+
+ {/* Standard Gradient Overlay */}
+
+ {/* Content Container */}
+
+ {/* Episode Number Badge */}
+
+ {episodeString}
+
+
+ {/* Episode Title */}
+
+ {episode.name}
+
+
+ {/* Episode Description */}
+
+ {episode.overview || 'No description available'}
+
+
+ {/* Metadata Row */}
+
+ {episode.runtime && (
+
+
+ {formatRuntime(episode.runtime)}
+
+
+ )}
+ {episode.vote_average > 0 && (
+
+
+
+ {episode.vote_average.toFixed(1)}
+
+
+ )}
+
+
+
+ {/* Progress Bar */}
+ {showProgress && (
+
+
+
+ )}
+
+ {/* Completed Badge */}
+ {progressPercent >= 85 && (
+
+
+
+ )}
+
+
+
+ );
+ };
+
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
return (
@@ -308,35 +547,63 @@ export const SeriesContent: React.FC = ({
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
-
- {isTablet ? (
-
- {currentSeasonEpisodes.map((episode, index) => (
+ {settings.episodeLayoutStyle === 'horizontal' ? (
+ // Horizontal Layout (Netflix-style)
+
+ {currentSeasonEpisodes.map((episode, index) => (
+
+ {renderHorizontalEpisodeCard(episode)}
+
+ ))}
+
+ ) : (
+ // Vertical Layout (Traditional)
+
+ {isTablet ? (
+
+ {currentSeasonEpisodes.map((episode, index) => (
+
+ {renderVerticalEpisodeCard(episode)}
+
+ ))}
+
+ ) : (
+ currentSeasonEpisodes.map((episode, index) => (
- {renderEpisodeCard(episode)}
+ {renderVerticalEpisodeCard(episode)}
- ))}
-
- ) : (
- currentSeasonEpisodes.map((episode, index) => (
-
- {renderEpisodeCard(episode)}
-
- ))
- )}
-
+ ))
+ )}
+
+ )}
);
@@ -345,7 +612,7 @@ export const SeriesContent: React.FC = ({
const styles = StyleSheet.create({
container: {
flex: 1,
- padding: 16,
+ paddingVertical: 16,
},
centeredContainer: {
flex: 1,
@@ -362,22 +629,26 @@ const styles = StyleSheet.create({
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
+ paddingHorizontal: 16,
},
episodeList: {
flex: 1,
},
- episodeListContent: {
+
+ // Vertical Layout Styles
+ episodeListContentVertical: {
paddingBottom: 20,
+ paddingHorizontal: 16,
},
- episodeListContentTablet: {
+ episodeListContentVerticalTablet: {
paddingHorizontal: 8,
},
- episodeGrid: {
+ episodeGridVertical: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
- episodeCard: {
+ episodeCardVertical: {
flexDirection: 'row',
borderRadius: 16,
marginBottom: 16,
@@ -391,7 +662,7 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255,255,255,0.1)',
height: 120,
},
- episodeCardTablet: {
+ episodeCardVerticalTablet: {
width: '48%',
flexDirection: 'column',
height: 120,
@@ -461,6 +732,19 @@ const styles = StyleSheet.create({
fontWeight: '700',
marginLeft: 4,
},
+ runtimeContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0,0,0,0.7)',
+ paddingHorizontal: 4,
+ paddingVertical: 2,
+ borderRadius: 4,
+ },
+ runtimeText: {
+ fontSize: 13,
+ fontWeight: '600',
+ marginLeft: 4,
+ },
airDateText: {
fontSize: 12,
opacity: 0.8,
@@ -469,8 +753,170 @@ const styles = StyleSheet.create({
fontSize: 13,
lineHeight: 18,
},
+ progressBarContainer: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ height: 3,
+ backgroundColor: 'rgba(0,0,0,0.5)',
+ },
+ progressBar: {
+ height: '100%',
+ },
+ completedBadge: {
+ position: 'absolute',
+ bottom: 8,
+ right: 8,
+ width: 20,
+ height: 20,
+ borderRadius: 10,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.3)',
+ },
+
+ // Horizontal Layout Styles
+ episodeListContentHorizontal: {
+ paddingLeft: 16,
+ paddingRight: 16,
+ },
+ episodeCardWrapperHorizontal: {
+ width: Dimensions.get('window').width * 0.85,
+ marginRight: 16,
+ },
+ episodeCardWrapperHorizontalTablet: {
+ width: Dimensions.get('window').width * 0.4,
+ },
+ episodeCardHorizontal: {
+ borderRadius: 16,
+ overflow: 'hidden',
+ elevation: 8,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.35,
+ shadowRadius: 12,
+ borderWidth: 1,
+ borderColor: 'rgba(255,255,255,0.05)',
+ height: 200,
+ position: 'relative',
+ width: '100%',
+ backgroundColor: 'transparent',
+ },
+ episodeCardHorizontalTablet: {
+ height: 180,
+ },
+ episodeBackgroundImage: {
+ width: '100%',
+ height: '100%',
+ borderRadius: 16,
+ },
+ episodeGradient: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ borderRadius: 16,
+ justifyContent: 'flex-end',
+ },
+ episodeContent: {
+ padding: 12,
+ paddingBottom: 16,
+ },
+ episodeNumberBadgeHorizontal: {
+ backgroundColor: 'rgba(0,0,0,0.4)',
+ paddingHorizontal: 6,
+ paddingVertical: 3,
+ borderRadius: 4,
+ marginBottom: 6,
+ alignSelf: 'flex-start',
+ },
+ episodeNumberHorizontal: {
+ color: 'rgba(255,255,255,0.8)',
+ fontSize: 10,
+ fontWeight: '600',
+ letterSpacing: 0.8,
+ textTransform: 'uppercase',
+ marginBottom: 2,
+ },
+ episodeTitleHorizontal: {
+ color: '#fff',
+ fontSize: 15,
+ fontWeight: '700',
+ letterSpacing: -0.3,
+ marginBottom: 4,
+ lineHeight: 18,
+ },
+ episodeDescriptionHorizontal: {
+ color: 'rgba(255,255,255,0.85)',
+ fontSize: 12,
+ lineHeight: 16,
+ marginBottom: 8,
+ opacity: 0.9,
+ },
+ episodeMetadataRowHorizontal: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 8,
+ },
+ runtimeContainerHorizontal: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0,0,0,0.4)',
+ paddingHorizontal: 5,
+ paddingVertical: 2,
+ borderRadius: 3,
+ },
+ runtimeTextHorizontal: {
+ color: 'rgba(255,255,255,0.8)',
+ fontSize: 11,
+ fontWeight: '500',
+ },
+ ratingContainerHorizontal: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ backgroundColor: 'rgba(0,0,0,0.4)',
+ paddingHorizontal: 5,
+ paddingVertical: 2,
+ borderRadius: 3,
+ gap: 2,
+ },
+ ratingTextHorizontal: {
+ color: '#FFD700',
+ fontSize: 11,
+ fontWeight: '600',
+ },
+ progressBarContainerHorizontal: {
+ position: 'absolute',
+ bottom: 0,
+ left: 0,
+ right: 0,
+ height: 3,
+ backgroundColor: 'rgba(255,255,255,0.2)',
+ },
+ progressBarHorizontal: {
+ height: '100%',
+ borderRadius: 2,
+ },
+ completedBadgeHorizontal: {
+ position: 'absolute',
+ bottom: 12,
+ right: 12,
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ alignItems: 'center',
+ justifyContent: 'center',
+ borderWidth: 2,
+ borderColor: '#fff',
+ },
+
+ // Season Selector Styles
seasonSelectorWrapper: {
marginBottom: 20,
+ paddingHorizontal: 16,
},
seasonSelectorTitle: {
fontSize: 18,
@@ -517,54 +963,4 @@ const styles = StyleSheet.create({
selectedSeasonButtonText: {
fontWeight: '700',
},
- progressBarContainer: {
- position: 'absolute',
- bottom: 0,
- left: 0,
- right: 0,
- height: 3,
- backgroundColor: 'rgba(0,0,0,0.5)',
- },
- progressBar: {
- height: '100%',
- },
- progressTextContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: 'rgba(0,0,0,0.7)',
- paddingHorizontal: 6,
- paddingVertical: 3,
- borderRadius: 4,
- marginRight: 8,
- },
- progressText: {
- fontSize: 12,
- fontWeight: '600',
- marginLeft: 4,
- },
- completedBadge: {
- position: 'absolute',
- bottom: 8,
- right: 8,
- width: 20,
- height: 20,
- borderRadius: 10,
- alignItems: 'center',
- justifyContent: 'center',
- borderWidth: 1,
- borderColor: 'rgba(255,255,255,0.3)',
- },
- runtimeContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- backgroundColor: 'rgba(0,0,0,0.7)',
- paddingHorizontal: 4,
- paddingVertical: 2,
- borderRadius: 4,
- },
- runtimeText: {
- fontSize: 13,
- fontWeight: '600',
- marginLeft: 4,
- },
});
\ No newline at end of file
diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx
new file mode 100644
index 0000000..13d5c96
--- /dev/null
+++ b/src/components/player/AndroidVideoPlayer.tsx
@@ -0,0 +1,1131 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } 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';
+import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
+import RNImmersiveMode from 'react-native-immersive-mode';
+import * as ScreenOrientation from 'expo-screen-orientation';
+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 { useTraktAutosync } from '../../hooks/useTraktAutosync';
+
+import {
+ DEFAULT_SUBTITLE_SIZE,
+ AudioTrack,
+ TextTrack,
+ ResizeModeType,
+ WyzieSubtitle,
+ SubtitleCue,
+ RESUME_PREF_KEY,
+ RESUME_PREF,
+ SUBTITLE_SIZE_KEY
+} 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 ResumeOverlay from './modals/ResumeOverlay';
+import PlayerControls from './controls/PlayerControls';
+import CustomSubtitles from './subtitles/CustomSubtitles';
+import SourcesModal from './modals/SourcesModal';
+
+// Map VLC resize modes to react-native-video resize modes
+const getVideoResizeMode = (resizeMode: ResizeModeType) => {
+ switch (resizeMode) {
+ case 'contain': return 'contain';
+ case 'cover': return 'cover';
+ case 'stretch': return 'stretch';
+ case 'none': return 'contain';
+ default: return 'contain';
+ }
+};
+
+const AndroidVideoPlayer: React.FC = () => {
+ const navigation = useNavigation();
+ const route = useRoute>();
+
+ const {
+ uri,
+ title = 'Episode Name',
+ season,
+ episode,
+ episodeTitle,
+ quality,
+ year,
+ streamProvider,
+ streamName,
+ id,
+ type,
+ episodeId,
+ imdbId,
+ availableStreams: passedAvailableStreams
+ } = route.params;
+
+ // Initialize Trakt autosync
+ const traktAutosync = useTraktAutosync({
+ id: id || '',
+ type: type === 'series' ? 'series' : 'movie',
+ title: episodeTitle || title,
+ year: year || 0,
+ imdbId: imdbId || '',
+ season: season,
+ episode: episode,
+ showTitle: title,
+ showYear: year,
+ showImdbId: imdbId,
+ episodeId: episodeId
+ });
+
+ safeDebugLog("Android Component mounted with props", {
+ uri, title, season, episode, episodeTitle, quality, year,
+ streamProvider, id, type, episodeId, imdbId
+ });
+
+ const screenData = Dimensions.get('screen');
+ const [screenDimensions, setScreenDimensions] = useState(screenData);
+
+ const [paused, setPaused] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [showControls, setShowControls] = useState(true);
+ const [audioTracks, setAudioTracks] = useState([]);
+ const [selectedAudioTrack, setSelectedAudioTrack] = useState(null);
+ const [textTracks, setTextTracks] = useState([]);
+ const [selectedTextTrack, setSelectedTextTrack] = useState(-1);
+ const [resizeMode, setResizeMode] = useState('stretch');
+ const [buffered, setBuffered] = useState(0);
+ const [seekTime, setSeekTime] = useState(null);
+ const videoRef = useRef(null);
+ const [showAudioModal, setShowAudioModal] = useState(false);
+ const [showSubtitleModal, setShowSubtitleModal] = useState(false);
+ const [initialPosition, setInitialPosition] = useState(null);
+ const [progressSaveInterval, setProgressSaveInterval] = useState(null);
+ const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
+ const [showResumeOverlay, setShowResumeOverlay] = useState(false);
+ const [resumePosition, setResumePosition] = useState(null);
+ const [rememberChoice, setRememberChoice] = useState(false);
+ const [resumePreference, setResumePreference] = useState(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 [rnVideoAudioTracks, setRnVideoAudioTracks] = useState>([]);
+ const [rnVideoTextTracks, setRnVideoTextTracks] = useState>([]);
+ const [isPlayerReady, setIsPlayerReady] = useState(false);
+ const progressAnim = useRef(new Animated.Value(0)).current;
+ const progressBarRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const isSeeking = useRef(false);
+ const seekDebounceTimer = useRef(null);
+ const pendingSeekValue = useRef(null);
+ const lastSeekTime = useRef(0);
+ const [isVideoLoaded, setIsVideoLoaded] = useState(false);
+ const [videoAspectRatio, setVideoAspectRatio] = useState(null);
+ const [is16by9Content, setIs16by9Content] = useState(false);
+ const [customVideoStyles, setCustomVideoStyles] = useState({});
+ const [zoomScale, setZoomScale] = useState(1);
+ const [zoomTranslateX, setZoomTranslateX] = useState(0);
+ const [zoomTranslateY, setZoomTranslateY] = useState(0);
+ const [lastZoomScale, setLastZoomScale] = useState(1);
+ const [lastTranslateX, setLastTranslateX] = useState(0);
+ const [lastTranslateY, setLastTranslateY] = useState(0);
+ const pinchRef = useRef(null);
+ const [customSubtitles, setCustomSubtitles] = useState([]);
+ const [currentSubtitle, setCurrentSubtitle] = useState('');
+ const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE);
+ const [useCustomSubtitles, setUseCustomSubtitles] = useState(false);
+ const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false);
+ const [availableSubtitles, setAvailableSubtitles] = useState([]);
+ const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
+ const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false);
+ const [showSourcesModal, setShowSourcesModal] = useState(false);
+ const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {});
+ const [currentStreamUrl, setCurrentStreamUrl] = useState(uri);
+ const [isChangingSource, setIsChangingSource] = useState(false);
+ const [pendingSeek, setPendingSeek] = useState<{ position: number; shouldPlay: boolean } | null>(null);
+ const [currentQuality, setCurrentQuality] = useState(quality);
+ const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider);
+ const [currentStreamName, setCurrentStreamName] = useState(streamName);
+ const isMounted = useRef(true);
+
+ const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => {
+ return {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: screenWidth,
+ height: screenHeight,
+ };
+ };
+
+ const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
+ const { scale } = event.nativeEvent;
+ const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
+ setZoomScale(newScale);
+ if (DEBUG_MODE) {
+ logger.log(`[AndroidVideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`);
+ }
+ };
+
+ const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
+ if (event.nativeEvent.state === State.END) {
+ setLastZoomScale(zoomScale);
+ if (DEBUG_MODE) {
+ logger.log(`[AndroidVideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`);
+ }
+ }
+ };
+
+ const resetZoom = () => {
+ const targetZoom = is16by9Content ? 1.1 : 1;
+ setZoomScale(targetZoom);
+ setLastZoomScale(targetZoom);
+ if (DEBUG_MODE) {
+ logger.log(`[AndroidVideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`);
+ }
+ };
+
+ useEffect(() => {
+ if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) {
+ const styles = calculateVideoStyles(
+ videoAspectRatio * 1000,
+ 1000,
+ screenDimensions.width,
+ screenDimensions.height
+ );
+ setCustomVideoStyles(styles);
+ if (DEBUG_MODE) {
+ logger.log(`[AndroidVideoPlayer] Screen dimensions changed, recalculated styles:`, styles);
+ }
+ }
+ }, [screenDimensions, videoAspectRatio]);
+
+ useEffect(() => {
+ const subscription = Dimensions.addEventListener('change', ({ screen }) => {
+ setScreenDimensions(screen);
+ });
+ const initializePlayer = () => {
+ StatusBar.setHidden(true, 'none');
+ enableImmersiveMode();
+ startOpeningAnimation();
+ };
+ initializePlayer();
+ return () => {
+ subscription?.remove();
+ const unlockOrientation = async () => {
+ await ScreenOrientation.unlockAsync();
+ };
+ unlockOrientation();
+ disableImmersiveMode();
+ };
+ }, []);
+
+ const startOpeningAnimation = () => {
+ // Animation logic here
+ };
+
+ const completeOpeningAnimation = () => {
+ Animated.parallel([
+ Animated.timing(openingFadeAnim, {
+ toValue: 1,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ Animated.timing(openingScaleAnim, {
+ toValue: 1,
+ duration: 700,
+ useNativeDriver: true,
+ }),
+ Animated.timing(backgroundFadeAnim, {
+ toValue: 0,
+ duration: 800,
+ useNativeDriver: true,
+ }),
+ ]).start(() => {
+ openingScaleAnim.setValue(1);
+ openingFadeAnim.setValue(1);
+ setIsOpeningAnimationComplete(true);
+ setTimeout(() => {
+ backgroundFadeAnim.setValue(0);
+ }, 100);
+ });
+ };
+
+ useEffect(() => {
+ const loadWatchProgress = async () => {
+ if (id && type) {
+ try {
+ logger.log(`[AndroidVideoPlayer] Loading watch progress for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
+ const savedProgress = await storageService.getWatchProgress(id, type, episodeId);
+ logger.log(`[AndroidVideoPlayer] Saved progress:`, savedProgress);
+
+ if (savedProgress) {
+ const progressPercent = (savedProgress.currentTime / savedProgress.duration) * 100;
+ logger.log(`[AndroidVideoPlayer] Progress: ${progressPercent.toFixed(1)}% (${savedProgress.currentTime}/${savedProgress.duration})`);
+
+ 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`);
+ }
+ } else {
+ logger.log(`[AndroidVideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`);
+ }
+ } else {
+ logger.log(`[AndroidVideoPlayer] No saved progress found`);
+ }
+ } catch (error) {
+ logger.error('[AndroidVideoPlayer] Error loading watch progress:', error);
+ }
+ } else {
+ logger.log(`[AndroidVideoPlayer] Missing id or type: id=${id}, type=${type}`);
+ }
+ };
+ loadWatchProgress();
+ }, [id, type, episodeId]);
+
+ const saveWatchProgress = async () => {
+ if (id && type && currentTime > 0 && duration > 0) {
+ const progress = {
+ currentTime,
+ duration,
+ lastUpdated: Date.now()
+ };
+ try {
+ await storageService.setWatchProgress(id, type, progress, episodeId);
+
+ // Sync to Trakt if authenticated
+ await traktAutosync.handleProgressUpdate(currentTime, duration);
+ } catch (error) {
+ logger.error('[AndroidVideoPlayer] Error saving watch progress:', error);
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (id && type && !paused && duration > 0) {
+ if (progressSaveInterval) {
+ clearInterval(progressSaveInterval);
+ }
+ const interval = setInterval(() => {
+ saveWatchProgress();
+ }, 5000);
+ setProgressSaveInterval(interval);
+ return () => {
+ clearInterval(interval);
+ setProgressSaveInterval(null);
+ };
+ }
+ }, [id, type, paused, currentTime, duration]);
+
+ useEffect(() => {
+ return () => {
+ if (id && type && duration > 0) {
+ saveWatchProgress();
+ // Final Trakt sync on component unmount
+ traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
+ }
+ };
+ }, [id, type, currentTime, duration]);
+
+ const seekToTime = (timeInSeconds: number) => {
+ if (videoRef.current && duration > 0 && !isSeeking.current) {
+ if (DEBUG_MODE) {
+ logger.log(`[AndroidVideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s`);
+ }
+
+ isSeeking.current = true;
+ setSeekTime(timeInSeconds);
+
+ // Clear seek state after seek
+ setTimeout(() => {
+ if (isMounted.current) {
+ setSeekTime(null);
+ isSeeking.current = false;
+ }
+ }, 100);
+ } else {
+ if (DEBUG_MODE) {
+ logger.error('[AndroidVideoPlayer] Seek failed: Player not ready, duration is zero, or already seeking.');
+ }
+ }
+ };
+
+ // Handle seeking when seekTime changes
+ useEffect(() => {
+ if (seekTime !== null && videoRef.current && duration > 0) {
+ videoRef.current.seek(seekTime);
+ }
+ }, [seekTime, duration]);
+
+ const handleProgressBarTouch = (event: any) => {
+ if (duration > 0) {
+ const { locationX } = event.nativeEvent;
+ 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) {
+ seekToTime(pendingSeekValue.current);
+ 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;
+ progressAnim.setValue(percentage);
+ if (isDragging) {
+ pendingSeekValue.current = seekTime;
+ setCurrentTime(seekTime);
+ } else {
+ seekToTime(seekTime);
+ }
+ });
+ };
+
+ const handleProgress = (data: any) => {
+ if (isDragging || isSeeking.current) return;
+
+ const currentTimeInSeconds = data.currentTime;
+
+ // Only update if there's a significant change to avoid unnecessary updates
+ if (Math.abs(currentTimeInSeconds - currentTime) > 0.5) {
+ safeSetState(() => setCurrentTime(currentTimeInSeconds));
+ const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0;
+ Animated.timing(progressAnim, {
+ toValue: progressPercent,
+ duration: 250,
+ useNativeDriver: false,
+ }).start();
+ const bufferedTime = data.playableDuration || currentTimeInSeconds;
+ safeSetState(() => setBuffered(bufferedTime));
+ }
+ };
+
+ const onLoad = (data: any) => {
+ if (DEBUG_MODE) {
+ logger.log('[AndroidVideoPlayer] Video loaded:', data);
+ }
+ if (isMounted.current) {
+ const videoDuration = data.duration;
+ if (data.duration > 0) {
+ setDuration(videoDuration);
+ }
+
+ // Set aspect ratio from video dimensions
+ if (data.naturalSize && data.naturalSize.width && data.naturalSize.height) {
+ setVideoAspectRatio(data.naturalSize.width / data.naturalSize.height);
+ }
+
+ // Handle audio tracks
+ if (data.audioTracks && data.audioTracks.length > 0) {
+ const formattedAudioTracks = data.audioTracks.map((track: any, index: number) => ({
+ id: track.index || index,
+ name: track.title || track.language || `Audio ${index + 1}`,
+ language: track.language,
+ }));
+ setRnVideoAudioTracks(formattedAudioTracks);
+ }
+
+ // Handle text tracks
+ if (data.textTracks && data.textTracks.length > 0) {
+ const formattedTextTracks = data.textTracks.map((track: any, index: number) => ({
+ id: track.index || index,
+ name: track.title || track.language || `Subtitle ${index + 1}`,
+ language: track.language,
+ }));
+ setRnVideoTextTracks(formattedTextTracks);
+ }
+
+ 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(`[AndroidVideoPlayer] Seeking to initial position: ${initialPosition}s (duration: ${videoDuration}s)`);
+ setTimeout(() => {
+ if (videoRef.current && videoDuration > 0 && isMounted.current) {
+ seekToTime(initialPosition);
+ setIsInitialSeekComplete(true);
+ logger.log(`[AndroidVideoPlayer] Initial seek completed to: ${initialPosition}s`);
+ } else {
+ logger.error(`[AndroidVideoPlayer] Initial seek failed: videoRef=${!!videoRef.current}, duration=${videoDuration}, mounted=${isMounted.current}`);
+ }
+ }, 1000);
+ }
+ completeOpeningAnimation();
+ }
+ };
+
+ const skip = (seconds: number) => {
+ if (videoRef.current) {
+ const newTime = Math.max(0, Math.min(currentTime + seconds, duration));
+ seekToTime(newTime);
+ }
+ };
+
+ const cycleAspectRatio = () => {
+ const newZoom = zoomScale === 1.1 ? 1 : 1.1;
+ setZoomScale(newZoom);
+ setZoomTranslateX(0);
+ setZoomTranslateY(0);
+ setLastZoomScale(newZoom);
+ setLastTranslateX(0);
+ setLastTranslateY(0);
+ };
+
+ const enableImmersiveMode = () => {
+ StatusBar.setHidden(true, 'none');
+ if (Platform.OS === 'android') {
+ try {
+ RNImmersiveMode.setBarMode('FullSticky');
+ RNImmersiveMode.fullLayout(true);
+ if (NativeModules.StatusBarManager) {
+ NativeModules.StatusBarManager.setHidden(true);
+ }
+ } catch (error) {
+ console.log('Immersive mode error:', error);
+ }
+ }
+ };
+
+ const disableImmersiveMode = () => {
+ StatusBar.setHidden(false);
+ if (Platform.OS === 'android') {
+ RNImmersiveMode.setBarMode('Normal');
+ RNImmersiveMode.fullLayout(false);
+ }
+ };
+
+ const handleClose = () => {
+ 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');
+
+ // 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(`[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);
+ } catch (error) {
+ logger.error('[AndroidVideoPlayer] Error resetting resume preference:', error);
+ }
+ };
+
+ 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);
+ }
+ };
+
+ 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);
+ }
+ }
+ setShowResumeOverlay(false);
+ setInitialPosition(0);
+ if (videoRef.current) {
+ seekToTime(0);
+ setCurrentTime(0);
+ }
+ };
+
+ const toggleControls = () => {
+ setShowControls(previousState => !previousState);
+ };
+
+ useEffect(() => {
+ Animated.timing(fadeAnim, {
+ toValue: showControls ? 1 : 0,
+ duration: 300,
+ useNativeDriver: true,
+ }).start();
+ }, [showControls]);
+
+ const handleError = (error: any) => {
+ logger.error('[AndroidVideoPlayer] Playback Error:', error);
+ };
+
+ const onBuffer = (data: any) => {
+ setIsBuffering(data.isBuffering);
+ };
+
+ const onEnd = () => {
+ // Sync final progress to Trakt
+ traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended');
+ };
+
+ const selectAudioTrack = (trackId: number) => {
+ setSelectedAudioTrack(trackId);
+ };
+
+ const selectTextTrack = (trackId: number) => {
+ if (trackId === -999) {
+ setUseCustomSubtitles(true);
+ setSelectedTextTrack(-1);
+ } else {
+ setUseCustomSubtitles(false);
+ setSelectedTextTrack(trackId);
+ }
+ };
+
+ const loadSubtitleSize = async () => {
+ try {
+ const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY);
+ if (savedSize) {
+ setSubtitleSize(parseInt(savedSize, 10));
+ }
+ } catch (error) {
+ logger.error('[AndroidVideoPlayer] Error loading subtitle size:', error);
+ }
+ };
+
+ const saveSubtitleSize = async (size: number) => {
+ try {
+ await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString());
+ setSubtitleSize(size);
+ } catch (error) {
+ logger.error('[AndroidVideoPlayer] Error saving subtitle size:', error);
+ }
+ };
+
+ const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => {
+ const targetImdbId = imdbIdParam || imdbId;
+ if (!targetImdbId) {
+ logger.error('[AndroidVideoPlayer] No IMDb ID available for subtitle search');
+ return;
+ }
+ setIsLoadingSubtitleList(true);
+ try {
+ let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`;
+ if (season && episode) {
+ searchUrl += `&season=${season}&episode=${episode}`;
+ }
+ const response = await fetch(searchUrl);
+ const subtitles: WyzieSubtitle[] = await response.json();
+ const uniqueSubtitles = subtitles.reduce((acc, current) => {
+ const exists = acc.find(item => item.language === current.language);
+ if (!exists) {
+ acc.push(current);
+ }
+ return acc;
+ }, [] as WyzieSubtitle[]);
+ uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display));
+ setAvailableSubtitles(uniqueSubtitles);
+ if (autoSelectEnglish) {
+ const englishSubtitle = uniqueSubtitles.find(sub =>
+ sub.language.toLowerCase() === 'eng' ||
+ sub.language.toLowerCase() === 'en' ||
+ sub.display.toLowerCase().includes('english')
+ );
+ if (englishSubtitle) {
+ loadWyzieSubtitle(englishSubtitle);
+ return;
+ }
+ }
+ if (!autoSelectEnglish) {
+ setShowSubtitleLanguageModal(true);
+ }
+ } catch (error) {
+ logger.error('[AndroidVideoPlayer] Error fetching subtitles from Wyzie API:', error);
+ } finally {
+ setIsLoadingSubtitleList(false);
+ }
+ };
+
+ const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => {
+ setShowSubtitleLanguageModal(false);
+ setIsLoadingSubtitles(true);
+ try {
+ const response = await fetch(subtitle.url);
+ const srtContent = await response.text();
+ const parsedCues = parseSRT(srtContent);
+ setCustomSubtitles(parsedCues);
+ setUseCustomSubtitles(true);
+ setSelectedTextTrack(-1);
+ } catch (error) {
+ logger.error('[AndroidVideoPlayer] Error loading Wyzie subtitle:', error);
+ } finally {
+ setIsLoadingSubtitles(false);
+ }
+ };
+
+ const togglePlayback = () => {
+ if (videoRef.current) {
+ setPaused(!paused);
+ }
+ };
+
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ if (seekDebounceTimer.current) {
+ clearTimeout(seekDebounceTimer.current);
+ }
+ };
+ }, []);
+
+ const safeSetState = (setter: any) => {
+ if (isMounted.current) {
+ setter();
+ }
+ };
+
+ useEffect(() => {
+ if (!useCustomSubtitles || customSubtitles.length === 0) {
+ if (currentSubtitle !== '') {
+ setCurrentSubtitle('');
+ }
+ return;
+ }
+ const currentCue = customSubtitles.find(cue =>
+ currentTime >= cue.start && currentTime <= cue.end
+ );
+ const newSubtitle = currentCue ? currentCue.text : '';
+ setCurrentSubtitle(newSubtitle);
+ }, [currentTime, customSubtitles, useCustomSubtitles]);
+
+ useEffect(() => {
+ loadSubtitleSize();
+ }, []);
+
+ const increaseSubtitleSize = () => {
+ const newSize = Math.min(subtitleSize + 2, 32);
+ saveSubtitleSize(newSize);
+ };
+
+ const decreaseSubtitleSize = () => {
+ const newSize = Math.max(subtitleSize - 2, 8);
+ saveSubtitleSize(newSize);
+ };
+
+ 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`);
+
+ if (pendingSeek.position > 0 && videoRef.current) {
+ const delayTime = 800; // Shorter delay for react-native-video
+
+ setTimeout(() => {
+ if (videoRef.current && duration > 0 && pendingSeek) {
+ logger.log(`[AndroidVideoPlayer] Executing seek to ${pendingSeek.position}s`);
+
+ seekToTime(pendingSeek.position);
+
+ if (pendingSeek.shouldPlay) {
+ setTimeout(() => {
+ logger.log('[AndroidVideoPlayer] Resuming playback after source change seek');
+ setPaused(false);
+ }, 300);
+ }
+
+ setTimeout(() => {
+ setPendingSeek(null);
+ setIsChangingSource(false);
+ }, 400);
+ }
+ }, delayTime);
+ } else {
+ // No seeking needed, just resume playback if it was playing
+ if (pendingSeek.shouldPlay) {
+ setTimeout(() => {
+ logger.log('[AndroidVideoPlayer] No seek needed, just resuming playback');
+ setPaused(false);
+ }, 300);
+ }
+
+ setTimeout(() => {
+ setPendingSeek(null);
+ setIsChangingSource(false);
+ }, 400);
+ }
+ }
+ }, [pendingSeek, isPlayerReady, isVideoLoaded, duration]);
+
+ const handleSelectStream = async (newStream: any) => {
+ if (newStream.url === currentStreamUrl) {
+ setShowSourcesModal(false);
+ return;
+ }
+
+ setIsChangingSource(true);
+ setShowSourcesModal(false);
+
+ try {
+ // Save current state
+ const savedPosition = currentTime;
+ const wasPlaying = !paused;
+
+ logger.log(`[AndroidVideoPlayer] Changing source from ${currentStreamUrl} to ${newStream.url}`);
+ logger.log(`[AndroidVideoPlayer] Saved position: ${savedPosition}, was playing: ${wasPlaying}`);
+
+ // Extract quality and provider information from the new stream
+ let newQuality = newStream.quality;
+ if (!newQuality && newStream.title) {
+ // Try to extract quality from title (e.g., "1080p", "720p")
+ const qualityMatch = newStream.title.match(/(\d+)p/);
+ newQuality = qualityMatch ? qualityMatch[0] : undefined;
+ }
+
+ // 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(`[AndroidVideoPlayer] Stream object:`, newStream);
+ logger.log(`[AndroidVideoPlayer] Extracted - Quality: ${newQuality}, Provider: ${newProvider}, Stream Name: ${newStreamName}`);
+
+ // Stop current playback
+ 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('[AndroidVideoPlayer] Error changing source:', error);
+ setPendingSeek(null);
+ setIsChangingSource(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Loading video...
+
+
+
+ {/* Source Change Loading Overlay */}
+ {isChangingSource && (
+
+
+
+ Changing source...
+ Please wait while we load the new stream
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AndroidVideoPlayer;
\ No newline at end of file
diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx
new file mode 100644
index 0000000..f5d2857
--- /dev/null
+++ b/src/components/player/VideoPlayer.tsx
@@ -0,0 +1,1148 @@
+import React, { useState, useRef, useEffect } from 'react';
+import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text } 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 { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
+import RNImmersiveMode from 'react-native-immersive-mode';
+import * as ScreenOrientation from 'expo-screen-orientation';
+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 AndroidVideoPlayer from './AndroidVideoPlayer';
+import { useTraktAutosync } from '../../hooks/useTraktAutosync';
+
+import {
+ DEFAULT_SUBTITLE_SIZE,
+ AudioTrack,
+ TextTrack,
+ ResizeModeType,
+ WyzieSubtitle,
+ SubtitleCue,
+ RESUME_PREF_KEY,
+ RESUME_PREF,
+ SUBTITLE_SIZE_KEY
+} 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 ResumeOverlay from './modals/ResumeOverlay';
+import PlayerControls from './controls/PlayerControls';
+import CustomSubtitles from './subtitles/CustomSubtitles';
+import SourcesModal from './modals/SourcesModal';
+
+const VideoPlayer: React.FC = () => {
+ // If on Android, use the AndroidVideoPlayer component
+ if (Platform.OS === 'android') {
+ return ;
+ }
+
+ const navigation = useNavigation();
+ const route = useRoute>();
+
+ const {
+ uri,
+ title = 'Episode Name',
+ season,
+ episode,
+ episodeTitle,
+ quality,
+ year,
+ streamProvider,
+ streamName,
+ id,
+ type,
+ episodeId,
+ imdbId,
+ availableStreams: passedAvailableStreams
+ } = route.params;
+
+ // Initialize Trakt autosync
+ const traktAutosync = useTraktAutosync({
+ id: id || '',
+ type: type === 'series' ? 'series' : 'movie',
+ title: episodeTitle || title,
+ year: year || 0,
+ imdbId: imdbId || '',
+ season: season,
+ episode: episode,
+ showTitle: title,
+ showYear: year,
+ showImdbId: imdbId,
+ episodeId: episodeId
+ });
+
+ safeDebugLog("Component mounted with props", {
+ uri, title, season, episode, episodeTitle, quality, year,
+ streamProvider, id, type, episodeId, imdbId
+ });
+
+ const screenData = Dimensions.get('screen');
+ const [screenDimensions, setScreenDimensions] = useState(screenData);
+
+ const [paused, setPaused] = useState(false);
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [showControls, setShowControls] = useState(true);
+ const [audioTracks, setAudioTracks] = useState([]);
+ const [selectedAudioTrack, setSelectedAudioTrack] = useState(null);
+ const [textTracks, setTextTracks] = useState([]);
+ const [selectedTextTrack, setSelectedTextTrack] = useState(-1);
+ const [resizeMode, setResizeMode] = useState('stretch');
+ const [buffered, setBuffered] = useState(0);
+ const [seekPosition, setSeekPosition] = useState(null);
+ const vlcRef = useRef(null);
+ const [showAudioModal, setShowAudioModal] = useState(false);
+ const [showSubtitleModal, setShowSubtitleModal] = useState(false);
+ const [initialPosition, setInitialPosition] = useState(null);
+ const [progressSaveInterval, setProgressSaveInterval] = useState(null);
+ const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
+ const [showResumeOverlay, setShowResumeOverlay] = useState(false);
+ const [resumePosition, setResumePosition] = useState(null);
+ const [rememberChoice, setRememberChoice] = useState(false);
+ const [resumePreference, setResumePreference] = useState(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>([]);
+ const [vlcTextTracks, setVlcTextTracks] = useState>([]);
+ const [isPlayerReady, setIsPlayerReady] = useState(false);
+ const progressAnim = useRef(new Animated.Value(0)).current;
+ const progressBarRef = useRef(null);
+ const [isDragging, setIsDragging] = useState(false);
+ const isSeeking = useRef(false);
+ const seekDebounceTimer = useRef(null);
+ const pendingSeekValue = useRef(null);
+ const lastSeekTime = useRef(0);
+ const [isVideoLoaded, setIsVideoLoaded] = useState(false);
+ const [videoAspectRatio, setVideoAspectRatio] = useState(null);
+ const [is16by9Content, setIs16by9Content] = useState(false);
+ const [customVideoStyles, setCustomVideoStyles] = useState({});
+ const [zoomScale, setZoomScale] = useState(1);
+ const [zoomTranslateX, setZoomTranslateX] = useState(0);
+ const [zoomTranslateY, setZoomTranslateY] = useState(0);
+ const [lastZoomScale, setLastZoomScale] = useState(1);
+ const [lastTranslateX, setLastTranslateX] = useState(0);
+ const [lastTranslateY, setLastTranslateY] = useState(0);
+ const pinchRef = useRef(null);
+ const [customSubtitles, setCustomSubtitles] = useState([]);
+ const [currentSubtitle, setCurrentSubtitle] = useState('');
+ const [subtitleSize, setSubtitleSize] = useState(DEFAULT_SUBTITLE_SIZE);
+ const [useCustomSubtitles, setUseCustomSubtitles] = useState(false);
+ const [isLoadingSubtitles, setIsLoadingSubtitles] = useState(false);
+ const [availableSubtitles, setAvailableSubtitles] = useState([]);
+ const [showSubtitleLanguageModal, setShowSubtitleLanguageModal] = useState(false);
+ const [isLoadingSubtitleList, setIsLoadingSubtitleList] = useState(false);
+ const [showSourcesModal, setShowSourcesModal] = useState(false);
+ const [availableStreams, setAvailableStreams] = useState<{ [providerId: string]: { streams: any[]; addonName: string } }>(passedAvailableStreams || {});
+ const [currentStreamUrl, setCurrentStreamUrl] = useState(uri);
+ const [isChangingSource, setIsChangingSource] = useState(false);
+ const [pendingSeek, setPendingSeek] = useState<{ position: number; shouldPlay: boolean } | null>(null);
+ const [currentQuality, setCurrentQuality] = useState(quality);
+ const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider);
+ const [currentStreamName, setCurrentStreamName] = useState(streamName);
+ const isMounted = useRef(true);
+
+ const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => {
+ return {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ width: screenWidth,
+ height: screenHeight,
+ };
+ };
+
+ const onPinchGestureEvent = (event: PinchGestureHandlerGestureEvent) => {
+ const { scale } = event.nativeEvent;
+ const newScale = Math.max(1, Math.min(lastZoomScale * scale, 1.1));
+ setZoomScale(newScale);
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] Center Zoom: ${newScale.toFixed(2)}x`);
+ }
+ };
+
+ const onPinchHandlerStateChange = (event: PinchGestureHandlerGestureEvent) => {
+ if (event.nativeEvent.state === State.END) {
+ setLastZoomScale(zoomScale);
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] Pinch ended - saved scale: ${zoomScale.toFixed(2)}x`);
+ }
+ }
+ };
+
+ const resetZoom = () => {
+ const targetZoom = is16by9Content ? 1.1 : 1;
+ setZoomScale(targetZoom);
+ setLastZoomScale(targetZoom);
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] Zoom reset to ${targetZoom}x (16:9: ${is16by9Content})`);
+ }
+ };
+
+ useEffect(() => {
+ if (videoAspectRatio && screenDimensions.width > 0 && screenDimensions.height > 0) {
+ const styles = calculateVideoStyles(
+ videoAspectRatio * 1000,
+ 1000,
+ screenDimensions.width,
+ screenDimensions.height
+ );
+ setCustomVideoStyles(styles);
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] Screen dimensions changed, recalculated styles:`, styles);
+ }
+ }
+ }, [screenDimensions, videoAspectRatio]);
+
+ useEffect(() => {
+ const subscription = Dimensions.addEventListener('change', ({ screen }) => {
+ setScreenDimensions(screen);
+ });
+ const initializePlayer = () => {
+ StatusBar.setHidden(true, 'none');
+ enableImmersiveMode();
+ startOpeningAnimation();
+ };
+ initializePlayer();
+ return () => {
+ subscription?.remove();
+ const unlockOrientation = async () => {
+ await ScreenOrientation.unlockAsync();
+ };
+ unlockOrientation();
+ disableImmersiveMode();
+ };
+ }, []);
+
+ const startOpeningAnimation = () => {
+ // Animation logic here
+ };
+
+ const completeOpeningAnimation = () => {
+ Animated.parallel([
+ Animated.timing(openingFadeAnim, {
+ toValue: 1,
+ duration: 600,
+ useNativeDriver: true,
+ }),
+ Animated.timing(openingScaleAnim, {
+ toValue: 1,
+ duration: 700,
+ useNativeDriver: true,
+ }),
+ Animated.timing(backgroundFadeAnim, {
+ toValue: 0,
+ duration: 800,
+ useNativeDriver: true,
+ }),
+ ]).start(() => {
+ openingScaleAnim.setValue(1);
+ openingFadeAnim.setValue(1);
+ setIsOpeningAnimationComplete(true);
+ setTimeout(() => {
+ backgroundFadeAnim.setValue(0);
+ }, 100);
+ });
+ };
+
+ useEffect(() => {
+ const loadWatchProgress = async () => {
+ if (id && type) {
+ try {
+ 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`);
+ }
+ } else {
+ logger.log(`[VideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`);
+ }
+ } else {
+ logger.log(`[VideoPlayer] No saved progress found`);
+ }
+ } catch (error) {
+ logger.error('[VideoPlayer] Error loading watch progress:', error);
+ }
+ } else {
+ logger.log(`[VideoPlayer] Missing id or type: id=${id}, type=${type}`);
+ }
+ };
+ loadWatchProgress();
+ }, [id, type, episodeId]);
+
+ const saveWatchProgress = async () => {
+ if (id && type && currentTime > 0 && duration > 0) {
+ const progress = {
+ currentTime,
+ duration,
+ lastUpdated: Date.now()
+ };
+ try {
+ await storageService.setWatchProgress(id, type, progress, episodeId);
+
+ // Sync to Trakt if authenticated
+ await traktAutosync.handleProgressUpdate(currentTime, duration);
+ } catch (error) {
+ logger.error('[VideoPlayer] Error saving watch progress:', error);
+ }
+ }
+ };
+
+ useEffect(() => {
+ if (id && type && !paused && duration > 0) {
+ if (progressSaveInterval) {
+ clearInterval(progressSaveInterval);
+ }
+ const interval = setInterval(() => {
+ saveWatchProgress();
+ }, 5000);
+ setProgressSaveInterval(interval);
+ return () => {
+ clearInterval(interval);
+ setProgressSaveInterval(null);
+ };
+ }
+ }, [id, type, paused, currentTime, duration]);
+
+ useEffect(() => {
+ return () => {
+ if (id && type && duration > 0) {
+ saveWatchProgress();
+ // Final Trakt sync on component unmount
+ traktAutosync.handlePlaybackEnd(currentTime, duration, 'unmount');
+ }
+ };
+ }, [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);
+ }
+ }
+ };
+
+ const onPaused = () => {
+ if (isMounted.current) {
+ setPaused(true);
+ }
+ };
+
+ const seekToTime = (timeInSeconds: number) => {
+ if (vlcRef.current && duration > 0 && !isSeeking.current) {
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.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;
+ }
+ }, 300);
+ } else {
+ // iOS fallback - use seek prop
+ const position = timeInSeconds / duration;
+ setSeekPosition(position);
+
+ setTimeout(() => {
+ if (isMounted.current) {
+ setSeekPosition(null);
+ isSeeking.current = false;
+ }
+ }, 500);
+ }
+ } else {
+ if (DEBUG_MODE) {
+ logger.error('[VideoPlayer] Seek failed: Player not ready, duration is zero, or already seeking.');
+ }
+ }
+ };
+
+ const handleProgressBarTouch = (event: any) => {
+ if (duration > 0) {
+ const { locationX } = event.nativeEvent;
+ 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) {
+ seekToTime(pendingSeekValue.current);
+ 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;
+ progressAnim.setValue(percentage);
+ if (isDragging) {
+ pendingSeekValue.current = seekTime;
+ setCurrentTime(seekTime);
+ } else {
+ seekToTime(seekTime);
+ }
+ });
+ };
+
+ 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));
+ const progressPercent = duration > 0 ? currentTimeInSeconds / duration : 0;
+ Animated.timing(progressAnim, {
+ toValue: progressPercent,
+ duration: 250,
+ useNativeDriver: false,
+ }).start();
+ const bufferedTime = event.bufferTime / 1000 || currentTimeInSeconds;
+ safeSetState(() => setBuffered(bufferedTime));
+ }
+ };
+
+ const onLoad = (data: any) => {
+ if (DEBUG_MODE) {
+ logger.log('[VideoPlayer] Video loaded:', data);
+ }
+ if (isMounted.current) {
+ const videoDuration = data.duration / 1000;
+ if (data.duration > 0) {
+ setDuration(videoDuration);
+ }
+ setVideoAspectRatio(data.videoSize.width / data.videoSize.height);
+
+ if (data.audioTracks && data.audioTracks.length > 0) {
+ setVlcAudioTracks(data.audioTracks);
+ }
+ if (data.textTracks && data.textTracks.length > 0) {
+ setVlcTextTracks(data.textTracks);
+ }
+
+ 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(() => {
+ if (vlcRef.current && videoDuration > 0 && isMounted.current) {
+ seekToTime(initialPosition);
+ setIsInitialSeekComplete(true);
+ logger.log(`[VideoPlayer] Initial seek completed to: ${initialPosition}s`);
+ } else {
+ logger.error(`[VideoPlayer] Initial seek failed: vlcRef=${!!vlcRef.current}, duration=${videoDuration}, mounted=${isMounted.current}`);
+ }
+ }, 1000);
+ }
+ completeOpeningAnimation();
+ }
+ };
+
+ const skip = (seconds: number) => {
+ if (vlcRef.current) {
+ const newTime = Math.max(0, Math.min(currentTime + seconds, duration));
+ seekToTime(newTime);
+ }
+ };
+
+ const onAudioTracks = (data: { audioTracks: AudioTrack[] }) => {
+ setAudioTracks(data.audioTracks || []);
+ };
+
+ const onTextTracks = (e: Readonly<{ textTracks: TextTrack[] }>) => {
+ setTextTracks(e.textTracks || []);
+ };
+
+ const cycleAspectRatio = () => {
+ const newZoom = zoomScale === 1.1 ? 1 : 1.1;
+ setZoomScale(newZoom);
+ setZoomTranslateX(0);
+ setZoomTranslateY(0);
+ setLastZoomScale(newZoom);
+ setLastTranslateX(0);
+ setLastTranslateY(0);
+ };
+
+ const enableImmersiveMode = () => {
+ StatusBar.setHidden(true, 'none');
+ if (Platform.OS === 'android') {
+ try {
+ RNImmersiveMode.setBarMode('FullSticky');
+ RNImmersiveMode.fullLayout(true);
+ if (NativeModules.StatusBarManager) {
+ NativeModules.StatusBarManager.setHidden(true);
+ }
+ } catch (error) {
+ console.log('Immersive mode error:', error);
+ }
+ }
+ };
+
+ const disableImmersiveMode = () => {
+ StatusBar.setHidden(false);
+ if (Platform.OS === 'android') {
+ RNImmersiveMode.setBarMode('Normal');
+ RNImmersiveMode.fullLayout(false);
+ }
+ };
+
+ const handleClose = () => {
+ 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]);
+
+ const resetResumePreference = async () => {
+ try {
+ await AsyncStorage.removeItem(RESUME_PREF_KEY);
+ setResumePreference(null);
+ } catch (error) {
+ logger.error('[VideoPlayer] Error resetting resume preference:', error);
+ }
+ };
+
+ 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);
+ }
+ };
+
+ 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);
+ }
+ }
+ setShowResumeOverlay(false);
+ setInitialPosition(0);
+ if (vlcRef.current) {
+ seekToTime(0);
+ setCurrentTime(0);
+ }
+ };
+
+ const toggleControls = () => {
+ setShowControls(previousState => !previousState);
+ };
+
+ useEffect(() => {
+ Animated.timing(fadeAnim, {
+ toValue: showControls ? 1 : 0,
+ duration: 300,
+ useNativeDriver: true,
+ }).start();
+ }, [showControls]);
+
+ const handleError = (error: any) => {
+ logger.error('[VideoPlayer] Playback Error:', error);
+ };
+
+ const onBuffering = (event: any) => {
+ setIsBuffering(event.isBuffering);
+ };
+
+ const onEnd = () => {
+ // Sync final progress to Trakt
+ traktAutosync.handlePlaybackEnd(currentTime, duration, 'ended');
+ };
+
+ const selectAudioTrack = (trackId: number) => {
+ setSelectedAudioTrack(trackId);
+ };
+
+ const selectTextTrack = (trackId: number) => {
+ if (trackId === -999) {
+ setUseCustomSubtitles(true);
+ setSelectedTextTrack(-1);
+ } else {
+ setUseCustomSubtitles(false);
+ setSelectedTextTrack(trackId);
+ }
+ };
+
+ const loadSubtitleSize = async () => {
+ try {
+ const savedSize = await AsyncStorage.getItem(SUBTITLE_SIZE_KEY);
+ if (savedSize) {
+ setSubtitleSize(parseInt(savedSize, 10));
+ }
+ } catch (error) {
+ logger.error('[VideoPlayer] Error loading subtitle size:', error);
+ }
+ };
+
+ const saveSubtitleSize = async (size: number) => {
+ try {
+ await AsyncStorage.setItem(SUBTITLE_SIZE_KEY, size.toString());
+ setSubtitleSize(size);
+ } catch (error) {
+ logger.error('[VideoPlayer] Error saving subtitle size:', error);
+ }
+ };
+
+ const fetchAvailableSubtitles = async (imdbIdParam?: string, autoSelectEnglish = false) => {
+ const targetImdbId = imdbIdParam || imdbId;
+ if (!targetImdbId) {
+ logger.error('[VideoPlayer] No IMDb ID available for subtitle search');
+ return;
+ }
+ setIsLoadingSubtitleList(true);
+ try {
+ let searchUrl = `https://sub.wyzie.ru/search?id=${targetImdbId}&encoding=utf-8&source=all`;
+ if (season && episode) {
+ searchUrl += `&season=${season}&episode=${episode}`;
+ }
+ const response = await fetch(searchUrl);
+ const subtitles: WyzieSubtitle[] = await response.json();
+ const uniqueSubtitles = subtitles.reduce((acc, current) => {
+ const exists = acc.find(item => item.language === current.language);
+ if (!exists) {
+ acc.push(current);
+ }
+ return acc;
+ }, [] as WyzieSubtitle[]);
+ uniqueSubtitles.sort((a, b) => a.display.localeCompare(b.display));
+ setAvailableSubtitles(uniqueSubtitles);
+ if (autoSelectEnglish) {
+ const englishSubtitle = uniqueSubtitles.find(sub =>
+ sub.language.toLowerCase() === 'eng' ||
+ sub.language.toLowerCase() === 'en' ||
+ sub.display.toLowerCase().includes('english')
+ );
+ if (englishSubtitle) {
+ loadWyzieSubtitle(englishSubtitle);
+ return;
+ }
+ }
+ if (!autoSelectEnglish) {
+ setShowSubtitleLanguageModal(true);
+ }
+ } catch (error) {
+ logger.error('[VideoPlayer] Error fetching subtitles from Wyzie API:', error);
+ } finally {
+ setIsLoadingSubtitleList(false);
+ }
+ };
+
+ const loadWyzieSubtitle = async (subtitle: WyzieSubtitle) => {
+ setShowSubtitleLanguageModal(false);
+ setIsLoadingSubtitles(true);
+ try {
+ const response = await fetch(subtitle.url);
+ const srtContent = await response.text();
+ const parsedCues = parseSRT(srtContent);
+ setCustomSubtitles(parsedCues);
+ setUseCustomSubtitles(true);
+ setSelectedTextTrack(-1);
+ } catch (error) {
+ logger.error('[VideoPlayer] Error loading Wyzie subtitle:', error);
+ } finally {
+ setIsLoadingSubtitles(false);
+ }
+ };
+
+ const togglePlayback = () => {
+ if (vlcRef.current) {
+ setPaused(!paused);
+ }
+ };
+
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ if (seekDebounceTimer.current) {
+ clearTimeout(seekDebounceTimer.current);
+ }
+ };
+ }, []);
+
+ const safeSetState = (setter: any) => {
+ if (isMounted.current) {
+ setter();
+ }
+ };
+
+ useEffect(() => {
+ if (!useCustomSubtitles || customSubtitles.length === 0) {
+ if (currentSubtitle !== '') {
+ setCurrentSubtitle('');
+ }
+ return;
+ }
+ const currentCue = customSubtitles.find(cue =>
+ currentTime >= cue.start && currentTime <= cue.end
+ );
+ const newSubtitle = currentCue ? currentCue.text : '';
+ setCurrentSubtitle(newSubtitle);
+ }, [currentTime, customSubtitles, useCustomSubtitles]);
+
+ useEffect(() => {
+ loadSubtitleSize();
+ }, []);
+
+ const increaseSubtitleSize = () => {
+ const newSize = Math.min(subtitleSize + 2, 32);
+ saveSubtitleSize(newSize);
+ };
+
+ const decreaseSubtitleSize = () => {
+ const newSize = Math.max(subtitleSize - 2, 8);
+ saveSubtitleSize(newSize);
+ };
+
+ 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);
+ }, 900);
+ }
+ }, delayTime);
+ } else {
+ // No seeking needed, just resume playback if it was playing
+ if (pendingSeek.shouldPlay) {
+ setTimeout(() => {
+ logger.log('[VideoPlayer] No seek needed, just resuming playback');
+ setPaused(false);
+ }, 500);
+ }
+
+ setTimeout(() => {
+ setPendingSeek(null);
+ setIsChangingSource(false);
+ }, 600);
+ }
+ }
+ }, [pendingSeek, isPlayerReady, isVideoLoaded, duration]);
+
+ const handleSelectStream = async (newStream: any) => {
+ if (newStream.url === currentStreamUrl) {
+ setShowSourcesModal(false);
+ return;
+ }
+
+ 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) {
+ // Try to extract quality from title (e.g., "1080p", "720p")
+ 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);
+ setIsChangingSource(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Loading video...
+
+
+
+ {/* Source Change Loading Overlay */}
+ {isChangingSource && (
+
+
+
+ Changing source...
+ Please wait while we load the new stream
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+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
new file mode 100644
index 0000000..3cc6054
--- /dev/null
+++ b/src/components/player/controls/PlayerControls.tsx
@@ -0,0 +1,228 @@
+import React from 'react';
+import { View, Text, TouchableOpacity, Animated, StyleSheet } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { LinearGradient } from 'expo-linear-gradient';
+import { styles } from '../utils/playerStyles';
+import { getTrackDisplayName } from '../utils/playerUtils';
+
+interface PlayerControlsProps {
+ showControls: boolean;
+ fadeAnim: Animated.Value;
+ paused: boolean;
+ title: string;
+ episodeTitle?: string;
+ season?: number;
+ episode?: number;
+ quality?: string;
+ year?: number;
+ streamProvider?: string;
+ streamName?: string;
+ currentTime: number;
+ duration: number;
+ zoomScale: number;
+ vlcAudioTracks: Array<{id: number, name: string, language?: string}>;
+ selectedAudioTrack: number | null;
+ availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
+ togglePlayback: () => void;
+ skip: (seconds: number) => void;
+ handleClose: () => void;
+ cycleAspectRatio: () => void;
+ setShowAudioModal: (show: boolean) => void;
+ setShowSubtitleModal: (show: boolean) => void;
+ setShowSourcesModal?: (show: boolean) => void;
+ progressBarRef: React.RefObject;
+ progressAnim: Animated.Value;
+ handleProgressBarTouch: (event: any) => void;
+ handleProgressBarDragStart: () => void;
+ handleProgressBarDragMove: (event: any) => void;
+ handleProgressBarDragEnd: () => void;
+ buffered: number;
+ formatTime: (seconds: number) => string;
+}
+
+export const PlayerControls: React.FC = ({
+ showControls,
+ fadeAnim,
+ paused,
+ title,
+ episodeTitle,
+ season,
+ episode,
+ quality,
+ year,
+ streamProvider,
+ streamName,
+ currentTime,
+ duration,
+ zoomScale,
+ vlcAudioTracks,
+ selectedAudioTrack,
+ availableStreams,
+ togglePlayback,
+ skip,
+ handleClose,
+ cycleAspectRatio,
+ setShowAudioModal,
+ setShowSubtitleModal,
+ setShowSourcesModal,
+ progressBarRef,
+ progressAnim,
+ handleProgressBarTouch,
+ handleProgressBarDragStart,
+ handleProgressBarDragMove,
+ handleProgressBarDragEnd,
+ buffered,
+ formatTime,
+}) => {
+ return (
+
+ {/* Progress bar with enhanced touch handling */}
+
+
+
+
+ {/* Buffered Progress */}
+
+ {/* Animated Progress */}
+
+
+
+
+
+ {formatTime(currentTime)}
+ {formatTime(duration)}
+
+
+
+ {/* Controls Overlay */}
+
+ {/* Top Gradient & Header */}
+
+
+ {/* Title Section - Enhanced with metadata */}
+
+ {title}
+ {/* Show season and episode for series */}
+ {season && episode && (
+
+ S{season}E{episode} {episodeTitle && `• ${episodeTitle}`}
+
+ )}
+ {/* Show year, quality, and provider */}
+
+ {year && {year}}
+ {quality && {quality}}
+ {streamName && via {streamName}}
+
+
+
+
+
+
+
+
+ {/* Center Controls (Play/Pause, Skip) */}
+
+ skip(-10)} style={styles.skipButton}>
+
+ 10
+
+
+
+
+ skip(10)} style={styles.skipButton}>
+
+ 10
+
+
+
+ {/* Bottom Gradient */}
+
+
+ {/* Bottom Buttons Row */}
+
+ {/* Fill/Cover Button - Updated to show fill/cover modes */}
+
+
+
+ {zoomScale === 1.1 ? 'Fill' : 'Cover'}
+
+
+
+ {/* Audio Button - Updated to use vlcAudioTracks */}
+ setShowAudioModal(true)}
+ disabled={vlcAudioTracks.length <= 1}
+ >
+
+
+ {`Audio: ${getTrackDisplayName(vlcAudioTracks.find(t => t.id === selectedAudioTrack) || {id: -1, name: 'Default'})}`}
+
+
+
+ {/* Subtitle Button - Always available for external subtitle search */}
+ setShowSubtitleModal(true)}
+ >
+
+
+ Subtitles
+
+
+
+ {/* Change Source Button */}
+ {setShowSourcesModal && (
+ setShowSourcesModal(true)}
+ >
+
+
+ Change Source
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default PlayerControls;
\ No newline at end of file
diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx
new file mode 100644
index 0000000..63805e1
--- /dev/null
+++ b/src/components/player/modals/AudioTrackModal.tsx
@@ -0,0 +1,444 @@
+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 Animated, {
+ FadeIn,
+ FadeOut,
+ SlideInDown,
+ SlideOutDown,
+ FadeInDown,
+ FadeInUp,
+ Layout,
+ withSpring,
+ withTiming,
+ useAnimatedStyle,
+ useSharedValue,
+ interpolate,
+ Easing,
+ withDelay,
+ withSequence,
+ runOnJS,
+ BounceIn,
+ ZoomIn
+} from 'react-native-reanimated';
+import { LinearGradient } from 'expo-linear-gradient';
+import { styles } from '../utils/playerStyles';
+import { getTrackDisplayName } from '../utils/playerUtils';
+
+interface AudioTrackModalProps {
+ showAudioModal: boolean;
+ setShowAudioModal: (show: boolean) => void;
+ vlcAudioTracks: Array<{id: number, name: string, language?: string}>;
+ selectedAudioTrack: number | null;
+ 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;
+}) => (
+
+ {icon && (
+
+ )}
+
+ {text}
+
+
+);
+
+export const AudioTrackModal: React.FC = ({
+ showAudioModal,
+ setShowAudioModal,
+ vlcAudioTracks,
+ 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);
+ };
+
+ if (!showAudioModal) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal Content */}
+
+ {/* Glassmorphism Background */}
+
+ {/* Header */}
+
+
+
+ Audio Tracks
+
+
+ Choose from {vlcAudioTracks.length} available track{vlcAudioTracks.length !== 1 ? 's' : ''}
+
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+
+ {vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => (
+
+ {
+ selectAudioTrack(track.id);
+ handleClose();
+ }}
+ activeOpacity={0.85}
+ >
+
+
+
+
+ {getTrackDisplayName(track)}
+
+
+ {selectedAudioTrack === track.id && (
+
+
+
+ ACTIVE
+
+
+ )}
+
+
+
+
+ {track.language && (
+
+ )}
+
+
+
+
+ {selectedAudioTrack === track.id ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ )) : (
+
+
+
+ No audio tracks found
+
+
+ No audio tracks are available for this content.{'\n'}Try a different source or check your connection.
+
+
+ )}
+
+
+
+
+
+ );
+};
+
+export default AudioTrackModal;
\ No newline at end of file
diff --git a/src/components/player/modals/ResumeOverlay.tsx b/src/components/player/modals/ResumeOverlay.tsx
new file mode 100644
index 0000000..0945165
--- /dev/null
+++ b/src/components/player/modals/ResumeOverlay.tsx
@@ -0,0 +1,125 @@
+import React, { useEffect } from 'react';
+import { View, Text, TouchableOpacity } from 'react-native';
+import { Ionicons } from '@expo/vector-icons';
+import { LinearGradient } from 'expo-linear-gradient';
+import { styles } from '../utils/playerStyles';
+import { formatTime } from '../utils/playerUtils';
+import { logger } from '../../../utils/logger';
+
+interface ResumeOverlayProps {
+ showResumeOverlay: boolean;
+ resumePosition: number | null;
+ duration: number;
+ title: string;
+ season?: number;
+ episode?: number;
+ rememberChoice: boolean;
+ setRememberChoice: (remember: boolean) => void;
+ resumePreference: string | null;
+ resetResumePreference: () => void;
+ handleResume: () => void;
+ handleStartFromBeginning: () => void;
+}
+
+export const ResumeOverlay: React.FC = ({
+ showResumeOverlay,
+ resumePosition,
+ duration,
+ title,
+ season,
+ episode,
+ rememberChoice,
+ setRememberChoice,
+ resumePreference,
+ resetResumePreference,
+ handleResume,
+ handleStartFromBeginning,
+}) => {
+ useEffect(() => {
+ logger.log(`[ResumeOverlay] Props changed: showOverlay=${showResumeOverlay}, resumePosition=${resumePosition}, duration=${duration}, title=${title}`);
+ }, [showResumeOverlay, resumePosition, duration, title]);
+
+ if (!showResumeOverlay || resumePosition === null) {
+ logger.log(`[ResumeOverlay] Not showing overlay: showOverlay=${showResumeOverlay}, resumePosition=${resumePosition}`);
+ return null;
+ }
+
+ logger.log(`[ResumeOverlay] Rendering overlay for ${title} at ${resumePosition}s`);
+
+ return (
+
+
+
+
+
+
+
+ Continue Watching
+
+ {title}
+ {season && episode && ` • S${season}E${episode}`}
+
+
+
+ 0 ? (resumePosition / duration) * 100 : 0}%` }
+ ]}
+ />
+
+
+ {formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''}
+
+
+
+
+
+ {/* Remember choice checkbox */}
+ setRememberChoice(!rememberChoice)}
+ activeOpacity={0.7}
+ >
+
+
+ {rememberChoice && }
+
+ Remember my choice
+
+
+ {resumePreference && (
+
+ Reset
+
+ )}
+
+
+
+
+
+ Start Over
+
+
+
+ Resume
+
+
+
+
+ );
+};
+
+export default ResumeOverlay;
\ No newline at end of file
diff --git a/src/components/player/modals/SourcesModal.tsx b/src/components/player/modals/SourcesModal.tsx
new file mode 100644
index 0000000..0c232e6
--- /dev/null
+++ b/src/components/player/modals/SourcesModal.tsx
@@ -0,0 +1,657 @@
+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 Animated, {
+ FadeIn,
+ FadeOut,
+ SlideInDown,
+ SlideOutDown,
+ FadeInDown,
+ FadeInUp,
+ Layout,
+ withSpring,
+ withTiming,
+ useAnimatedStyle,
+ useSharedValue,
+ interpolate,
+ Easing,
+ withDelay,
+ withSequence,
+ runOnJS,
+ BounceIn,
+ ZoomIn
+} 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;
+ setShowSourcesModal: (show: boolean) => void;
+ availableStreams: { [providerId: string]: { streams: Stream[]; addonName: string } };
+ currentStreamUrl: string;
+ onSelectStream: (stream: Stream) => void;
+ isChangingSource: boolean;
+}
+
+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 QualityIndicator = ({ quality }: { quality: string | null }) => {
+ if (!quality) return null;
+
+ const qualityNum = parseInt(quality);
+ let color = '#8B5CF6'; // Default purple
+ let label = `${quality}p`;
+
+ if (qualityNum >= 2160) {
+ color = '#F59E0B'; // Gold for 4K
+ label = '4K';
+ } else if (qualityNum >= 1080) {
+ color = '#EF4444'; // Red for 1080p
+ label = 'FHD';
+ } else if (qualityNum >= 720) {
+ color = '#10B981'; // Green for 720p
+ label = 'HD';
+ }
+
+ return (
+
+
+
+ {label}
+
+
+ );
+};
+
+const StreamMetaBadge = ({
+ text,
+ color,
+ bgColor,
+ icon,
+ delay = 0
+}: {
+ text: string;
+ color: string;
+ bgColor: string;
+ icon?: string;
+ delay?: number;
+}) => (
+
+ {icon && (
+
+ )}
+
+ {text}
+
+
+);
+
+const SourcesModal: React.FC = ({
+ showSourcesModal,
+ setShowSourcesModal,
+ availableStreams,
+ currentStreamUrl,
+ 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,
+ }));
+
+ 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 handleStreamSelect = (stream: Stream) => {
+ if (stream.url !== currentStreamUrl && !isChangingSource) {
+ onSelectStream(stream);
+ }
+ };
+
+ const getQualityFromTitle = (title?: string): string | null => {
+ if (!title) return null;
+ const match = title.match(/(\d+)p/);
+ return match ? match[1] : null;
+ };
+
+ const isStreamSelected = (stream: Stream): boolean => {
+ return stream.url === currentStreamUrl;
+ };
+
+ const handleClose = () => {
+ modalScale.value = withTiming(0.9, { duration: 150 });
+ modalOpacity.value = withTiming(0, { duration: 150 });
+ setTimeout(() => setShowSourcesModal(false), 150);
+ };
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal Content */}
+
+ {/* Glassmorphism Background */}
+
+ {/* Header */}
+
+
+
+ Switch Source
+
+
+ Choose from {Object.values(availableStreams).reduce((acc, curr) => acc + curr.streams.length, 0)} available streams
+
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {sortedProviders.map(([providerId, { streams, addonName }], providerIndex) => (
+ 0 ? 32 : 0,
+ width: '100%',
+ }}
+ >
+ {/* Provider Header */}
+
+
+
+
+ {addonName}
+
+
+ Provider • {streams.length} stream{streams.length !== 1 ? 's' : ''}
+
+
+
+
+
+ {streams.length}
+
+
+
+
+ {/* Streams Grid */}
+
+ {streams.map((stream, index) => {
+ const quality = getQualityFromTitle(stream.title);
+ 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';
+
+ return (
+
+ handleStreamSelect(stream)}
+ disabled={isChangingSource || isSelected}
+ activeOpacity={0.85}
+ >
+
+ {/* Stream Info */}
+
+ {/* Title Row */}
+
+
+ {isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')}
+
+
+ {isSelected && (
+
+
+
+ PLAYING
+
+
+ )}
+
+ {isChangingSource && isSelected && (
+
+
+
+ Switching...
+
+
+ )}
+
+
+ {/* Subtitle */}
+ {!isHDRezka && stream.title && stream.title !== stream.name && (
+
+ {stream.title}
+
+ )}
+
+ {/* Enhanced Meta Info */}
+
+
+
+ {isDolby && (
+
+ )}
+
+ {isHDR && (
+
+ )}
+
+ {size && (
+
+ )}
+
+ {isDebrid && (
+
+ )}
+
+ {isHDRezka && (
+
+ )}
+
+
+
+ {/* Enhanced Action Icon */}
+
+ {isSelected ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+ })}
+
+
+ ))}
+
+
+
+
+ );
+};
+
+export default SourcesModal;
\ No newline at end of file
diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx
new file mode 100644
index 0000000..cf5e247
--- /dev/null
+++ b/src/components/player/modals/SubtitleModals.tsx
@@ -0,0 +1,1148 @@
+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 Animated, {
+ FadeIn,
+ FadeOut,
+ SlideInDown,
+ SlideOutDown,
+ FadeInDown,
+ FadeInUp,
+ Layout,
+ withSpring,
+ withTiming,
+ useAnimatedStyle,
+ useSharedValue,
+ interpolate,
+ Easing,
+ withDelay,
+ withSequence,
+ runOnJS,
+ BounceIn,
+ ZoomIn
+} 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';
+
+interface SubtitleModalsProps {
+ showSubtitleModal: boolean;
+ setShowSubtitleModal: (show: boolean) => void;
+ showSubtitleLanguageModal: boolean;
+ setShowSubtitleLanguageModal: (show: boolean) => void;
+ isLoadingSubtitleList: boolean;
+ isLoadingSubtitles: boolean;
+ customSubtitles: SubtitleCue[];
+ availableSubtitles: WyzieSubtitle[];
+ vlcTextTracks: Array<{id: number, name: string, language?: string}>;
+ selectedTextTrack: number;
+ useCustomSubtitles: boolean;
+ subtitleSize: number;
+ fetchAvailableSubtitles: () => void;
+ loadWyzieSubtitle: (subtitle: WyzieSubtitle) => void;
+ selectTextTrack: (trackId: number) => void;
+ increaseSubtitleSize: () => void;
+ decreaseSubtitleSize: () => 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;
+}) => (
+
+ {icon && (
+
+ )}
+
+ {text}
+
+
+);
+
+export const SubtitleModals: React.FC = ({
+ showSubtitleModal,
+ setShowSubtitleModal,
+ showSubtitleLanguageModal,
+ setShowSubtitleLanguageModal,
+ isLoadingSubtitleList,
+ isLoadingSubtitles,
+ customSubtitles,
+ availableSubtitles,
+ vlcTextTracks,
+ selectedTextTrack,
+ useCustomSubtitles,
+ subtitleSize,
+ fetchAvailableSubtitles,
+ loadWyzieSubtitle,
+ selectTextTrack,
+ increaseSubtitleSize,
+ decreaseSubtitleSize,
+}) => {
+ const modalScale = useSharedValue(0.9);
+ const modalOpacity = useSharedValue(0);
+ const languageModalScale = useSharedValue(0.9);
+ const languageModalOpacity = useSharedValue(0);
+
+ 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),
+ });
+ }
+ }, [showSubtitleModal]);
+
+ 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),
+ });
+ }
+ }, [showSubtitleLanguageModal]);
+
+ const modalStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: modalScale.value }],
+ opacity: modalOpacity.value,
+ }));
+
+ const languageModalStyle = useAnimatedStyle(() => ({
+ transform: [{ scale: languageModalScale.value }],
+ opacity: languageModalOpacity.value,
+ }));
+
+ const handleClose = () => {
+ modalScale.value = withTiming(0.9, { duration: 150 });
+ modalOpacity.value = withTiming(0, { duration: 150 });
+ setTimeout(() => setShowSubtitleModal(false), 150);
+ };
+
+ const handleLanguageClose = () => {
+ languageModalScale.value = withTiming(0.9, { duration: 150 });
+ languageModalOpacity.value = withTiming(0, { duration: 150 });
+ setTimeout(() => setShowSubtitleLanguageModal(false), 150);
+ };
+
+ // Render subtitle settings modal
+ const renderSubtitleModal = () => {
+ if (!showSubtitleModal) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal Content */}
+
+ {/* Glassmorphism Background */}
+
+ {/* Header */}
+
+
+
+ Subtitle Settings
+
+
+ Configure subtitles and language options
+
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+
+
+ {/* External Subtitles Section */}
+
+
+
+
+
+ External Subtitles
+
+
+ High quality with size control
+
+
+
+
+ {/* Custom subtitles option */}
+ {customSubtitles.length > 0 && (
+
+ {
+ selectTextTrack(-999);
+ setShowSubtitleModal(false);
+ }}
+ activeOpacity={0.85}
+ >
+
+
+
+
+ Custom Subtitles
+
+
+ {useCustomSubtitles && (
+
+
+
+ ACTIVE
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {useCustomSubtitles ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ )}
+
+ {/* Search for external subtitles */}
+
+ {
+ handleClose();
+ fetchAvailableSubtitles();
+ }}
+ disabled={isLoadingSubtitleList}
+ activeOpacity={0.85}
+ >
+
+ {isLoadingSubtitleList ? (
+
+ ) : (
+
+ )}
+
+ {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'}
+
+
+
+
+
+
+ {/* Subtitle Size Controls */}
+ {useCustomSubtitles && (
+
+
+
+
+
+ Size Control
+
+
+ Adjust font size for better readability
+
+
+
+
+
+
+
+
+
+
+
+
+ {subtitleSize}px
+
+
+ Font Size
+
+
+
+
+
+
+
+
+
+ )}
+
+ {/* Available built-in subtitle tracks */}
+ {vlcTextTracks.length > 0 ? vlcTextTracks.map((track, index) => (
+
+ {
+ selectTextTrack(track.id);
+ handleClose();
+ }}
+ activeOpacity={0.85}
+ >
+
+
+
+
+ {getTrackDisplayName(track)}
+
+
+ {(selectedTextTrack === track.id && !useCustomSubtitles) && (
+
+
+
+ ACTIVE
+
+
+ )}
+
+
+
+
+
+
+
+
+
+ {(selectedTextTrack === track.id && !useCustomSubtitles) ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+
+ )) : (
+
+
+
+ No built-in subtitles available
+
+
+ Try searching for external subtitles
+
+
+ )}
+
+
+
+
+
+ );
+ };
+
+ // Render subtitle language selection modal
+ const renderSubtitleLanguageModal = () => {
+ if (!showSubtitleLanguageModal) return null;
+
+ return (
+
+ {/* Backdrop */}
+
+
+ {/* Modal Content */}
+
+ {/* Glassmorphism Background */}
+
+ {/* Header */}
+
+
+
+ Select Language
+
+
+ Choose from {availableSubtitles.length} available languages
+
+
+
+
+
+
+
+
+
+
+ {/* Content */}
+
+ {availableSubtitles.length > 0 ? availableSubtitles.map((subtitle, index) => (
+
+ loadWyzieSubtitle(subtitle)}
+ disabled={isLoadingSubtitles}
+ activeOpacity={0.85}
+ >
+
+
+
+
+
+ {formatLanguage(subtitle.language)}
+
+
+ {subtitle.display}
+
+
+
+
+
+ {isLoadingSubtitles ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )) : (
+
+
+
+ No subtitles found
+
+
+ No subtitles are available for this content.{'\n'}Try searching again or check back later.
+
+
+ )}
+
+
+
+
+ );
+ };
+
+ return (
+ <>
+ {renderSubtitleModal()}
+ {renderSubtitleLanguageModal()}
+ >
+ );
+};
+
+export default SubtitleModals;
\ No newline at end of file
diff --git a/src/components/player/subtitles/CustomSubtitles.tsx b/src/components/player/subtitles/CustomSubtitles.tsx
new file mode 100644
index 0000000..66bbedf
--- /dev/null
+++ b/src/components/player/subtitles/CustomSubtitles.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { View, Text } from 'react-native';
+import { styles } from '../utils/playerStyles';
+
+interface CustomSubtitlesProps {
+ useCustomSubtitles: boolean;
+ currentSubtitle: string;
+ subtitleSize: number;
+}
+
+export const CustomSubtitles: React.FC = ({
+ useCustomSubtitles,
+ currentSubtitle,
+ subtitleSize,
+}) => {
+ if (!useCustomSubtitles || !currentSubtitle) return null;
+
+ return (
+
+
+
+ {currentSubtitle}
+
+
+
+ );
+};
+
+export default CustomSubtitles;
\ No newline at end of file
diff --git a/src/components/player/utils/playerStyles.ts b/src/components/player/utils/playerStyles.ts
new file mode 100644
index 0000000..84cea74
--- /dev/null
+++ b/src/components/player/utils/playerStyles.ts
@@ -0,0 +1,987 @@
+import { StyleSheet } from 'react-native';
+
+export const styles = StyleSheet.create({
+ container: {
+ backgroundColor: '#000',
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ margin: 0,
+ padding: 0,
+ },
+ videoContainer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ margin: 0,
+ padding: 0,
+ },
+ video: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ margin: 0,
+ padding: 0,
+ },
+ controlsContainer: {
+ ...StyleSheet.absoluteFillObject,
+ justifyContent: 'space-between',
+ margin: 0,
+ padding: 0,
+ },
+ topGradient: {
+ paddingTop: 20,
+ paddingHorizontal: 20,
+ paddingBottom: 10,
+ },
+ bottomGradient: {
+ paddingBottom: 20,
+ paddingHorizontal: 20,
+ paddingTop: 20,
+ },
+ header: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'flex-start',
+ },
+ titleSection: {
+ flex: 1,
+ marginRight: 10,
+ },
+ title: {
+ color: 'white',
+ fontSize: 18,
+ fontWeight: 'bold',
+ },
+ episodeInfo: {
+ color: 'rgba(255, 255, 255, 0.9)',
+ fontSize: 14,
+ marginTop: 3,
+ },
+ metadataRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 5,
+ flexWrap: 'wrap',
+ },
+ metadataText: {
+ color: 'rgba(255, 255, 255, 0.7)',
+ fontSize: 12,
+ marginRight: 8,
+ },
+ qualityBadge: {
+ backgroundColor: 'rgba(229, 9, 20, 0.2)',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 4,
+ marginRight: 8,
+ marginBottom: 4,
+ },
+ qualityText: {
+ color: '#E50914',
+ fontSize: 11,
+ fontWeight: 'bold',
+ },
+ providerText: {
+ color: 'rgba(255, 255, 255, 0.7)',
+ fontSize: 12,
+ fontStyle: 'italic',
+ },
+ closeButton: {
+ padding: 8,
+ },
+ controls: {
+ position: 'absolute',
+ flexDirection: 'row',
+ justifyContent: 'center',
+ alignItems: 'center',
+ gap: 40,
+ left: 0,
+ right: 0,
+ top: '50%',
+ transform: [{ translateY: -30 }],
+ zIndex: 1000,
+ },
+ playButton: {
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 10,
+ },
+ skipButton: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ },
+ skipText: {
+ color: 'white',
+ fontSize: 12,
+ marginTop: 2,
+ },
+ bottomControls: {
+ gap: 12,
+ },
+ sliderContainer: {
+ position: 'absolute',
+ bottom: 55,
+ left: 0,
+ right: 0,
+ paddingHorizontal: 20,
+ zIndex: 1000,
+ },
+ progressTouchArea: {
+ height: 30,
+ justifyContent: 'center',
+ width: '100%',
+ },
+ progressBarContainer: {
+ height: 4,
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ borderRadius: 2,
+ overflow: 'hidden',
+ marginHorizontal: 4,
+ position: 'relative',
+ },
+ bufferProgress: {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(255, 255, 255, 0.4)',
+ },
+ progressBarFill: {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ bottom: 0,
+ backgroundColor: '#E50914',
+ height: '100%',
+ },
+ timeDisplay: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ width: '100%',
+ paddingHorizontal: 4,
+ marginTop: 4,
+ marginBottom: 8,
+ },
+ duration: {
+ color: 'white',
+ fontSize: 12,
+ fontWeight: '500',
+ },
+ bottomButtons: {
+ flexDirection: 'row',
+ justifyContent: 'space-around',
+ alignItems: 'center',
+ },
+ bottomButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 5,
+ },
+ bottomButtonText: {
+ color: 'white',
+ fontSize: 12,
+ },
+ modalOverlay: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 20,
+ },
+ modalContent: {
+ width: '80%',
+ maxHeight: '70%',
+ backgroundColor: '#222',
+ borderRadius: 10,
+ overflow: 'hidden',
+ zIndex: 1000,
+ elevation: 5,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.8,
+ shadowRadius: 5,
+ },
+ modalHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 20,
+ paddingHorizontal: 4,
+ },
+ modalTitle: {
+ color: 'white',
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ trackList: {
+ padding: 10,
+ },
+ trackItem: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 15,
+ borderRadius: 5,
+ marginVertical: 5,
+ },
+ selectedTrackItem: {
+ backgroundColor: 'rgba(229, 9, 20, 0.2)',
+ },
+ trackLabel: {
+ color: 'white',
+ fontSize: 16,
+ },
+ noTracksText: {
+ color: 'white',
+ fontSize: 16,
+ textAlign: 'center',
+ padding: 20,
+ },
+ fullscreenOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0,0,0,0.85)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 2000,
+ },
+ enhancedModalContainer: {
+ width: 300,
+ maxHeight: '70%',
+ backgroundColor: '#181818',
+ borderRadius: 8,
+ overflow: 'hidden',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.4,
+ shadowRadius: 10,
+ elevation: 8,
+ },
+ enhancedModalHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: '#333',
+ },
+ enhancedModalTitle: {
+ color: 'white',
+ fontSize: 18,
+ fontWeight: 'bold',
+ },
+ enhancedCloseButton: {
+ padding: 4,
+ },
+ trackListScrollContainer: {
+ maxHeight: 350,
+ },
+ trackListContainer: {
+ padding: 6,
+ },
+ enhancedTrackItem: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 10,
+ marginVertical: 2,
+ borderRadius: 6,
+ backgroundColor: '#222',
+ },
+ trackInfoContainer: {
+ flex: 1,
+ marginRight: 8,
+ },
+ trackPrimaryText: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ trackSecondaryText: {
+ color: '#aaa',
+ fontSize: 11,
+ marginTop: 2,
+ },
+ selectedIndicatorContainer: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: 'rgba(229, 9, 20, 0.15)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ emptyStateContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 20,
+ },
+ emptyStateText: {
+ color: '#888',
+ fontSize: 14,
+ marginTop: 8,
+ textAlign: 'center',
+ },
+ resumeOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(0, 0, 0, 0.7)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 1000,
+ },
+ resumeContainer: {
+ width: '80%',
+ maxWidth: 500,
+ borderRadius: 12,
+ padding: 20,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 4 },
+ shadowOpacity: 0.3,
+ shadowRadius: 6,
+ elevation: 8,
+ },
+ resumeContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 20,
+ },
+ resumeIconContainer: {
+ marginRight: 16,
+ width: 50,
+ height: 50,
+ borderRadius: 25,
+ backgroundColor: 'rgba(229, 9, 20, 0.2)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ resumeTextContainer: {
+ flex: 1,
+ },
+ resumeTitle: {
+ color: 'white',
+ fontSize: 20,
+ fontWeight: 'bold',
+ marginBottom: 4,
+ },
+ resumeInfo: {
+ color: 'rgba(255, 255, 255, 0.9)',
+ fontSize: 14,
+ },
+ resumeProgressContainer: {
+ marginTop: 12,
+ },
+ resumeProgressBar: {
+ height: 4,
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ borderRadius: 2,
+ overflow: 'hidden',
+ marginBottom: 6,
+ },
+ resumeProgressFill: {
+ height: '100%',
+ backgroundColor: '#E50914',
+ },
+ resumeTimeText: {
+ color: 'rgba(255,255,255,0.7)',
+ fontSize: 12,
+ },
+ resumeButtons: {
+ flexDirection: 'row',
+ justifyContent: 'flex-end',
+ width: '100%',
+ gap: 12,
+ },
+ resumeButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 10,
+ paddingHorizontal: 16,
+ borderRadius: 6,
+ backgroundColor: 'rgba(255, 255, 255, 0.15)',
+ minWidth: 110,
+ justifyContent: 'center',
+ },
+ buttonIcon: {
+ marginRight: 6,
+ },
+ resumeButtonText: {
+ color: 'white',
+ fontWeight: 'bold',
+ fontSize: 14,
+ },
+ resumeFromButton: {
+ backgroundColor: '#E50914',
+ },
+ rememberChoiceContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ marginBottom: 16,
+ paddingHorizontal: 2,
+ },
+ checkboxContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ checkbox: {
+ width: 18,
+ height: 18,
+ borderRadius: 3,
+ borderWidth: 2,
+ borderColor: 'rgba(255, 255, 255, 0.5)',
+ marginRight: 8,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ checkboxChecked: {
+ backgroundColor: '#E50914',
+ borderColor: '#E50914',
+ },
+ rememberChoiceText: {
+ color: 'rgba(255, 255, 255, 0.8)',
+ fontSize: 14,
+ },
+ resetPreferenceButton: {
+ padding: 4,
+ },
+ resetPreferenceText: {
+ color: '#E50914',
+ fontSize: 12,
+ fontWeight: 'bold',
+ },
+ openingOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0,0,0,0.85)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 2000,
+ margin: 0,
+ padding: 0,
+ },
+ openingContent: {
+ padding: 20,
+ backgroundColor: 'rgba(0,0,0,0.85)',
+ borderRadius: 10,
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ openingText: {
+ color: 'white',
+ fontSize: 18,
+ fontWeight: 'bold',
+ marginTop: 20,
+ },
+ videoPlayerContainer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ margin: 0,
+ padding: 0,
+ },
+ subtitleSizeContainer: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 10,
+ paddingVertical: 12,
+ marginBottom: 8,
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
+ borderRadius: 6,
+ },
+ subtitleSizeLabel: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: 'bold',
+ },
+ subtitleSizeControls: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ sizeButton: {
+ width: 30,
+ height: 30,
+ borderRadius: 15,
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ subtitleSizeText: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: 'bold',
+ minWidth: 40,
+ textAlign: 'center',
+ },
+ customSubtitleContainer: {
+ position: 'absolute',
+ bottom: 40, // Position above controls and progress bar
+ 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,
+ },
+ customSubtitleText: {
+ color: 'white',
+ textAlign: 'center',
+ textShadowColor: 'rgba(0, 0, 0, 0.9)',
+ textShadowOffset: { width: 2, height: 2 },
+ textShadowRadius: 4,
+ lineHeight: undefined, // Let React Native calculate line height
+ fontWeight: '500',
+ },
+ loadSubtitlesButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 12,
+ marginTop: 8,
+ borderRadius: 6,
+ backgroundColor: 'rgba(229, 9, 20, 0.2)',
+ borderWidth: 1,
+ borderColor: '#E50914',
+ },
+ loadSubtitlesText: {
+ color: '#E50914',
+ fontSize: 14,
+ fontWeight: 'bold',
+ marginLeft: 8,
+ },
+ disabledContainer: {
+ opacity: 0.5,
+ },
+ disabledText: {
+ color: '#666',
+ },
+ disabledButton: {
+ backgroundColor: '#666',
+ },
+ noteContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 10,
+ },
+ noteText: {
+ color: '#aaa',
+ fontSize: 12,
+ marginLeft: 5,
+ },
+ subtitleLanguageItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flex: 1,
+ },
+ flagIcon: {
+ width: 24,
+ height: 18,
+ marginRight: 12,
+ borderRadius: 2,
+ },
+ modernModalContainer: {
+ width: '90%',
+ maxWidth: 500,
+ backgroundColor: '#181818',
+ borderRadius: 10,
+ overflow: 'hidden',
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 6 },
+ shadowOpacity: 0.4,
+ shadowRadius: 10,
+ elevation: 8,
+ },
+ modernModalHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ paddingHorizontal: 16,
+ paddingVertical: 12,
+ borderBottomWidth: 1,
+ borderBottomColor: '#333',
+ },
+ modernModalTitle: {
+ color: 'white',
+ fontSize: 18,
+ fontWeight: 'bold',
+ },
+ modernCloseButton: {
+ padding: 4,
+ },
+ modernTrackListScrollContainer: {
+ maxHeight: 350,
+ },
+ modernTrackListContainer: {
+ padding: 6,
+ },
+ sectionContainer: {
+ marginBottom: 20,
+ },
+ sectionTitle: {
+ color: 'white',
+ fontSize: 16,
+ fontWeight: 'bold',
+ marginBottom: 8,
+ },
+ sectionDescription: {
+ color: 'rgba(255, 255, 255, 0.7)',
+ fontSize: 12,
+ marginBottom: 12,
+ },
+ trackIconContainer: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ modernTrackInfoContainer: {
+ flex: 1,
+ marginLeft: 10,
+ },
+ modernTrackPrimaryText: {
+ color: 'white',
+ fontSize: 14,
+ fontWeight: '500',
+ },
+ modernTrackSecondaryText: {
+ color: '#aaa',
+ fontSize: 11,
+ marginTop: 2,
+ },
+ modernSelectedIndicator: {
+ width: 24,
+ height: 24,
+ borderRadius: 12,
+ backgroundColor: 'rgba(255, 255, 255, 0.15)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ modernEmptyStateContainer: {
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 20,
+ },
+ modernEmptyStateText: {
+ color: '#888',
+ fontSize: 14,
+ marginTop: 8,
+ textAlign: 'center',
+ },
+ searchSubtitlesButton: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 12,
+ marginTop: 8,
+ borderRadius: 6,
+ backgroundColor: 'rgba(229, 9, 20, 0.2)',
+ borderWidth: 1,
+ borderColor: '#E50914',
+ },
+ searchButtonContent: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: 8,
+ },
+ searchSubtitlesText: {
+ color: '#E50914',
+ fontSize: 14,
+ fontWeight: 'bold',
+ marginLeft: 8,
+ },
+ modernSubtitleSizeContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ },
+ modernSizeButton: {
+ width: 30,
+ height: 30,
+ borderRadius: 15,
+ backgroundColor: 'rgba(255, 255, 255, 0.2)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ modernTrackItem: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ padding: 12,
+ marginVertical: 4,
+ borderRadius: 8,
+ backgroundColor: '#222',
+ },
+ modernSelectedTrackItem: {
+ backgroundColor: 'rgba(76, 175, 80, 0.15)',
+ borderWidth: 1,
+ borderColor: 'rgba(76, 175, 80, 0.3)',
+ },
+ sizeDisplayContainer: {
+ alignItems: 'center',
+ flex: 1,
+ marginHorizontal: 20,
+ },
+ modernSubtitleSizeText: {
+ color: 'white',
+ fontSize: 16,
+ fontWeight: 'bold',
+ },
+ sizeLabel: {
+ color: 'rgba(255, 255, 255, 0.7)',
+ fontSize: 12,
+ marginTop: 2,
+ },
+ loadingCloseButton: {
+ position: 'absolute',
+ top: 40,
+ right: 20,
+ width: 44,
+ height: 44,
+ backgroundColor: 'rgba(0, 0, 0, 0.6)',
+ borderRadius: 22,
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 9999,
+ },
+ // Sources Modal Styles
+ sourcesModal: {
+ flex: 1,
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ padding: 20,
+ },
+ sourcesContainer: {
+ backgroundColor: 'rgba(20, 20, 20, 0.98)',
+ borderRadius: 12,
+ width: '100%',
+ maxWidth: 500,
+ maxHeight: '80%',
+ paddingVertical: 20,
+ paddingHorizontal: 16,
+ },
+ sourcesHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 20,
+ paddingHorizontal: 4,
+ },
+ sourcesTitle: {
+ color: 'white',
+ fontSize: 20,
+ fontWeight: 'bold',
+ },
+ modalCloseButton: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ sourcesScrollView: {
+ maxHeight: 400,
+ },
+ sourceItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 16,
+ paddingHorizontal: 12,
+ borderRadius: 8,
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
+ marginBottom: 8,
+ },
+ currentSourceItem: {
+ backgroundColor: 'rgba(229, 9, 20, 0.2)',
+ borderWidth: 1,
+ borderColor: 'rgba(229, 9, 20, 0.5)',
+ },
+ sourceInfo: {
+ flex: 1,
+ marginLeft: 12,
+ },
+ sourceTitle: {
+ color: 'white',
+ fontSize: 16,
+ fontWeight: '600',
+ marginBottom: 4,
+ },
+ sourceDetails: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ },
+ sourceDetailText: {
+ color: '#888',
+ fontSize: 12,
+ marginRight: 8,
+ marginBottom: 4,
+ },
+ currentStreamBadge: {
+ backgroundColor: 'rgba(0, 255, 0, 0.2)',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 4,
+ marginRight: 8,
+ marginBottom: 4,
+ },
+ currentStreamText: {
+ color: '#00FF00',
+ fontSize: 11,
+ fontWeight: 'bold',
+ },
+ switchingSourceOverlay: {
+ ...StyleSheet.absoluteFillObject,
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 9999,
+ },
+ switchingContent: {
+ alignItems: 'center',
+ backgroundColor: 'rgba(20, 20, 20, 0.9)',
+ padding: 30,
+ borderRadius: 12,
+ minWidth: 200,
+ },
+ switchingText: {
+ color: 'white',
+ fontSize: 16,
+ fontWeight: '600',
+ marginTop: 12,
+ textAlign: 'center',
+ },
+ // Additional SourcesModal styles
+ sourceProviderSection: {
+ marginBottom: 20,
+ },
+ sourceProviderTitle: {
+ color: 'rgba(255, 255, 255, 0.8)',
+ fontSize: 14,
+ fontWeight: '600',
+ marginBottom: 12,
+ paddingHorizontal: 4,
+ textTransform: 'uppercase',
+ letterSpacing: 0.5,
+ },
+ sourceStreamItem: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ paddingVertical: 12,
+ paddingHorizontal: 12,
+ borderRadius: 8,
+ backgroundColor: 'rgba(255, 255, 255, 0.05)',
+ marginBottom: 8,
+ },
+ sourceStreamItemSelected: {
+ backgroundColor: 'rgba(229, 9, 20, 0.2)',
+ borderWidth: 1,
+ borderColor: 'rgba(229, 9, 20, 0.5)',
+ },
+ sourceStreamDetails: {
+ flex: 1,
+ },
+ sourceStreamTitleRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginBottom: 4,
+ },
+ sourceStreamTitle: {
+ color: 'white',
+ fontSize: 16,
+ fontWeight: '600',
+ flex: 1,
+ },
+ sourceStreamTitleSelected: {
+ color: '#E50914',
+ },
+ sourceStreamSubtitle: {
+ color: 'rgba(255, 255, 255, 0.7)',
+ fontSize: 14,
+ marginBottom: 6,
+ },
+ sourceStreamMeta: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ flexWrap: 'wrap',
+ },
+ sourceChip: {
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ paddingHorizontal: 8,
+ paddingVertical: 2,
+ borderRadius: 4,
+ marginRight: 6,
+ marginBottom: 4,
+ },
+ sourceChipText: {
+ color: 'rgba(255, 255, 255, 0.8)',
+ fontSize: 11,
+ fontWeight: 'bold',
+ },
+ debridChip: {
+ backgroundColor: 'rgba(0, 255, 0, 0.2)',
+ },
+ hdrezkaChip: {
+ backgroundColor: 'rgba(255, 165, 0, 0.2)',
+ },
+ sourceStreamAction: {
+ width: 40,
+ height: 40,
+ borderRadius: 20,
+ backgroundColor: 'rgba(255, 255, 255, 0.1)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ },
+ // Source Change Loading Overlay
+ sourceChangeOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0, 0, 0, 0.9)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 5000,
+ },
+ sourceChangeContent: {
+ alignItems: 'center',
+ padding: 30,
+ },
+ sourceChangeText: {
+ color: '#E50914',
+ fontSize: 18,
+ fontWeight: 'bold',
+ marginTop: 15,
+ textAlign: 'center',
+ },
+ sourceChangeSubtext: {
+ color: 'rgba(255, 255, 255, 0.8)',
+ fontSize: 14,
+ marginTop: 8,
+ textAlign: 'center',
+ },
+});
\ No newline at end of file
diff --git a/src/components/player/utils/playerTypes.ts b/src/components/player/utils/playerTypes.ts
new file mode 100644
index 0000000..3f2c5d8
--- /dev/null
+++ b/src/components/player/utils/playerTypes.ts
@@ -0,0 +1,88 @@
+// Player constants
+export const RESUME_PREF_KEY = '@video_resume_preference';
+export const RESUME_PREF = {
+ ALWAYS_ASK: 'always_ask',
+ ALWAYS_RESUME: 'always_resume',
+ ALWAYS_START_OVER: 'always_start_over'
+};
+
+export const SUBTITLE_SIZE_KEY = '@subtitle_size_preference';
+export const DEFAULT_SUBTITLE_SIZE = 16;
+
+// Define the TrackPreferenceType for audio/text tracks
+export type TrackPreferenceType = 'system' | 'disabled' | 'title' | 'language' | 'index';
+
+// Define the SelectedTrack type for audio/text tracks
+export interface SelectedTrack {
+ type: TrackPreferenceType;
+ value?: string | number; // value is optional for 'system' and 'disabled'
+}
+
+export interface VideoPlayerProps {
+ uri: string;
+ title?: string;
+ season?: number;
+ episode?: number;
+ episodeTitle?: string;
+ quality?: string;
+ year?: number;
+ streamProvider?: string;
+ id?: string;
+ type?: string;
+ episodeId?: string;
+ imdbId?: string; // Add IMDb ID for subtitle fetching
+}
+
+// Match the react-native-video AudioTrack type
+export interface AudioTrack {
+ index: number;
+ title?: string;
+ language?: string;
+ bitrate?: number;
+ type?: string;
+ selected?: boolean;
+}
+
+// Define TextTrack interface based on react-native-video expected structure
+export interface TextTrack {
+ index: number;
+ title?: string;
+ language?: string;
+ type?: string | null; // Adjusting type based on linter error
+}
+
+// Define the possible resize modes - force to stretch for absolute full screen
+export type ResizeModeType = 'contain' | 'cover' | 'fill' | 'none' | 'stretch';
+export const resizeModes: ResizeModeType[] = ['stretch']; // Force stretch mode for absolute full screen
+
+// Add VLC specific interface for their event structure
+export interface VlcMediaEvent {
+ currentTime: number;
+ duration: number;
+ bufferTime?: number;
+ isBuffering?: boolean;
+ audioTracks?: Array<{id: number, name: string, language?: string}>;
+ textTracks?: Array<{id: number, name: string, language?: string}>;
+ selectedAudioTrack?: number;
+ selectedTextTrack?: number;
+}
+
+export interface SubtitleCue {
+ start: number;
+ end: number;
+ text: string;
+}
+
+// Add interface for Wyzie subtitle API response
+export interface WyzieSubtitle {
+ id: string;
+ url: string;
+ flagUrl: string;
+ format: string;
+ encoding: string;
+ media: string;
+ display: string;
+ language: string;
+ isHearingImpaired: boolean;
+ source: string;
+}
\ No newline at end of file
diff --git a/src/components/player/utils/playerUtils.ts b/src/components/player/utils/playerUtils.ts
new file mode 100644
index 0000000..72aeb0c
--- /dev/null
+++ b/src/components/player/utils/playerUtils.ts
@@ -0,0 +1,219 @@
+import { logger } from '../../../utils/logger';
+import { useEffect } from 'react';
+import { SubtitleCue } from './playerTypes';
+
+// Debug flag - set back to false to disable verbose logging
+// WARNING: Setting this to true currently causes infinite render loops
+// Use selective logging instead if debugging is needed
+export const DEBUG_MODE = false;
+
+// Safer debug function that won't cause render loops
+// Call this with any debugging info you need instead of using inline DEBUG_MODE checks
+export const safeDebugLog = (message: string, data?: any) => {
+ // This function only runs once per call site, avoiding render loops
+ // eslint-disable-next-line react-hooks/rules-of-hooks
+ useEffect(() => {
+ if (DEBUG_MODE) {
+ if (data) {
+ logger.log(`[VideoPlayer] ${message}`, data);
+ } else {
+ logger.log(`[VideoPlayer] ${message}`);
+ }
+ }
+ }, []); // Empty dependency array means this only runs once per mount
+};
+
+// Add language code to name mapping
+export const languageMap: {[key: string]: string} = {
+ 'en': 'English',
+ 'eng': 'English',
+ 'es': 'Spanish',
+ 'spa': 'Spanish',
+ 'fr': 'French',
+ 'fre': 'French',
+ 'de': 'German',
+ 'ger': 'German',
+ 'it': 'Italian',
+ 'ita': 'Italian',
+ 'ja': 'Japanese',
+ 'jpn': 'Japanese',
+ 'ko': 'Korean',
+ 'kor': 'Korean',
+ 'zh': 'Chinese',
+ 'chi': 'Chinese',
+ 'ru': 'Russian',
+ 'rus': 'Russian',
+ 'pt': 'Portuguese',
+ 'por': 'Portuguese',
+ 'hi': 'Hindi',
+ 'hin': 'Hindi',
+ 'ar': 'Arabic',
+ 'ara': 'Arabic',
+ 'nl': 'Dutch',
+ 'dut': 'Dutch',
+ 'sv': 'Swedish',
+ 'swe': 'Swedish',
+ 'no': 'Norwegian',
+ 'nor': 'Norwegian',
+ 'fi': 'Finnish',
+ 'fin': 'Finnish',
+ 'da': 'Danish',
+ 'dan': 'Danish',
+ 'pl': 'Polish',
+ 'pol': 'Polish',
+ 'tr': 'Turkish',
+ 'tur': 'Turkish',
+ 'cs': 'Czech',
+ 'cze': 'Czech',
+ 'hu': 'Hungarian',
+ 'hun': 'Hungarian',
+ 'el': 'Greek',
+ 'gre': 'Greek',
+ 'th': 'Thai',
+ 'tha': 'Thai',
+ 'vi': 'Vietnamese',
+ 'vie': 'Vietnamese',
+};
+
+// Function to format language code to readable name
+export const formatLanguage = (code?: string): string => {
+ if (!code) return 'Unknown';
+ const normalized = code.toLowerCase();
+ const languageName = languageMap[normalized] || code.toUpperCase();
+
+ // If the result is still the uppercased code, it means we couldn't find it in our map.
+ if (languageName === code.toUpperCase()) {
+ return `Unknown (${code})`;
+ }
+
+ return languageName;
+};
+
+// Helper function to extract a display name from the track's name property
+export const getTrackDisplayName = (track: { name?: string, id: number }): string => {
+ if (!track || !track.name) return `Track ${track.id}`;
+
+ // Try to extract language from name like "Some Info - [English]"
+ const languageMatch = track.name.match(/\[(.*?)\]/);
+ if (languageMatch && languageMatch[1]) {
+ return languageMatch[1];
+ }
+
+ // If no language in brackets, or if the name is simple, use the full name
+ return track.name;
+};
+
+// Format time function for the player
+export const formatTime = (seconds: number) => {
+ const hours = Math.floor(seconds / 3600);
+ const mins = Math.floor((seconds % 3600) / 60);
+ const secs = Math.floor(seconds % 60);
+
+ if (hours > 0) {
+ return `${hours}:${mins < 10 ? '0' : ''}${mins}:${secs < 10 ? '0' : ''}${secs}`;
+ } else {
+ return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
+ }
+};
+
+// Enhanced SRT parser function - more robust
+export const parseSRT = (srtContent: string): SubtitleCue[] => {
+ const cues: SubtitleCue[] = [];
+
+ if (!srtContent || srtContent.trim().length === 0) {
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] SRT Parser: Empty content provided`);
+ }
+ return cues;
+ }
+
+ // Normalize line endings and clean up the content
+ const normalizedContent = srtContent
+ .replace(/\r\n/g, '\n') // Convert Windows line endings
+ .replace(/\r/g, '\n') // Convert Mac line endings
+ .trim();
+
+ // Split by double newlines, but also handle cases with multiple empty lines
+ const blocks = normalizedContent.split(/\n\s*\n/).filter(block => block.trim().length > 0);
+
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] SRT Parser: Found ${blocks.length} blocks after normalization`);
+ logger.log(`[VideoPlayer] SRT Parser: First few characters: "${normalizedContent.substring(0, 300)}"`);
+ }
+
+ for (let i = 0; i < blocks.length; i++) {
+ const block = blocks[i].trim();
+ const lines = block.split('\n').map(line => line.trim()).filter(line => line.length > 0);
+
+ if (lines.length >= 3) {
+ // Find the timestamp line (could be line 1 or 2, depending on numbering)
+ let timeLineIndex = -1;
+ let timeMatch = null;
+
+ for (let j = 0; j < Math.min(3, lines.length); j++) {
+ // More flexible time pattern matching
+ timeMatch = lines[j].match(/(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})\s*-->\s*(\d{1,2}):(\d{2}):(\d{2})[,.](\d{3})/);
+ if (timeMatch) {
+ timeLineIndex = j;
+ break;
+ }
+ }
+
+ if (timeMatch && timeLineIndex !== -1) {
+ try {
+ const startTime =
+ parseInt(timeMatch[1]) * 3600 +
+ parseInt(timeMatch[2]) * 60 +
+ parseInt(timeMatch[3]) +
+ parseInt(timeMatch[4]) / 1000;
+
+ const endTime =
+ parseInt(timeMatch[5]) * 3600 +
+ parseInt(timeMatch[6]) * 60 +
+ parseInt(timeMatch[7]) +
+ parseInt(timeMatch[8]) / 1000;
+
+ // Get text lines (everything after the timestamp line)
+ const textLines = lines.slice(timeLineIndex + 1);
+ if (textLines.length > 0) {
+ const text = textLines
+ .join('\n')
+ .replace(/<[^>]*>/g, '') // Remove HTML tags
+ .replace(/\{[^}]*\}/g, '') // Remove subtitle formatting tags like {italic}
+ .replace(/\\N/g, '\n') // Handle \N newlines
+ .trim();
+
+ if (text.length > 0) {
+ cues.push({
+ start: startTime,
+ end: endTime,
+ text: text
+ });
+
+ if (DEBUG_MODE && (i < 5 || cues.length <= 10)) {
+ logger.log(`[VideoPlayer] SRT Parser: Cue ${cues.length}: ${startTime.toFixed(3)}s-${endTime.toFixed(3)}s: "${text.substring(0, 50)}${text.length > 50 ? '...' : ''}"`);
+ }
+ }
+ }
+ } catch (error) {
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] SRT Parser: Error parsing times for block ${i + 1}: ${error}`);
+ }
+ }
+ } else if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] SRT Parser: No valid timestamp found in block ${i + 1}. Lines: ${JSON.stringify(lines.slice(0, 3))}`);
+ }
+ } else if (DEBUG_MODE && block.length > 0) {
+ logger.log(`[VideoPlayer] SRT Parser: Block ${i + 1} has insufficient lines (${lines.length}): "${block.substring(0, 100)}"`);
+ }
+ }
+
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] SRT Parser: Successfully parsed ${cues.length} subtitle cues`);
+ if (cues.length > 0) {
+ logger.log(`[VideoPlayer] SRT Parser: Time range: ${cues[0].start.toFixed(1)}s to ${cues[cues.length-1].end.toFixed(1)}s`);
+ }
+ }
+
+ return cues;
+};
\ No newline at end of file
diff --git a/src/contexts/ThemeContext.tsx b/src/contexts/ThemeContext.tsx
index 5eb8c96..54c550b 100644
--- a/src/contexts/ThemeContext.tsx
+++ b/src/contexts/ThemeContext.tsx
@@ -45,9 +45,9 @@ export const DEFAULT_THEMES: Theme[] = [
name: 'Moonlight',
colors: {
...defaultColors,
- primary: '#a786df',
- secondary: '#5e72e4',
- darkBackground: '#0f0f1a',
+ primary: '#c084fc',
+ secondary: '#60a5fa',
+ darkBackground: '#060609',
},
isEditable: false,
},
diff --git a/src/contexts/TraktContext.tsx b/src/contexts/TraktContext.tsx
index 05c27d1..1cc30fe 100644
--- a/src/contexts/TraktContext.tsx
+++ b/src/contexts/TraktContext.tsx
@@ -1,6 +1,13 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { useTraktIntegration } from '../hooks/useTraktIntegration';
-import { TraktUser, TraktWatchedItem } from '../services/traktService';
+import {
+ TraktUser,
+ TraktWatchedItem,
+ TraktWatchlistItem,
+ TraktCollectionItem,
+ TraktRatingItem,
+ TraktPlaybackItem
+} from '../services/traktService';
interface TraktContextProps {
isAuthenticated: boolean;
@@ -8,13 +15,21 @@ interface TraktContextProps {
userProfile: TraktUser | null;
watchedMovies: TraktWatchedItem[];
watchedShows: TraktWatchedItem[];
+ watchlistMovies: TraktWatchlistItem[];
+ watchlistShows: TraktWatchlistItem[];
+ collectionMovies: TraktCollectionItem[];
+ collectionShows: TraktCollectionItem[];
+ continueWatching: TraktPlaybackItem[];
+ ratedContent: TraktRatingItem[];
checkAuthStatus: () => Promise;
refreshAuthStatus: () => Promise;
loadWatchedItems: () => Promise;
+ loadAllCollections: () => Promise;
isMovieWatched: (imdbId: string) => Promise;
isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise;
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise;
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise;
+ forceSyncTraktProgress?: () => Promise;
}
const TraktContext = createContext(undefined);
diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts
index d464c31..0c796c4 100644
--- a/src/hooks/useMetadata.ts
+++ b/src/hooks/useMetadata.ts
@@ -3,11 +3,15 @@ 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 { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
import { TMDBService } from '../services/tmdbService';
import { logger } from '../utils/logger';
import { usePersistentSeasons } from './usePersistentSeasons';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { Stream } from '../types/metadata';
+import { storageService } from '../services/storageService';
// Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds
@@ -56,6 +60,7 @@ const withRetry = async (
interface UseMetadataProps {
id: string;
type: string;
+ addonId?: string;
}
interface UseMetadataReturn {
@@ -90,7 +95,7 @@ interface UseMetadataReturn {
imdbId: string | null;
}
-export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn => {
+export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
const [metadata, setMetadata] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
@@ -113,6 +118,8 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const [recommendations, setRecommendations] = useState([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
const [imdbId, setImdbId] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
// Add hook for persistent seasons
const { getSeason, saveSeason } = usePersistentSeasons();
@@ -150,8 +157,12 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
if (isEpisode) {
setEpisodeStreams(updateState);
+ // Turn off loading when we get streams
+ setLoadingEpisodeStreams(false);
} else {
setGroupedStreams(updateState);
+ // Turn off loading when we get streams
+ setLoadingStreams(false);
}
} else {
logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
@@ -173,35 +184,80 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Loading indicators should probably be managed based on callbacks completing.
};
- const processExternalSource = async (sourceType: string, promise: Promise, isEpisode = false) => {
+ 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';
- try {
- logger.log(`🔍 [${logPrefix}:${sourceType}] Starting fetch`);
- const result = await promise;
- logger.log(`✅ [${logPrefix}:${sourceType}] Completed in ${Date.now() - sourceStartTime}ms`);
-
- if (Object.keys(result).length > 0) {
- const totalStreams = Object.values(result).reduce((acc, group: any) => acc + (group.streams?.length || 0), 0);
- logger.log(`📦 [${logPrefix}:${sourceType}] Found ${totalStreams} streams`);
-
- const updateState = (prevState: GroupedStreams) => {
- logger.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`);
- return { ...prevState, ...result };
- };
+ 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, 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 {
- logger.log(`⚠️ [${logPrefix}:${sourceType}] No streams found`);
+ console.log(`⚠️ [processExternalSource:${sourceType}] No streams found after ${processingTime}ms`);
+ return {};
}
- return result;
} catch (error) {
- logger.error(`❌ [${logPrefix}:${sourceType}] Error:`, error);
+ console.error(`❌ [processExternalSource:${sourceType}] Error:`, error);
return {};
}
};
@@ -356,7 +412,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
if (writers.length > 0) {
(formattedMovie as any).creators = writers;
- (formattedMovie as StreamingContent & { writer: string }).writer = writers.join(', ');
+ (formattedMovie as any).writer = writers;
}
}
} catch (error) {
@@ -459,7 +515,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Load content with timeout and retry
withRetry(async () => {
const result = await withTimeout(
- catalogService.getContentDetails(type, actualId),
+ catalogService.getEnhancedContentDetails(type, actualId, addonId),
API_TIMEOUT
);
// Store the actual ID used (could be IMDB)
@@ -485,8 +541,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
cacheService.setMetadata(id, type, content.value);
if (type === 'series') {
- // Load series data in parallel with other data
+ // Load series data after the enhanced metadata is processed
+ setTimeout(() => {
loadSeriesData().catch(console.error);
+ }, 100);
}
} else {
throw new Error('Content not found');
@@ -509,6 +567,67 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const loadSeriesData = async () => {
setLoadingSeasons(true);
try {
+ // First check if we have episode data from the addon
+ const addonVideos = metadata?.videos;
+ if (addonVideos && Array.isArray(addonVideos) && addonVideos.length > 0) {
+ logger.log(`🎬 Found ${addonVideos.length} episodes from addon metadata for ${metadata?.name || id}`);
+
+ // Group addon episodes by season
+ const groupedAddonEpisodes: GroupedEpisodes = {};
+
+ addonVideos.forEach((video: any) => {
+ const seasonNumber = video.season || 1;
+ const episodeNumber = video.episode || video.number || 1;
+
+ if (!groupedAddonEpisodes[seasonNumber]) {
+ groupedAddonEpisodes[seasonNumber] = [];
+ }
+
+ // Convert addon episode format to our Episode interface
+ const episode: Episode = {
+ id: video.id,
+ name: video.name || video.title || `Episode ${episodeNumber}`,
+ overview: video.overview || video.description || '',
+ 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,
+ vote_average: parseFloat(video.rating) || 0,
+ runtime: undefined,
+ episodeString: `S${seasonNumber.toString().padStart(2, '0')}E${episodeNumber.toString().padStart(2, '0')}`,
+ stremioId: video.id,
+ season_poster_path: null
+ };
+
+ groupedAddonEpisodes[seasonNumber].push(episode);
+ });
+
+ // Sort episodes within each season
+ Object.keys(groupedAddonEpisodes).forEach(season => {
+ groupedAddonEpisodes[parseInt(season)].sort((a, b) => a.episode_number - b.episode_number);
+ });
+
+ logger.log(`📺 Processed addon episodes into ${Object.keys(groupedAddonEpisodes).length} seasons`);
+ setGroupedEpisodes(groupedAddonEpisodes);
+
+ // Set the first available season
+ const seasons = Object.keys(groupedAddonEpisodes).map(Number);
+ const firstSeason = Math.min(...seasons);
+ logger.log(`📺 Setting season ${firstSeason} as selected (${groupedAddonEpisodes[firstSeason]?.length || 0} episodes)`);
+ setSelectedSeason(firstSeason);
+ setEpisodes(groupedAddonEpisodes[firstSeason] || []);
+
+ // Try to get TMDB ID for additional metadata (cast, etc.) but don't override episodes
+ const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
+ if (tmdbIdResult) {
+ setTmdbId(tmdbIdResult);
+ }
+
+ return; // Use addon episodes, skip TMDB loading
+ }
+
+ // Fallback to TMDB if no addon episodes
+ logger.log('📺 No addon episodes found, falling back to TMDB');
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
if (tmdbIdResult) {
setTmdbId(tmdbIdResult);
@@ -535,14 +654,61 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Get the first available season as fallback
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
- // Get saved season from persistence, fallback to first season if not found
- const persistedSeason = getSeason(id, firstSeason);
+ // Check for watch progress to auto-select season
+ let selectedSeasonNumber = firstSeason;
- // Set the selected season from persistence
- setSelectedSeason(persistedSeason);
+ try {
+ // Check watch progress for auto-season selection
+ const allProgress = await storageService.getAllWatchProgress();
+
+ // Find the most recently watched episode for this series
+ let mostRecentEpisodeId = '';
+ let mostRecentTimestamp = 0;
+
+ Object.entries(allProgress).forEach(([key, progress]) => {
+ if (key.includes(`series:${id}:`)) {
+ const episodeId = key.split(`series:${id}:`)[1];
+ if (progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
+ mostRecentTimestamp = progress.lastUpdated;
+ mostRecentEpisodeId = episodeId;
+ }
+ }
+ });
+
+ if (mostRecentEpisodeId) {
+ // Parse season number from episode ID
+ const parts = mostRecentEpisodeId.split(':');
+ if (parts.length === 3) {
+ const watchProgressSeason = parseInt(parts[1], 10);
+ if (transformedEpisodes[watchProgressSeason]) {
+ selectedSeasonNumber = watchProgressSeason;
+ logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for ${mostRecentEpisodeId}`);
+ }
+ } else {
+ // Try to find episode by stremioId to get season
+ const allEpisodesList = Object.values(transformedEpisodes).flat();
+ const episode = allEpisodesList.find(ep => ep.stremioId === mostRecentEpisodeId);
+ if (episode) {
+ selectedSeasonNumber = episode.season_number;
+ logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for episode with stremioId ${mostRecentEpisodeId}`);
+ }
+ }
+ } else {
+ // No watch progress found, use persistent storage as fallback
+ selectedSeasonNumber = getSeason(id, firstSeason);
+ logger.log(`[useMetadata] No watch progress found, using persistent season ${selectedSeasonNumber}`);
+ }
+ } catch (error) {
+ logger.error('[useMetadata] Error checking watch progress for season selection:', error);
+ // Fall back to persistent storage
+ selectedSeasonNumber = getSeason(id, firstSeason);
+ }
+
+ // Set the selected season
+ setSelectedSeason(selectedSeasonNumber);
// Set episodes for the selected season
- setEpisodes(transformedEpisodes[persistedSeason] || []);
+ setEpisodes(transformedEpisodes[selectedSeasonNumber] || []);
}
} catch (error) {
console.error('Failed to load episodes:', error);
@@ -575,39 +741,71 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log('🚀 [loadStreams] START - Loading streams for:', id);
updateLoadingState();
- // Always clear streams first to ensure we don't show stale data
- setGroupedStreams({});
-
- // Get TMDB ID for external sources first before starting parallel requests
+ // Get TMDB ID for external sources and determine the correct ID for Stremio addons
console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
let tmdbId;
+ let stremioId = id; // Default to original ID
+
if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1];
console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId);
+
+ // Try to get IMDb ID from metadata first, then convert if needed
+ if (metadata?.imdb_id) {
+ stremioId = metadata.imdb_id;
+ console.log('✅ [loadStreams] Using IMDb ID from metadata for Stremio:', stremioId);
+ } else if (imdbId) {
+ stremioId = imdbId;
+ console.log('✅ [loadStreams] Using stored IMDb ID for Stremio:', stremioId);
+ } else {
+ // Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
+ try {
+ let externalIds = null;
+ if (type === 'movie') {
+ const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
+ externalIds = movieDetails?.external_ids;
+ } else if (type === 'series') {
+ externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
+ }
+
+ if (externalIds?.imdb_id) {
+ stremioId = externalIds.imdb_id;
+ console.log('✅ [loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId);
+ } else {
+ console.log('⚠️ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId);
+ }
+ } catch (error) {
+ console.log('⚠️ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error);
+ }
+ }
} else if (id.startsWith('tt')) {
- // This is an IMDB ID
+ // This is already an IMDB ID, perfect for Stremio
+ stremioId = id;
console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId);
} else {
tmdbId = id;
- console.log('ℹ️ [loadStreams] Using ID as TMDB ID:', tmdbId);
+ stremioId = id;
+ console.log('ℹ️ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
}
-
- console.log('🔄 [loadStreams] Starting stream requests');
- // Start Stremio request using the callback method
- processStremioSource(type, id, false);
+ // 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[] = [hdrezkaPromise];
- // No external sources are used anymore
- const fetchPromises: Promise[] = [];
-
- // Wait only for external promises now (none in this case)
+ // 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[] = []; // No external sources
+ const sourceTypes: string[] = ['hdrezka'];
results.forEach((result, index) => {
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
console.log(`📊 [loadStreams:${source}] Status: ${result.status}`);
@@ -634,15 +832,15 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
return prev;
});
+ // Add a delay before marking loading as complete to give Stremio addons more time
+ setTimeout(() => {
+ setLoadingStreams(false);
+ }, 10000); // 10 second delay to allow streams to load
+
} catch (error) {
console.error('❌ [loadStreams] Failed to load streams:', error);
setError('Failed to load streams');
- } finally {
- // Loading is now complete when external sources finish, Stremio updates happen independently.
- // We need a better way to track overall completion if we want a final 'FINISHED' log.
- const endTime = Date.now() - startTime;
- console.log(`🏁 [loadStreams] External sources FINISHED in ${endTime}ms`);
- setLoadingStreams(false); // Mark loading=false, but Stremio might still be working
+ setLoadingStreams(false);
}
};
@@ -652,42 +850,76 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
updateEpisodeLoadingState();
- // Get TMDB ID for external sources first before starting parallel requests
+ // Get TMDB ID for external sources and determine the correct ID for Stremio addons
console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
let tmdbId;
+ let stremioEpisodeId = episodeId; // Default to original episode ID
+
if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1];
console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
+
+ // Try to get IMDb ID from metadata first, then convert if needed
+ if (metadata?.imdb_id) {
+ // Replace the series ID in episodeId with the IMDb ID
+ const [, season, episode] = episodeId.split(':');
+ stremioEpisodeId = `series:${metadata.imdb_id}:${season}:${episode}`;
+ console.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId);
+ } else if (imdbId) {
+ const [, season, episode] = episodeId.split(':');
+ stremioEpisodeId = `series:${imdbId}:${season}:${episode}`;
+ console.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId);
+ } else {
+ // Convert TMDB ID to IMDb ID for Stremio addons
+ try {
+ const externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
+
+ if (externalIds?.imdb_id) {
+ const [, season, episode] = episodeId.split(':');
+ stremioEpisodeId = `series:${externalIds.imdb_id}:${season}:${episode}`;
+ console.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId);
+ } else {
+ console.log('⚠️ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using original episode ID:', stremioEpisodeId);
+ }
+ } catch (error) {
+ console.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using original episode ID:', error);
+ }
+ }
} else if (id.startsWith('tt')) {
- // This is an IMDB ID
+ // This is already an IMDB ID, perfect for Stremio
console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
} else {
tmdbId = id;
- console.log('ℹ️ [loadEpisodeStreams] Using ID as TMDB ID:', tmdbId);
+ console.log('ℹ️ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
}
- // Extract episode info from the episodeId
+ // Extract episode info from the episodeId for logging
const [, season, episode] = episodeId.split(':');
const episodeQuery = `?s=${season}&e=${episode}`;
console.log(`ℹ️ [loadEpisodeStreams] Episode query: ${episodeQuery}`);
console.log('🔄 [loadEpisodeStreams] Starting stream requests');
- const fetchPromises: Promise[] = [];
+ // Start Stremio request using the converted episode ID format
+ console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId);
+ processStremioSource('series', stremioEpisodeId, true);
- // Start Stremio request using the callback method
- processStremioSource('series', episodeId, true);
+ // Add HDRezka source for episodes
+ const hdrezkaEpisodePromise = processExternalSource('hdrezka',
+ processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true),
+ true
+ );
+
+ const fetchPromises: Promise[] = [hdrezkaEpisodePromise];
- // No external sources are used anymore
-
- // Wait only for external promises now (none in this case)
+ // 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[] = []; // No external sources
+ const sourceTypes: string[] = ['hdrezka'];
results.forEach((result, index) => {
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`);
@@ -699,31 +931,23 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log('🧮 [loadEpisodeStreams] Summary:');
console.log(' Total time for external sources:', totalTime + 'ms');
- // Log the final states - might not include all Stremio addons yet
- console.log('📦 [loadEpisodeStreams] Current combined streams count:',
- Object.keys(episodeStreams).length > 0 ?
- Object.values(episodeStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
- 0
- );
-
- // Cache the final streams state - Might be incomplete
- setEpisodeStreams(prev => {
- // Cache episode streams - maybe incrementally?
- setPreloadedEpisodeStreams(currentPreloaded => ({
- ...currentPreloaded,
- [episodeId]: prev
+ // Update preloaded episode streams for future use
+ if (Object.keys(episodeStreams).length > 0) {
+ setPreloadedEpisodeStreams(prev => ({
+ ...prev,
+ [episodeId]: { ...episodeStreams }
}));
- return prev;
- });
+ }
+
+ // Add a delay before marking loading as complete to give addons more time
+ setTimeout(() => {
+ setLoadingEpisodeStreams(false);
+ }, 10000); // 10 second delay to allow streams to load
} catch (error) {
console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
setError('Failed to load episode streams');
- } finally {
- // Loading is now complete when external sources finish
- const endTime = Date.now() - startTime;
- console.log(`🏁 [loadEpisodeStreams] External sources FINISHED in ${endTime}ms`);
- setLoadingEpisodeStreams(false); // Mark loading=false, but Stremio might still be working
+ setLoadingEpisodeStreams(false);
}
};
@@ -770,6 +994,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
loadMetadata();
}, [id, type]);
+ // Re-run series data loading when metadata updates with videos
+ useEffect(() => {
+ if (metadata && type === 'series' && metadata.videos && metadata.videos.length > 0) {
+ logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
+ loadSeriesData().catch(console.error);
+ }
+ }, [metadata?.videos, type]);
+
const loadRecommendations = useCallback(async () => {
if (!tmdbId) return;
diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts
index 7ef53e6..89f0d6d 100644
--- a/src/hooks/useMetadataAnimations.ts
+++ b/src/hooks/useMetadataAnimations.ts
@@ -6,242 +6,156 @@ import {
withSpring,
Easing,
useAnimatedScrollHandler,
- interpolate,
- Extrapolate,
+ runOnUI,
} from 'react-native-reanimated';
const { width, height } = Dimensions.get('window');
-// Animation constants
-const springConfig = {
- damping: 20,
- mass: 1,
- stiffness: 100
+// Highly optimized animation configurations
+const fastSpring = {
+ damping: 15,
+ mass: 0.8,
+ stiffness: 150,
};
-// Animation timing constants for staggered appearance
-const ANIMATION_DELAY_CONSTANTS = {
- HERO: 100,
- LOGO: 250,
- PROGRESS: 350,
- GENRES: 400,
- BUTTONS: 450,
- CONTENT: 500
+const ultraFastSpring = {
+ damping: 12,
+ mass: 0.6,
+ stiffness: 200,
+};
+
+// Ultra-optimized easing functions
+const easings = {
+ fast: Easing.out(Easing.quad),
+ ultraFast: Easing.out(Easing.linear),
+ natural: Easing.bezier(0.2, 0, 0.2, 1),
};
export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => {
- // Animation values for screen entrance
- const screenScale = useSharedValue(0.92);
- const screenOpacity = useSharedValue(0);
+ // Consolidated entrance animations - start with visible values for Android compatibility
+ const screenOpacity = useSharedValue(1);
+ const contentOpacity = useSharedValue(1);
- // Animation values for hero section
- const heroHeight = useSharedValue(height * 0.5);
- const heroScale = useSharedValue(1.05);
- const heroOpacity = useSharedValue(0);
-
- // Animation values for content
- const contentTranslateY = useSharedValue(60);
+ // Combined hero animations
+ const heroOpacity = useSharedValue(1);
+ const heroScale = useSharedValue(1); // Start at 1 for Android compatibility
+ const heroHeightValue = useSharedValue(height * 0.5);
- // Animation values for logo
- const logoOpacity = useSharedValue(0);
- const logoScale = useSharedValue(0.9);
+ // Combined UI element animations
+ const uiElementsOpacity = useSharedValue(1);
+ const uiElementsTranslateY = useSharedValue(0);
- // Animation values for progress
- const watchProgressOpacity = useSharedValue(0);
- const watchProgressScaleY = useSharedValue(0);
+ // Progress animation - simplified to single value
+ const progressOpacity = useSharedValue(0);
- // Animation values for genres
- const genresOpacity = useSharedValue(0);
- const genresTranslateY = useSharedValue(20);
-
- // Animation values for buttons
- const buttonsOpacity = useSharedValue(0);
- const buttonsTranslateY = useSharedValue(30);
-
- // Scroll values for parallax effect
+ // Scroll values - minimal
const scrollY = useSharedValue(0);
- const dampedScrollY = useSharedValue(0);
+ const headerProgress = useSharedValue(0); // Single value for all header animations
- // Header animation values
- const headerOpacity = useSharedValue(0);
- const headerElementsY = useSharedValue(-10);
- const headerElementsOpacity = useSharedValue(0);
-
- // Start entrance animation
+ // Static header elements Y for performance
+ const staticHeaderElementsY = useSharedValue(0);
+
+ // Ultra-fast entrance sequence - batch animations for better performance
useEffect(() => {
- // Use a timeout to ensure the animations starts after the component is mounted
- const animationTimeout = setTimeout(() => {
- // 1. First animate the container
- screenScale.value = withSpring(1, springConfig);
- screenOpacity.value = withSpring(1, springConfig);
+ // Batch all entrance animations to run simultaneously
+ const enterAnimations = () => {
+ 'worklet';
- // 2. Then animate the hero section with a slight delay
- setTimeout(() => {
- heroOpacity.value = withSpring(1, {
- damping: 14,
- stiffness: 80
- });
- heroScale.value = withSpring(1, {
- damping: 18,
- stiffness: 100
- });
- }, ANIMATION_DELAY_CONSTANTS.HERO);
+ // Start with slightly reduced values and animate to full visibility
+ screenOpacity.value = withTiming(1, {
+ duration: 250,
+ easing: easings.fast
+ });
- // 3. Then animate the logo
- setTimeout(() => {
- logoOpacity.value = withSpring(1, {
- damping: 12,
- stiffness: 100
- });
- logoScale.value = withSpring(1, {
- damping: 14,
- stiffness: 90
- });
- }, ANIMATION_DELAY_CONSTANTS.LOGO);
+ heroOpacity.value = withTiming(1, {
+ duration: 300,
+ easing: easings.fast
+ });
- // 4. Then animate the watch progress if applicable
- setTimeout(() => {
- if (watchProgress && watchProgress.duration > 0) {
- watchProgressOpacity.value = withSpring(1, {
- damping: 14,
- stiffness: 100
- });
- watchProgressScaleY.value = withSpring(1, {
- damping: 18,
- stiffness: 120
- });
- }
- }, ANIMATION_DELAY_CONSTANTS.PROGRESS);
+ heroScale.value = withSpring(1, ultraFastSpring);
- // 5. Then animate the genres
- setTimeout(() => {
- genresOpacity.value = withSpring(1, {
- damping: 14,
- stiffness: 100
- });
- genresTranslateY.value = withSpring(0, {
- damping: 18,
- stiffness: 120
- });
- }, ANIMATION_DELAY_CONSTANTS.GENRES);
+ uiElementsOpacity.value = withTiming(1, {
+ duration: 400,
+ easing: easings.natural
+ });
- // 6. Then animate the buttons
- setTimeout(() => {
- buttonsOpacity.value = withSpring(1, {
- damping: 14,
- stiffness: 100
- });
- buttonsTranslateY.value = withSpring(0, {
- damping: 18,
- stiffness: 120
- });
- }, ANIMATION_DELAY_CONSTANTS.BUTTONS);
+ uiElementsTranslateY.value = withSpring(0, fastSpring);
- // 7. Finally animate the content section
- setTimeout(() => {
- contentTranslateY.value = withSpring(0, {
- damping: 25,
- mass: 1,
- stiffness: 100
- });
- }, ANIMATION_DELAY_CONSTANTS.CONTENT);
- }, 50); // Small timeout to ensure component is fully mounted
-
- return () => clearTimeout(animationTimeout);
+ contentOpacity.value = withTiming(1, {
+ duration: 350,
+ easing: easings.fast
+ });
+ };
+
+ // Use runOnUI for better performance
+ runOnUI(enterAnimations)();
}, []);
- // Effect to animate watch progress when it changes
+ // Optimized watch progress animation
useEffect(() => {
- if (watchProgress && watchProgress.duration > 0) {
- watchProgressOpacity.value = withSpring(1, {
- mass: 0.2,
- stiffness: 100,
- damping: 14
- });
- watchProgressScaleY.value = withSpring(1, {
- mass: 0.3,
- stiffness: 120,
- damping: 18
- });
- } else {
- watchProgressOpacity.value = withSpring(0, {
- mass: 0.2,
- stiffness: 100,
- damping: 14
- });
- watchProgressScaleY.value = withSpring(0, {
- mass: 0.3,
- stiffness: 120,
- damping: 18
- });
- }
- }, [watchProgress, watchProgressOpacity, watchProgressScaleY]);
+ const hasProgress = watchProgress && watchProgress.duration > 0;
+
+ const updateProgress = () => {
+ 'worklet';
+ progressOpacity.value = withTiming(hasProgress ? 1 : 0, {
+ duration: hasProgress ? 200 : 150,
+ easing: easings.fast
+ });
+ };
+
+ runOnUI(updateProgress)();
+ }, [watchProgress]);
- // Effect to animate logo when it's available
- const animateLogo = (hasLogo: boolean) => {
- if (hasLogo) {
- logoOpacity.value = withTiming(1, {
- duration: 500,
- easing: Easing.out(Easing.ease)
- });
- } else {
- logoOpacity.value = withTiming(0, {
- duration: 200,
- easing: Easing.in(Easing.ease)
- });
- }
- };
-
- // Scroll handler
+ // Ultra-optimized scroll handler with minimal calculations
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
+ 'worklet';
+
const rawScrollY = event.contentOffset.y;
scrollY.value = rawScrollY;
-
- // Apply spring-like damping for smoother transitions
- dampedScrollY.value = withTiming(rawScrollY, {
- duration: 300,
- easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve
- });
- // Update header opacity based on scroll position
- const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer
- if (rawScrollY > headerThreshold) {
- headerOpacity.value = withTiming(1, { duration: 200 });
- headerElementsY.value = withTiming(0, { duration: 300 });
- headerElementsOpacity.value = withTiming(1, { duration: 450 });
- } else {
- headerOpacity.value = withTiming(0, { duration: 150 });
- headerElementsY.value = withTiming(-10, { duration: 200 });
- headerElementsOpacity.value = withTiming(0, { duration: 200 });
+ // Single calculation for header threshold
+ const threshold = height * 0.4 - safeAreaTop;
+ const progress = rawScrollY > threshold ? 1 : 0;
+
+ // Use single progress value for all header animations
+ if (headerProgress.value !== progress) {
+ headerProgress.value = withTiming(progress, {
+ duration: progress ? 200 : 150,
+ easing: easings.ultraFast
+ });
}
},
});
return {
- // Animated values
- screenScale,
+ // Optimized shared values - reduced count
screenOpacity,
- heroHeight,
- heroScale,
+ contentOpacity,
heroOpacity,
- contentTranslateY,
- logoOpacity,
- logoScale,
- watchProgressOpacity,
- watchProgressScaleY,
- genresOpacity,
- genresTranslateY,
- buttonsOpacity,
- buttonsTranslateY,
+ heroScale,
+ uiElementsOpacity,
+ uiElementsTranslateY,
+ progressOpacity,
scrollY,
- dampedScrollY,
- headerOpacity,
- headerElementsY,
- headerElementsOpacity,
+ headerProgress,
+
+ // Computed values for compatibility (derived from optimized values)
+ get heroHeight() { return heroHeightValue; },
+ get logoOpacity() { return uiElementsOpacity; },
+ get buttonsOpacity() { return uiElementsOpacity; },
+ get buttonsTranslateY() { return uiElementsTranslateY; },
+ get contentTranslateY() { return uiElementsTranslateY; },
+ get watchProgressOpacity() { return progressOpacity; },
+ get watchProgressWidth() { return progressOpacity; }, // Reuse for width animation
+ get headerOpacity() { return headerProgress; },
+ get headerElementsY() {
+ return staticHeaderElementsY; // Use pre-created shared value
+ },
+ get headerElementsOpacity() { return headerProgress; },
// Functions
scrollHandler,
- animateLogo,
+ animateLogo: () => {}, // Simplified - no separate logo animation
};
};
\ No newline at end of file
diff --git a/src/hooks/useMetadataAssets.ts b/src/hooks/useMetadataAssets.ts
index 0b44bf0..b52cc65 100644
--- a/src/hooks/useMetadataAssets.ts
+++ b/src/hooks/useMetadataAssets.ts
@@ -196,7 +196,15 @@ export const useMetadataAssets = (
else if (shouldFetchLogo && logoFetchInProgress.current) {
logger.log(`[useMetadataAssets:Logo] Skipping logo fetch because logoFetchInProgress is true.`);
}
- }, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference]); // Added tmdbLanguagePreference dependency
+ }, [
+ id,
+ type,
+ imdbId,
+ metadata?.logo, // Depend on the logo value itself, not the whole object
+ settings.logoSourcePreference,
+ settings.tmdbLanguagePreference,
+ setMetadata // Keep setMetadata, but ensure it's memoized in parent
+ ]);
// Fetch banner image based on logo source preference - optimized version
useEffect(() => {
@@ -217,9 +225,15 @@ export const useMetadataAssets = (
const fetchBanner = async () => {
logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`);
- setLoadingBanner(true);
- setBannerImage(null); // Clear existing banner to prevent mixed sources
- setBannerSource(null); // Clear source tracking
+ 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}`);
+ }
let finalBanner: string | null = null;
let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default';
@@ -411,17 +425,31 @@ export const useMetadataAssets = (
// Set the final state
logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`);
- setBannerImage(finalBanner);
- setBannerSource(bannerSourceType); // Track the source of the final image
+
+ // 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`);
+ }
+
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;
- setBannerImage(defaultBanner);
- setBannerSource('default');
- logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`);
+
+ // 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`);
+ }
} finally {
logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`);
setLoadingBanner(false);
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
index 3f55f4e..ec3b664 100644
--- a/src/hooks/useSettings.ts
+++ b/src/hooks/useSettings.ts
@@ -34,6 +34,9 @@ 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
}
export const DEFAULT_SETTINGS: AppSettings = {
@@ -50,6 +53,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
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
};
const SETTINGS_STORAGE_KEY = 'app_settings';
diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts
new file mode 100644
index 0000000..299efd4
--- /dev/null
+++ b/src/hooks/useTraktAutosync.ts
@@ -0,0 +1,369 @@
+import { useCallback, useRef, useEffect } from 'react';
+import { useTraktIntegration } from './useTraktIntegration';
+import { useTraktAutosyncSettings } from './useTraktAutosyncSettings';
+import { TraktContentData } from '../services/traktService';
+import { storageService } from '../services/storageService';
+import { logger } from '../utils/logger';
+
+interface TraktAutosyncOptions {
+ id: string;
+ type: 'movie' | 'series';
+ title: string;
+ year: number | string; // Allow both for compatibility
+ imdbId: string;
+ // For episodes
+ season?: number;
+ episode?: number;
+ showTitle?: string;
+ showYear?: number | string; // Allow both for compatibility
+ showImdbId?: string;
+ episodeId?: string;
+}
+
+export function useTraktAutosync(options: TraktAutosyncOptions) {
+ const {
+ isAuthenticated,
+ startWatching,
+ updateProgress,
+ stopWatching
+ } = useTraktIntegration();
+
+ const { settings: autosyncSettings } = useTraktAutosyncSettings();
+
+ const hasStartedWatching = useRef(false);
+ const hasStopped = useRef(false); // New: Track if we've already stopped for this session
+ const isSessionComplete = useRef(false); // New: Track if session is completely finished (scrobbled)
+ const lastSyncTime = useRef(0);
+ const lastSyncProgress = useRef(0);
+ const sessionKey = useRef(null);
+ const unmountCount = useRef(0);
+ const lastStopCall = useRef(0); // New: Track last stop call timestamp
+
+ // Generate a unique session key for this content instance
+ useEffect(() => {
+ const contentKey = options.type === 'movie'
+ ? `movie:${options.imdbId}`
+ : `episode:${options.imdbId}:${options.season}:${options.episode}`;
+ sessionKey.current = `${contentKey}:${Date.now()}`;
+
+ // Reset all session state for new content
+ hasStartedWatching.current = false;
+ hasStopped.current = false;
+ isSessionComplete.current = false;
+ lastStopCall.current = 0;
+
+ logger.log(`[TraktAutosync] Session started for: ${sessionKey.current}`);
+
+ return () => {
+ unmountCount.current++;
+ logger.log(`[TraktAutosync] Component unmount #${unmountCount.current} for: ${sessionKey.current}`);
+ };
+ }, [options.imdbId, options.season, options.episode, options.type]);
+
+ // Build Trakt content data from options
+ const buildContentData = useCallback((): TraktContentData => {
+ // Ensure year is a number and valid
+ const parseYear = (year: number | string | undefined): number => {
+ if (!year) return 0;
+ if (typeof year === 'number') return year;
+ const parsed = parseInt(year.toString(), 10);
+ return isNaN(parsed) ? 0 : parsed;
+ };
+
+ const numericYear = parseYear(options.year);
+ const numericShowYear = parseYear(options.showYear);
+
+ // Validate required fields
+ if (!options.title || !options.imdbId) {
+ logger.warn('[TraktAutosync] Missing required fields:', { title: options.title, imdbId: options.imdbId });
+ }
+
+ if (options.type === 'movie') {
+ return {
+ type: 'movie',
+ imdbId: options.imdbId,
+ title: options.title,
+ year: numericYear
+ };
+ } else {
+ return {
+ type: 'episode',
+ imdbId: options.imdbId,
+ title: options.title,
+ year: numericYear,
+ season: options.season,
+ episode: options.episode,
+ showTitle: options.showTitle || options.title,
+ showYear: numericShowYear || numericYear,
+ showImdbId: options.showImdbId || options.imdbId
+ };
+ }
+ }, [options]);
+
+ // Start watching (scrobble start)
+ const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
+ logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`);
+
+ if (!isAuthenticated || !autosyncSettings.enabled) {
+ logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
+ return;
+ }
+
+ // PREVENT SESSION RESTART: Don't start if session is complete (scrobbled)
+ if (isSessionComplete.current) {
+ logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session is complete, preventing any restart`);
+ return;
+ }
+
+ // PREVENT SESSION RESTART: Don't start if we've already stopped this session
+ if (hasStopped.current) {
+ logger.log(`[TraktAutosync] Skipping handlePlaybackStart: session already stopped, preventing restart`);
+ return;
+ }
+
+ if (hasStartedWatching.current) {
+ logger.log(`[TraktAutosync] Skipping handlePlaybackStart: already started=${hasStartedWatching.current}`);
+ return;
+ }
+
+ if (duration <= 0) {
+ logger.log(`[TraktAutosync] Skipping handlePlaybackStart: invalid duration (${duration})`);
+ return;
+ }
+
+ try {
+ const progressPercent = (currentTime / duration) * 100;
+ const contentData = buildContentData();
+
+ const success = await startWatching(contentData, progressPercent);
+ if (success) {
+ hasStartedWatching.current = true;
+ hasStopped.current = false; // Reset stop flag when starting
+ logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
+ }
+ } catch (error) {
+ logger.error('[TraktAutosync] Error starting watch:', error);
+ }
+ }, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]);
+
+ // Sync progress during playback
+ const handleProgressUpdate = useCallback(async (
+ currentTime: number,
+ duration: number,
+ force: boolean = false
+ ) => {
+ if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
+ return;
+ }
+
+ // Skip if session is already complete
+ if (isSessionComplete.current) {
+ return;
+ }
+
+ try {
+ const progressPercent = (currentTime / duration) * 100;
+ const now = Date.now();
+
+ // Use the user's configured sync frequency
+ const timeSinceLastSync = now - lastSyncTime.current;
+ const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
+
+ if (!force && timeSinceLastSync < autosyncSettings.syncFrequency && progressDiff < 5) {
+ return;
+ }
+
+ const contentData = buildContentData();
+ const success = await updateProgress(contentData, progressPercent, force);
+
+ if (success) {
+ lastSyncTime.current = now;
+ lastSyncProgress.current = progressPercent;
+
+ // Update local storage sync status
+ await storageService.updateTraktSyncStatus(
+ options.id,
+ options.type,
+ true,
+ progressPercent,
+ options.episodeId
+ );
+
+ logger.log(`[TraktAutosync] Synced progress ${progressPercent.toFixed(1)}%: ${contentData.title}`);
+ }
+ } catch (error) {
+ logger.error('[TraktAutosync] Error syncing progress:', error);
+ }
+ }, [isAuthenticated, autosyncSettings.enabled, autosyncSettings.syncFrequency, updateProgress, buildContentData, options]);
+
+ // Handle playback end/pause
+ const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' = 'ended') => {
+ const now = Date.now();
+
+ logger.log(`[TraktAutosync] handlePlaybackEnd called: reason=${reason}, time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, started=${hasStartedWatching.current}, stopped=${hasStopped.current}, complete=${isSessionComplete.current}, session=${sessionKey.current}, unmountCount=${unmountCount.current}`);
+
+ if (!isAuthenticated || !autosyncSettings.enabled) {
+ logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
+ return;
+ }
+
+ // ENHANCED DEDUPLICATION: Check if session is already complete
+ if (isSessionComplete.current) {
+ logger.log(`[TraktAutosync] Session already complete, skipping end call (reason: ${reason})`);
+ return;
+ }
+
+ // ENHANCED DEDUPLICATION: Check if we've already stopped this session
+ // However, allow updates if the new progress is significantly higher (>5% improvement)
+ if (hasStopped.current) {
+ const currentProgressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
+ const progressImprovement = currentProgressPercent - lastSyncProgress.current;
+
+ if (progressImprovement > 5) {
+ 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;
+ } else {
+ logger.log(`[TraktAutosync] Already stopped this session, skipping duplicate call (reason: ${reason})`);
+ return;
+ }
+ }
+
+ // ENHANCED DEDUPLICATION: Prevent rapid successive calls (within 5 seconds)
+ if (now - lastStopCall.current < 5000) {
+ logger.log(`[TraktAutosync] Ignoring rapid successive stop call within 5 seconds (reason: ${reason})`);
+ return;
+ }
+
+ // Skip rapid unmount calls (likely from React strict mode or component remounts)
+ if (reason === 'unmount' && unmountCount.current > 1) {
+ logger.log(`[TraktAutosync] Skipping duplicate unmount call #${unmountCount.current}`);
+ return;
+ }
+
+ try {
+ let progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
+ logger.log(`[TraktAutosync] Initial progress calculation: ${progressPercent.toFixed(1)}%`);
+
+ // For unmount calls, always use the highest available progress
+ // Check current progress, last synced progress, and local storage progress
+ if (reason === 'unmount') {
+ let maxProgress = progressPercent;
+
+ // Check last synced progress
+ if (lastSyncProgress.current > maxProgress) {
+ maxProgress = lastSyncProgress.current;
+ }
+
+ // 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;
+ if (savedProgressPercent > maxProgress) {
+ maxProgress = savedProgressPercent;
+ }
+ }
+ } catch (error) {
+ logger.error('[TraktAutosync] Error checking saved progress:', error);
+ }
+
+ if (maxProgress !== progressPercent) {
+ logger.log(`[TraktAutosync] Using highest available progress for unmount: ${maxProgress.toFixed(1)}% (current: ${progressPercent.toFixed(1)}%, last synced: ${lastSyncProgress.current.toFixed(1)}%)`);
+ progressPercent = maxProgress;
+ } else {
+ logger.log(`[TraktAutosync] Current progress is already highest: ${progressPercent.toFixed(1)}%`);
+ }
+ }
+
+ // If we have valid progress but no started session, force start one first
+ if (!hasStartedWatching.current && progressPercent > 1) {
+ logger.log(`[TraktAutosync] Force starting session for progress: ${progressPercent.toFixed(1)}%`);
+ const contentData = buildContentData();
+ const success = await startWatching(contentData, progressPercent);
+ if (success) {
+ hasStartedWatching.current = true;
+ logger.log(`[TraktAutosync] Force started watching: ${contentData.title}`);
+ }
+ }
+
+ // 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) {
+ logger.log(`[TraktAutosync] Skipping unmount stop for ${options.title} - too early (${progressPercent.toFixed(1)}%)`);
+ return;
+ }
+
+ // Mark stop attempt and update timestamp
+ lastStopCall.current = now;
+ hasStopped.current = true;
+
+ const contentData = buildContentData();
+
+ // Use stopWatching for proper scrobble stop
+ const success = await stopWatching(contentData, progressPercent);
+
+ if (success) {
+ // Update local storage sync status
+ await storageService.updateTraktSyncStatus(
+ options.id,
+ options.type,
+ true,
+ progressPercent,
+ options.episodeId
+ );
+
+ // Mark session as complete if high progress (scrobbled)
+ if (progressPercent >= 80) {
+ isSessionComplete.current = true;
+ logger.log(`[TraktAutosync] Session marked as complete (scrobbled) at ${progressPercent.toFixed(1)}%`);
+ }
+
+ logger.log(`[TraktAutosync] Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
+ } else {
+ // If stop failed, reset the stop flag so we can try again later
+ hasStopped.current = false;
+ logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`);
+ }
+
+ // Reset state only for natural end or very high progress unmounts
+ if (reason === 'ended' || progressPercent >= 80) {
+ hasStartedWatching.current = false;
+ lastSyncTime.current = 0;
+ lastSyncProgress.current = 0;
+ logger.log(`[TraktAutosync] Reset session state for ${reason} at ${progressPercent.toFixed(1)}%`);
+ }
+
+ } catch (error) {
+ logger.error('[TraktAutosync] Error ending watch:', error);
+ // Reset stop flag on error so we can try again
+ hasStopped.current = false;
+ }
+ }, [isAuthenticated, autosyncSettings.enabled, stopWatching, buildContentData, options]);
+
+ // Reset state (useful when switching content)
+ const resetState = useCallback(() => {
+ hasStartedWatching.current = false;
+ hasStopped.current = false;
+ isSessionComplete.current = false;
+ lastSyncTime.current = 0;
+ lastSyncProgress.current = 0;
+ unmountCount.current = 0;
+ sessionKey.current = null;
+ lastStopCall.current = 0;
+ logger.log(`[TraktAutosync] Manual state reset for: ${options.title}`);
+ }, [options.title]);
+
+ return {
+ isAuthenticated,
+ handlePlaybackStart,
+ handleProgressUpdate,
+ handlePlaybackEnd,
+ resetState
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/useTraktAutosyncSettings.ts b/src/hooks/useTraktAutosyncSettings.ts
new file mode 100644
index 0000000..29d3268
--- /dev/null
+++ b/src/hooks/useTraktAutosyncSettings.ts
@@ -0,0 +1,165 @@
+import { useState, useEffect, useCallback } from 'react';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { useTraktIntegration } from './useTraktIntegration';
+import { logger } from '../utils/logger';
+
+const TRAKT_AUTOSYNC_ENABLED_KEY = '@trakt_autosync_enabled';
+const TRAKT_SYNC_FREQUENCY_KEY = '@trakt_sync_frequency';
+const TRAKT_COMPLETION_THRESHOLD_KEY = '@trakt_completion_threshold';
+
+export interface TraktAutosyncSettings {
+ enabled: boolean;
+ syncFrequency: number; // in milliseconds
+ completionThreshold: number; // percentage (80-95)
+}
+
+const DEFAULT_SETTINGS: TraktAutosyncSettings = {
+ enabled: true,
+ syncFrequency: 60000, // 60 seconds
+ completionThreshold: 95, // 95%
+};
+
+export function useTraktAutosyncSettings() {
+ const {
+ isAuthenticated,
+ syncAllProgress,
+ fetchAndMergeTraktProgress
+ } = useTraktIntegration();
+
+ const [settings, setSettings] = useState(DEFAULT_SETTINGS);
+ const [isLoading, setIsLoading] = useState(true);
+ const [isSyncing, setIsSyncing] = useState(false);
+
+ // Load settings from storage
+ const loadSettings = useCallback(async () => {
+ try {
+ setIsLoading(true);
+ const [enabled, frequency, threshold] = await Promise.all([
+ AsyncStorage.getItem(TRAKT_AUTOSYNC_ENABLED_KEY),
+ AsyncStorage.getItem(TRAKT_SYNC_FREQUENCY_KEY),
+ AsyncStorage.getItem(TRAKT_COMPLETION_THRESHOLD_KEY)
+ ]);
+
+ setSettings({
+ enabled: enabled !== null ? JSON.parse(enabled) : DEFAULT_SETTINGS.enabled,
+ syncFrequency: frequency ? parseInt(frequency, 10) : DEFAULT_SETTINGS.syncFrequency,
+ completionThreshold: threshold ? parseInt(threshold, 10) : DEFAULT_SETTINGS.completionThreshold,
+ });
+ } catch (error) {
+ logger.error('[useTraktAutosyncSettings] Error loading settings:', error);
+ setSettings(DEFAULT_SETTINGS);
+ } finally {
+ setIsLoading(false);
+ }
+ }, []);
+
+ // Save individual setting
+ const saveSetting = useCallback(async (key: string, value: any) => {
+ try {
+ await AsyncStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ logger.error('[useTraktAutosyncSettings] Error saving setting:', error);
+ }
+ }, []);
+
+ // Update autosync enabled status
+ const setAutosyncEnabled = useCallback(async (enabled: boolean) => {
+ try {
+ await saveSetting(TRAKT_AUTOSYNC_ENABLED_KEY, enabled);
+ setSettings(prev => ({ ...prev, enabled }));
+ logger.log(`[useTraktAutosyncSettings] Autosync ${enabled ? 'enabled' : 'disabled'}`);
+ } catch (error) {
+ logger.error('[useTraktAutosyncSettings] Error updating autosync enabled:', error);
+ }
+ }, [saveSetting]);
+
+ // Update sync frequency
+ const setSyncFrequency = useCallback(async (frequency: number) => {
+ try {
+ await saveSetting(TRAKT_SYNC_FREQUENCY_KEY, frequency);
+ setSettings(prev => ({ ...prev, syncFrequency: frequency }));
+ logger.log(`[useTraktAutosyncSettings] Sync frequency updated to ${frequency}ms`);
+ } catch (error) {
+ logger.error('[useTraktAutosyncSettings] Error updating sync frequency:', error);
+ }
+ }, [saveSetting]);
+
+ // Update completion threshold
+ const setCompletionThreshold = useCallback(async (threshold: number) => {
+ try {
+ await saveSetting(TRAKT_COMPLETION_THRESHOLD_KEY, threshold);
+ setSettings(prev => ({ ...prev, completionThreshold: threshold }));
+ logger.log(`[useTraktAutosyncSettings] Completion threshold updated to ${threshold}%`);
+ } catch (error) {
+ logger.error('[useTraktAutosyncSettings] Error updating completion threshold:', error);
+ }
+ }, [saveSetting]);
+
+ // Manual sync all progress
+ const performManualSync = useCallback(async (): Promise => {
+ if (!isAuthenticated) {
+ logger.warn('[useTraktAutosyncSettings] Cannot sync: not authenticated');
+ return false;
+ }
+
+ try {
+ setIsSyncing(true);
+ logger.log('[useTraktAutosyncSettings] Starting manual sync...');
+
+ // First, fetch and merge Trakt progress with local
+ const fetchSuccess = await fetchAndMergeTraktProgress();
+
+ // Then, sync any unsynced local progress to Trakt
+ const uploadSuccess = await syncAllProgress();
+
+ // Consider sync successful if either:
+ // 1. We successfully fetched from Trakt (main purpose of manual sync)
+ // 2. We successfully uploaded local progress to Trakt
+ // 3. Everything was already in sync (uploadSuccess = false is OK if fetchSuccess = true)
+ const overallSuccess = fetchSuccess || uploadSuccess;
+
+ logger.log(`[useTraktAutosyncSettings] Manual sync ${overallSuccess ? 'completed' : 'failed'}`);
+ return overallSuccess;
+ } catch (error) {
+ logger.error('[useTraktAutosyncSettings] Error during manual sync:', error);
+ return false;
+ } finally {
+ setIsSyncing(false);
+ }
+ }, [isAuthenticated, syncAllProgress, fetchAndMergeTraktProgress]);
+
+ // Get formatted sync frequency options
+ const getSyncFrequencyOptions = useCallback(() => [
+ { label: 'Every 30 seconds', value: 30000 },
+ { label: 'Every minute', value: 60000 },
+ { label: 'Every 2 minutes', value: 120000 },
+ { label: 'Every 5 minutes', value: 300000 },
+ ], []);
+
+ // Get formatted completion threshold options
+ const getCompletionThresholdOptions = useCallback(() => [
+ { label: '80% complete', value: 80 },
+ { label: '85% complete', value: 85 },
+ { label: '90% complete', value: 90 },
+ { label: '95% complete', value: 95 },
+ ], []);
+
+ // Load settings on mount
+ useEffect(() => {
+ loadSettings();
+ }, [loadSettings]);
+
+ return {
+ settings,
+ isLoading,
+ isSyncing,
+ isAuthenticated,
+ setAutosyncEnabled,
+ setSyncFrequency,
+ setCompletionThreshold,
+ performManualSync,
+ getSyncFrequencyOptions,
+ getCompletionThresholdOptions,
+ loadSettings
+ };
+}
\ No newline at end of file
diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts
index 692cdaa..3b61d44 100644
--- a/src/hooks/useTraktIntegration.ts
+++ b/src/hooks/useTraktIntegration.ts
@@ -1,5 +1,16 @@
import { useState, useEffect, useCallback } from 'react';
-import { traktService, TraktUser, TraktWatchedItem } from '../services/traktService';
+import { AppState, AppStateStatus } from 'react-native';
+import {
+ traktService,
+ TraktUser,
+ TraktWatchedItem,
+ TraktWatchlistItem,
+ TraktCollectionItem,
+ TraktRatingItem,
+ TraktContentData,
+ TraktPlaybackItem
+} from '../services/traktService';
+import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
export function useTraktIntegration() {
@@ -8,19 +19,30 @@ export function useTraktIntegration() {
const [userProfile, setUserProfile] = useState(null);
const [watchedMovies, setWatchedMovies] = useState([]);
const [watchedShows, setWatchedShows] = useState([]);
+ const [watchlistMovies, setWatchlistMovies] = useState([]);
+ const [watchlistShows, setWatchlistShows] = useState([]);
+ const [collectionMovies, setCollectionMovies] = useState([]);
+ const [collectionShows, setCollectionShows] = useState([]);
+ const [continueWatching, setContinueWatching] = useState([]);
+ const [ratedContent, setRatedContent] = useState([]);
const [lastAuthCheck, setLastAuthCheck] = useState(Date.now());
// Check authentication status
const checkAuthStatus = useCallback(async () => {
+ logger.log('[useTraktIntegration] checkAuthStatus called');
setIsLoading(true);
try {
const authenticated = await traktService.isAuthenticated();
+ logger.log(`[useTraktIntegration] Authentication check result: ${authenticated}`);
setIsAuthenticated(authenticated);
if (authenticated) {
+ logger.log('[useTraktIntegration] User is authenticated, fetching profile...');
const profile = await traktService.getUserProfile();
+ logger.log(`[useTraktIntegration] User profile: ${profile.username}`);
setUserProfile(profile);
} else {
+ logger.log('[useTraktIntegration] User is not authenticated');
setUserProfile(null);
}
@@ -46,8 +68,8 @@ export function useTraktIntegration() {
setIsLoading(true);
try {
const [movies, shows] = await Promise.all([
- traktService.getWatchedMovies(),
- traktService.getWatchedShows()
+ traktService.getWatchedMoviesWithImages(),
+ traktService.getWatchedShowsWithImages()
]);
setWatchedMovies(movies);
setWatchedShows(shows);
@@ -58,6 +80,41 @@ export function useTraktIntegration() {
}
}, [isAuthenticated]);
+ // Load all collections (watchlist, collection, continue watching, ratings)
+ const loadAllCollections = useCallback(async () => {
+ if (!isAuthenticated) return;
+
+ setIsLoading(true);
+ try {
+ const [
+ watchlistMovies,
+ watchlistShows,
+ collectionMovies,
+ collectionShows,
+ continueWatching,
+ ratings
+ ] = await Promise.all([
+ traktService.getWatchlistMoviesWithImages(),
+ traktService.getWatchlistShowsWithImages(),
+ traktService.getCollectionMoviesWithImages(),
+ traktService.getCollectionShowsWithImages(),
+ traktService.getPlaybackProgressWithImages(),
+ traktService.getRatingsWithImages()
+ ]);
+
+ setWatchlistMovies(watchlistMovies);
+ setWatchlistShows(watchlistShows);
+ setCollectionMovies(collectionMovies);
+ setCollectionShows(collectionShows);
+ setContinueWatching(continueWatching);
+ setRatedContent(ratings);
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error loading all collections:', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }, [isAuthenticated]);
+
// Check if a movie is watched
const isMovieWatched = useCallback(async (imdbId: string): Promise => {
if (!isAuthenticated) return false;
@@ -128,6 +185,224 @@ export function useTraktIntegration() {
}
}, [isAuthenticated, loadWatchedItems]);
+ // Start watching content (scrobble start)
+ const startWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise => {
+ if (!isAuthenticated) return false;
+
+ try {
+ return await traktService.scrobbleStart(contentData, progress);
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error starting watch:', error);
+ return false;
+ }
+ }, [isAuthenticated]);
+
+ // Update progress while watching (scrobble pause)
+ const updateProgress = useCallback(async (
+ contentData: TraktContentData,
+ progress: number,
+ force: boolean = false
+ ): Promise => {
+ if (!isAuthenticated) return false;
+
+ try {
+ return await traktService.scrobblePause(contentData, progress, force);
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error updating progress:', error);
+ return false;
+ }
+ }, [isAuthenticated]);
+
+ // Stop watching content (scrobble stop)
+ const stopWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise => {
+ if (!isAuthenticated) return false;
+
+ try {
+ return await traktService.scrobbleStop(contentData, progress);
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error stopping watch:', error);
+ return false;
+ }
+ }, [isAuthenticated]);
+
+ // Sync progress to Trakt (legacy method)
+ const syncProgress = useCallback(async (
+ contentData: TraktContentData,
+ progress: number,
+ force: boolean = false
+ ): Promise => {
+ if (!isAuthenticated) return false;
+
+ try {
+ return await traktService.syncProgressToTrakt(contentData, progress, force);
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error syncing progress:', error);
+ return false;
+ }
+ }, [isAuthenticated]);
+
+ // Get playback progress from Trakt
+ const getTraktPlaybackProgress = useCallback(async (type?: 'movies' | 'shows'): Promise => {
+ logger.log(`[useTraktIntegration] getTraktPlaybackProgress called - isAuthenticated: ${isAuthenticated}, type: ${type || 'all'}`);
+
+ if (!isAuthenticated) {
+ logger.log('[useTraktIntegration] getTraktPlaybackProgress: Not authenticated');
+ return [];
+ }
+
+ try {
+ logger.log('[useTraktIntegration] Calling traktService.getPlaybackProgress...');
+ const result = await traktService.getPlaybackProgress(type);
+ logger.log(`[useTraktIntegration] traktService.getPlaybackProgress returned ${result.length} items`);
+ return result;
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error getting playback progress:', error);
+ return [];
+ }
+ }, [isAuthenticated]);
+
+ // Sync all local progress to Trakt
+ const syncAllProgress = useCallback(async (): Promise => {
+ if (!isAuthenticated) return false;
+
+ try {
+ const unsyncedProgress = await storageService.getUnsyncedProgress();
+ logger.log(`[useTraktIntegration] Found ${unsyncedProgress.length} unsynced progress entries`);
+
+ let syncedCount = 0;
+ const batchSize = 5; // Process in smaller batches
+ const delayBetweenBatches = 2000; // 2 seconds between batches
+
+ // Process items in batches to avoid overwhelming the API
+ for (let i = 0; i < unsyncedProgress.length; i += batchSize) {
+ const batch = unsyncedProgress.slice(i, i + batchSize);
+
+ // Process batch items with individual error handling
+ const batchPromises = batch.map(async (item) => {
+ try {
+ // Build content data from stored progress
+ const contentData: TraktContentData = {
+ type: item.type as 'movie' | 'episode',
+ imdbId: item.id,
+ title: 'Unknown', // We don't store title in progress, this would need metadata lookup
+ year: 0,
+ season: item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined,
+ episode: item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined
+ };
+
+ const progressPercent = (item.progress.currentTime / item.progress.duration) * 100;
+
+ const success = await traktService.syncProgressToTrakt(contentData, progressPercent, true);
+ if (success) {
+ await storageService.updateTraktSyncStatus(item.id, item.type, true, progressPercent, item.episodeId);
+ return true;
+ }
+ return false;
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error syncing individual progress:', error);
+ return false;
+ }
+ });
+
+ // Wait for batch to complete
+ const batchResults = await Promise.all(batchPromises);
+ syncedCount += batchResults.filter(result => result).length;
+
+ // Delay between batches to avoid rate limiting
+ if (i + batchSize < unsyncedProgress.length) {
+ await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
+ }
+ }
+
+ logger.log(`[useTraktIntegration] Synced ${syncedCount}/${unsyncedProgress.length} progress entries`);
+ return syncedCount > 0;
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error syncing all progress:', error);
+ return false;
+ }
+ }, [isAuthenticated]);
+
+ // Fetch and merge Trakt progress with local progress
+ const fetchAndMergeTraktProgress = useCallback(async (): Promise => {
+ 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`);
+
+ // Process playback progress (in-progress items)
+ for (const item of traktProgress) {
+ try {
+ let id: string;
+ let type: string;
+ let episodeId: string | undefined;
+
+ 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(
+ id,
+ type,
+ item.progress,
+ item.paused_at,
+ episodeId
+ );
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error merging individual Trakt progress:', error);
+ }
+ }
+
+ // Process watched movies (100% completed)
+ for (const movie of watchedMovies) {
+ try {
+ 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(
+ id,
+ 'movie',
+ 100, // 100% progress for watched items
+ watchedAt
+ );
+ }
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error merging watched movie:', error);
+ }
+ }
+
+ logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} progress items + ${watchedMovies.length} watched movies`);
+ return true;
+ } catch (error) {
+ logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error);
+ return false;
+ }
+ }, [isAuthenticated, getTraktPlaybackProgress]);
+
// Initialize and check auth status
useEffect(() => {
checkAuthStatus();
@@ -140,18 +415,98 @@ export function useTraktIntegration() {
}
}, [isAuthenticated, loadWatchedItems]);
+ // Auto-sync when authenticated changes OR when auth status is refreshed
+ 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');
+ }
+ // Small delay to ensure storage subscribers are notified
+ setTimeout(() => {
+ logger.log('[useTraktIntegration] Trakt progress merge completed, UI should refresh');
+ }, 100);
+ });
+ }
+ }, [isAuthenticated, fetchAndMergeTraktProgress]);
+
+ // App focus sync - sync when app comes back into focus (much smarter than periodic)
+ useEffect(() => {
+ if (!isAuthenticated) return;
+
+ 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 => {
+ logger.error('[useTraktIntegration] App focus sync failed:', error);
+ });
+ }
+ };
+
+ const subscription = AppState.addEventListener('change', handleAppStateChange);
+
+ return () => {
+ subscription?.remove();
+ };
+ }, [isAuthenticated, fetchAndMergeTraktProgress]);
+
+ // 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');
+ }
+ });
+ }
+ }, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]);
+
+ // Manual force sync function for testing/troubleshooting
+ const forceSyncTraktProgress = useCallback(async (): Promise => {
+ logger.log('[useTraktIntegration] Manual force sync triggered');
+ if (!isAuthenticated) {
+ logger.log('[useTraktIntegration] Cannot force sync - not authenticated');
+ return false;
+ }
+ return await fetchAndMergeTraktProgress();
+ }, [isAuthenticated, fetchAndMergeTraktProgress]);
+
return {
isAuthenticated,
isLoading,
userProfile,
watchedMovies,
watchedShows,
+ watchlistMovies,
+ watchlistShows,
+ collectionMovies,
+ collectionShows,
+ continueWatching,
+ ratedContent,
checkAuthStatus,
loadWatchedItems,
+ loadAllCollections,
isMovieWatched,
isEpisodeWatched,
markMovieAsWatched,
markEpisodeAsWatched,
- refreshAuthStatus
+ refreshAuthStatus,
+ startWatching,
+ updateProgress,
+ stopWatching,
+ syncProgress, // legacy
+ getTraktPlaybackProgress,
+ syncAllProgress,
+ fetchAndMergeTraktProgress,
+ forceSyncTraktProgress // For manual testing
};
}
\ No newline at end of file
diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts
index 7c71539..a4ec9d5 100644
--- a/src/hooks/useWatchProgress.ts
+++ b/src/hooks/useWatchProgress.ts
@@ -1,5 +1,6 @@
import { useState, useCallback, useEffect } from 'react';
import { useFocusEffect } from '@react-navigation/native';
+import { useTraktContext } from '../contexts/TraktContext';
import { logger } from '../utils/logger';
import { storageService } from '../services/storageService';
@@ -8,6 +9,8 @@ interface WatchProgressData {
duration: number;
lastUpdated: number;
episodeId?: string;
+ traktSynced?: boolean;
+ traktProgress?: number;
}
export const useWatchProgress = (
@@ -17,6 +20,7 @@ export const useWatchProgress = (
episodes: any[] = []
) => {
const [watchProgress, setWatchProgress] = useState(null);
+ const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
// Function to get episode details from episodeId
const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => {
@@ -52,7 +56,7 @@ export const useWatchProgress = (
return null;
}, [episodes]);
- // Load watch progress
+ // Enhanced load watch progress with Trakt integration
const loadWatchProgress = useCallback(async () => {
try {
if (id && type) {
@@ -87,75 +91,39 @@ export const useWatchProgress = (
if (episodeId) {
const progress = await storageService.getWatchProgress(id, type, episodeId);
if (progress) {
- const progressPercent = (progress.currentTime / progress.duration) * 100;
-
- // If current episode is finished (≥95%), try to find next unwatched episode
- if (progressPercent >= 95) {
- const currentEpNum = getEpisodeNumber(episodeId);
- if (currentEpNum && episodes.length > 0) {
- // Find the next episode
- const nextEpisode = episodes.find(ep => {
- // First check in same season
- if (ep.season_number === currentEpNum.season && ep.episode_number > currentEpNum.episode) {
- const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
- const epProgress = seriesProgresses.find(p => p.episodeId === epId);
- if (!epProgress) return true;
- const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100;
- return percent < 95;
- }
- // Then check next seasons
- if (ep.season_number > currentEpNum.season) {
- const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
- const epProgress = seriesProgresses.find(p => p.episodeId === epId);
- if (!epProgress) return true;
- const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100;
- return percent < 95;
- }
- return false;
- });
-
- if (nextEpisode) {
- const nextEpisodeId = nextEpisode.stremioId ||
- `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`;
- const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId);
- if (nextProgress) {
- setWatchProgress({ ...nextProgress, episodeId: nextEpisodeId });
- } else {
- setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: nextEpisodeId });
- }
- return;
- }
- }
- // If no next episode found or current episode is finished, show no progress
- setWatchProgress(null);
- return;
- }
-
- // If current episode is not finished, show its progress
- setWatchProgress({ ...progress, episodeId });
+ // Always show the current episode progress when viewing it specifically
+ // This allows HeroSection to properly display watched state
+ setWatchProgress({
+ ...progress,
+ episodeId,
+ traktSynced: progress.traktSynced,
+ traktProgress: progress.traktProgress
+ });
} else {
setWatchProgress(null);
}
} else {
- // Find the first unfinished episode
- const unfinishedEpisode = episodes.find(ep => {
- const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
- const progress = seriesProgresses.find(p => p.episodeId === epId);
- if (!progress) return true;
- const percent = (progress.progress.currentTime / progress.progress.duration) * 100;
- return percent < 95;
- });
-
- if (unfinishedEpisode) {
- const epId = unfinishedEpisode.stremioId ||
- `${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`;
- const progress = await storageService.getWatchProgress(id, type, epId);
- if (progress) {
- setWatchProgress({ ...progress, episodeId: epId });
- } else {
- setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: epId });
- }
+ // FIXED: Find the most recently watched episode instead of first unfinished
+ // Sort by lastUpdated timestamp (most recent first)
+ const sortedProgresses = seriesProgresses.sort((a, b) =>
+ b.progress.lastUpdated - a.progress.lastUpdated
+ );
+
+ if (sortedProgresses.length > 0) {
+ // Use the most recently watched episode
+ const mostRecentProgress = sortedProgresses[0];
+ const progress = mostRecentProgress.progress;
+
+ logger.log(`[useWatchProgress] Using most recent progress for ${mostRecentProgress.episodeId}, updated at ${new Date(progress.lastUpdated).toLocaleString()}`);
+
+ setWatchProgress({
+ ...progress,
+ episodeId: mostRecentProgress.episodeId,
+ traktSynced: progress.traktSynced,
+ traktProgress: progress.traktProgress
+ });
} else {
+ // No watched episodes found
setWatchProgress(null);
}
}
@@ -163,12 +131,14 @@ export const useWatchProgress = (
// For movies
const progress = await storageService.getWatchProgress(id, type, episodeId);
if (progress && progress.currentTime > 0) {
- const progressPercent = (progress.currentTime / progress.duration) * 100;
- if (progressPercent >= 95) {
- setWatchProgress(null);
- } else {
- setWatchProgress({ ...progress, episodeId });
- }
+ // Always show progress data, even if watched (≥95%)
+ // The HeroSection will handle the "watched" state display
+ setWatchProgress({
+ ...progress,
+ episodeId,
+ traktSynced: progress.traktSynced,
+ traktProgress: progress.traktProgress
+ });
} else {
setWatchProgress(null);
}
@@ -180,21 +150,33 @@ export const useWatchProgress = (
}
}, [id, type, episodeId, episodes]);
- // Function to get play button text based on watch progress
+ // Enhanced function to get play button text with Trakt awareness
const getPlayButtonText = useCallback(() => {
if (!watchProgress || watchProgress.currentTime <= 0) {
return 'Play';
}
- // Consider episode complete if progress is >= 95%
+ // Consider episode complete if progress is >= 85%
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
- if (progressPercent >= 95) {
+ if (progressPercent >= 85) {
return 'Play';
}
+ // If we have Trakt data and it differs significantly from local, show "Resume"
+ // but the UI will show the discrepancy
return 'Resume';
}, [watchProgress]);
+ // Subscribe to storage changes for real-time updates
+ useEffect(() => {
+ const unsubscribe = storageService.subscribeToWatchProgressUpdates(() => {
+ logger.log('[useWatchProgress] Storage updated, reloading progress');
+ loadWatchProgress();
+ });
+
+ return unsubscribe;
+ }, [loadWatchProgress]);
+
// Initial load
useEffect(() => {
loadWatchProgress();
@@ -207,6 +189,16 @@ export const useWatchProgress = (
}, [loadWatchProgress])
);
+ // Re-load when Trakt authentication status changes
+ useEffect(() => {
+ if (isTraktAuthenticated !== undefined) {
+ // Small delay to ensure Trakt context is fully initialized
+ setTimeout(() => {
+ loadWatchProgress();
+ }, 100);
+ }
+ }, [isTraktAuthenticated, loadWatchProgress]);
+
return {
watchProgress,
getEpisodeDetails,
diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx
index 6720cda..86d41b1 100644
--- a/src/navigation/AppNavigator.tsx
+++ b/src/navigation/AppNavigator.tsx
@@ -21,7 +21,7 @@ import DiscoverScreen from '../screens/DiscoverScreen';
import LibraryScreen from '../screens/LibraryScreen';
import SettingsScreen from '../screens/SettingsScreen';
import MetadataScreen from '../screens/MetadataScreen';
-import VideoPlayer from '../screens/VideoPlayer';
+import VideoPlayer from '../components/player/VideoPlayer';
import CatalogScreen from '../screens/CatalogScreen';
import AddonsScreen from '../screens/AddonsScreen';
import SearchScreen from '../screens/SearchScreen';
@@ -39,6 +39,7 @@ 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';
// Stack navigator types
export type RootStackParamList = {
@@ -53,6 +54,7 @@ export type RootStackParamList = {
id: string;
type: string;
episodeId?: string;
+ addonId?: string;
};
Streams: {
id: string;
@@ -74,9 +76,12 @@ export type RootStackParamList = {
quality?: string;
year?: number;
streamProvider?: string;
+ streamName?: string;
id?: string;
type?: string;
episodeId?: string;
+ imdbId?: string;
+ availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
};
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
Credits: { mediaId: string; mediaType: string };
@@ -97,6 +102,7 @@ export type RootStackParamList = {
LogoSourceSettings: undefined;
ThemeSettings: undefined;
ProfilesSettings: undefined;
+ InternalProvidersSettings: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp;
@@ -657,10 +663,45 @@ const MainTabs = () => {
);
};
+// Create custom fade animation interpolator for MetadataScreen
+const customFadeInterpolator = ({ current, layouts }: any) => {
+ return {
+ cardStyle: {
+ opacity: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, 1],
+ }),
+ transform: [
+ {
+ scale: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0.95, 1],
+ }),
+ },
+ ],
+ },
+ overlayStyle: {
+ opacity: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [0, 0.3],
+ }),
+ },
+ };
+};
+
// Stack Navigator
const AppNavigator = () => {
const { currentTheme } = useTheme();
+ // Handle Android-specific optimizations
+ useEffect(() => {
+ if (Platform.OS === 'android') {
+ // Ensure consistent background color for Android
+ StatusBar.setBackgroundColor('transparent', true);
+ StatusBar.setTranslucent(true);
+ }
+ }, []);
+
return (
{
barStyle="light-content"
/>
-
-
-
-
-
-
-
-
-
-
+ {
+ return {
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [layouts.screen.width, 0],
+ }),
+ },
+ ],
+ backgroundColor: currentTheme.colors.darkBackground,
+ },
+ };
+ },
+ }),
}}
- />
-
-
-
-
-
-
-
-
-
-
-
-
+ >
+
+
+
+
+
+
+ {
+ return {
+ cardStyle: {
+ transform: [
+ {
+ translateX: current.progress.interpolate({
+ inputRange: [0, 1],
+ outputRange: [layouts.screen.width, 0],
+ }),
+ },
+ ],
+ opacity: current.progress.interpolate({
+ inputRange: [0, 0.3, 1],
+ outputRange: [0, 0.85, 1],
+ }),
+ },
+ };
+ },
+ }),
+ contentStyle: {
+ backgroundColor: currentTheme.colors.darkBackground,
+ },
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
diff --git a/src/screens/AddonsScreen.tsx b/src/screens/AddonsScreen.tsx
index 1761a9a..c9b39bb 100644
--- a/src/screens/AddonsScreen.tsx
+++ b/src/screens/AddonsScreen.tsx
@@ -29,7 +29,9 @@ import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
-import { BlurView } from 'expo-blur';
+import { BlurView as ExpoBlurView } from 'expo-blur';
+import { BlurView as CommunityBlurView } from '@react-native-community/blur';
+import Constants, { ExecutionEnvironment } from 'expo-constants';
import axios from 'axios';
import { useTheme } from '../contexts/ThemeContext';
@@ -552,6 +554,36 @@ const createStyles = (colors: any) => StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
},
+ blurOverlay: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0,0,0,0.4)',
+ },
+ androidBlurContainer: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ androidBlur: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ androidFallbackBlur: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'black',
+ },
});
const AddonsScreen = () => {
@@ -1233,7 +1265,24 @@ const AddonsScreen = () => {
setAddonDetails(null);
}}
>
-
+
+ {Platform.OS === 'ios' ? (
+
+ ) : (
+ Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? (
+
+ ) : (
+
+
+
+ )
+ )}
{addonDetails && (
<>
@@ -1332,7 +1381,7 @@ const AddonsScreen = () => {
>
)}
-
+
);
diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx
index 597ce7c..e80afcc 100644
--- a/src/screens/CatalogScreen.tsx
+++ b/src/screens/CatalogScreen.tsx
@@ -41,9 +41,38 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Screen dimensions and grid layout
const { width } = Dimensions.get('window');
-const NUM_COLUMNS = 3;
+
+// 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
+
+ // 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);
+
+ // 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);
+
+ return {
+ numColumns,
+ itemWidth: finalItemWidth
+ };
+};
+
+const catalogLayout = calculateCatalogLayout(width);
+const NUM_COLUMNS = catalogLayout.numColumns;
const ITEM_MARGIN = SPACING.sm;
-const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS;
+const ITEM_WIDTH = catalogLayout.itemWidth;
// Create a styles creator function that accepts the theme colors
const createStyles = (colors: any) => StyleSheet.create({
@@ -79,13 +108,9 @@ const createStyles = (colors: any) => StyleSheet.create({
padding: SPACING.lg,
paddingTop: SPACING.sm,
},
- columnWrapper: {
- justifyContent: 'space-between',
- },
item: {
- width: ITEM_WIDTH,
marginBottom: SPACING.lg,
- borderRadius: 12,
+ borderRadius: 8,
overflow: 'hidden',
backgroundColor: colors.elevation2,
shadowColor: '#000',
@@ -97,8 +122,8 @@ const createStyles = (colors: any) => StyleSheet.create({
poster: {
width: '100%',
aspectRatio: 2/3,
- borderTopLeftRadius: 12,
- borderTopRightRadius: 12,
+ borderTopLeftRadius: 8,
+ borderTopRightRadius: 8,
backgroundColor: colors.elevation3,
},
itemContent: {
@@ -168,13 +193,60 @@ const CatalogScreen: React.FC = ({ route, navigation }) => {
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState(null);
const [dataSource, setDataSource] = useState(DataSource.STREMIO_ADDONS);
+ const [actualCatalogName, setActualCatalogName] = useState(null);
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const styles = createStyles(colors);
const isDarkMode = true;
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
- const displayName = getCustomName(addonId || '', type || '', id || '', originalName || '');
+
+ // Create display name with proper type suffix
+ const createDisplayName = (catalogName: string) => {
+ if (!catalogName) return '';
+
+ // Check if the name already includes content type indicators
+ const lowerName = catalogName.toLowerCase();
+ const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
+
+ // If the name already contains type information, return as is
+ if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
+ return catalogName;
+ }
+
+ // Otherwise append the content type
+ return `${catalogName} ${contentType}`;
+ };
+
+ // Use actual catalog name if available, otherwise fallback to custom name or original name
+ const displayName = actualCatalogName
+ ? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
+ : getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
+ (genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
+ `${type.charAt(0).toUpperCase() + type.slice(1)}s`);
+
+ // Add effect to get the actual catalog name from addon manifest
+ useEffect(() => {
+ const getActualCatalogName = async () => {
+ if (addonId && type && id) {
+ try {
+ const manifests = await stremioService.getInstalledAddonsAsync();
+ const addon = manifests.find(a => a.id === addonId);
+
+ if (addon && addon.catalogs) {
+ const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
+ if (catalog && catalog.name) {
+ setActualCatalogName(catalog.name);
+ }
+ }
+ } catch (error) {
+ logger.error('Failed to get actual catalog name:', error);
+ }
+ }
+ };
+
+ getActualCatalogName();
+ }, [addonId, type, id]);
// Add effect to get data source preference when component mounts
useEffect(() => {
@@ -415,11 +487,23 @@ const CatalogScreen: React.FC = ({ route, navigation }) => {
}
}, [loading, hasMore, page, loadItems]);
- const renderItem = useCallback(({ item }: { item: Meta }) => {
+ 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 rightMargin = isLastInRow ? 0 : SPACING.sm;
+
return (
navigation.navigate('Metadata', { id: item.id, type: item.type })}
+ style={[
+ styles.item,
+ {
+ marginRight: rightMargin,
+ // For 2 columns, ensure items fill the available space properly
+ width: NUM_COLUMNS === 2 ? ITEM_WIDTH : ITEM_WIDTH
+ }
+ ]}
+ onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })}
activeOpacity={0.7}
>
= ({ route, navigation }) => {
);
- }, [navigation, styles]);
+ }, [navigation, styles, NUM_COLUMNS, ITEM_WIDTH]);
const renderEmptyState = () => (
@@ -542,6 +626,7 @@ const CatalogScreen: React.FC = ({ route, navigation }) => {
renderItem={renderItem}
keyExtractor={(item) => `${item.id}-${item.type}`}
numColumns={NUM_COLUMNS}
+ key={NUM_COLUMNS}
refreshControl={
= ({ route, navigation }) => {
) : null
}
contentContainerStyle={styles.list}
- columnWrapperStyle={styles.columnWrapper}
showsVerticalScrollIndicator={false}
/>
) : renderEmptyState()}
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index 1b82a1e..e622643 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect, useCallback, useRef } from 'react';
+import React, { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
import {
View,
Text,
@@ -6,7 +6,6 @@ import {
FlatList,
TouchableOpacity,
ActivityIndicator,
- RefreshControl,
SafeAreaView,
StatusBar,
useColorScheme,
@@ -16,12 +15,14 @@ import {
Platform,
Image,
Modal,
- Pressable
+ Pressable,
+ Alert
} from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService';
+import { stremioService } from '../services/stremioService';
import { Stream } from '../types/metadata';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@@ -60,6 +61,7 @@ import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
import homeStyles, { sharedStyles } from '../styles/homeStyles';
import { useTheme } from '../contexts/ThemeContext';
import type { Theme } from '../contexts/ThemeContext';
+import * as ScreenOrientation from 'expo-screen-orientation';
// Define interfaces for our data
interface Category {
@@ -83,7 +85,7 @@ interface ContinueWatchingRef {
refresh: () => Promise;
}
-const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
+const DropUpMenu = React.memo(({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
const translateY = useSharedValue(300);
const opacity = useSharedValue(0);
const isDarkMode = useColorScheme() === 'dark';
@@ -98,9 +100,15 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
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 = Gesture.Pan()
+ const gesture = useMemo(() => Gesture.Pan()
.onStart(() => {
// Store initial position if needed
})
@@ -124,7 +132,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
translateY.value = withTiming(0, { duration: 300 });
opacity.value = withTiming(1, { duration: 200 });
}
- });
+ }), [onClose]);
const overlayStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
@@ -138,7 +146,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white,
}));
- const menuOptions = [
+ const menuOptions = useMemo(() => [
{
icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
@@ -159,7 +167,12 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
label: 'Share',
action: 'share'
}
- ];
+ ], [item.inLibrary]);
+
+ const handleOptionSelect = useCallback((action: string) => {
+ onOptionSelect(action);
+ onClose();
+ }, [onOptionSelect, onClose]);
return (
{
- onOptionSelect(option.action);
- onClose();
- }}
+ onPress={() => handleOptionSelect(option.action)}
>
);
-};
+});
-const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
+const ContentItem = React.memo(({ item: initialItem, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
const [localItem, setLocalItem] = useState(initialItem);
const [isWatched, setIsWatched] = useState(false);
@@ -256,8 +266,8 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
setIsWatched(prev => !prev);
break;
case 'playlist':
- break;
case 'share':
+ // These options don't have implementations yet
break;
}
}, [localItem]);
@@ -266,16 +276,20 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
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
);
- setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
+ if (isInLibrary !== localItem.inLibrary) {
+ setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
+ }
});
return () => unsubscribe();
@@ -330,15 +344,24 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
-
+ {menuVisible && (
+
+ )}
>
);
-};
+}, (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
+ );
+});
// Sample categories (real app would get these from API)
const SAMPLE_CATEGORIES: Category[] = [
@@ -347,7 +370,7 @@ const SAMPLE_CATEGORIES: Category[] = [
{ id: 'channel', name: 'Channels' },
];
-const SkeletonCatalog = () => {
+const SkeletonCatalog = React.memo(() => {
const { currentTheme } = useTheme();
return (
@@ -356,7 +379,7 @@ const SkeletonCatalog = () => {
);
-};
+});
const HomeScreen = () => {
const navigation = useNavigation>();
@@ -364,17 +387,16 @@ const HomeScreen = () => {
const { currentTheme } = useTheme();
const continueWatchingRef = useRef(null);
const { settings } = useSettings();
+ const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef(null);
const [hasContinueWatching, setHasContinueWatching] = useState(false);
- const {
- catalogs,
- loading: catalogsLoading,
- refreshing: catalogsRefreshing,
- refreshCatalogs
- } = useHomeCatalogs();
+ const [catalogs, setCatalogs] = useState([]);
+ const [catalogsLoading, setCatalogsLoading] = useState(true);
+ const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
+ const totalCatalogsRef = useRef(0);
const {
featuredContent,
@@ -384,9 +406,119 @@ const HomeScreen = () => {
refreshFeatured
} = useFeaturedContent();
+ // Progressive catalog loading function
+ const loadCatalogsProgressively = useCallback(async () => {
+ setCatalogsLoading(true);
+ setCatalogs([]);
+ setLoadedCatalogCount(0);
+
+ try {
+ const addons = await catalogService.getAllAddons();
+
+ // Create placeholder array with proper order and track indices
+ const catalogPlaceholders: (CatalogContent | null)[] = [];
+ const catalogPromises: Promise[] = [];
+ let catalogIndex = 0;
+
+ for (const addon of addons) {
+ if (addon.catalogs) {
+ for (const catalog of addon.catalogs) {
+ const currentIndex = catalogIndex;
+ catalogPlaceholders.push(null); // Reserve position
+
+ const catalogPromise = (async () => {
+ try {
+ const addonManifest = await stremioService.getInstalledAddonsAsync();
+ const manifest = addonManifest.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 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);
+ }
+ })();
+
+ 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));
+ });
+
+ } 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
- const isLoading = (showHeroSection ? featuredLoading : false) || catalogsLoading;
- const isRefreshing = catalogsRefreshing;
+ // For catalogs, we show them progressively, so only show loading if no catalogs are loaded yet
+ const isLoading = useMemo(() =>
+ (showHeroSection ? featuredLoading : false) || (catalogsLoading && catalogs.length === 0),
+ [showHeroSection, featuredLoading, catalogsLoading, catalogs.length]
+ );
// React to settings changes
useEffect(() => {
@@ -394,14 +526,26 @@ const HomeScreen = () => {
setFeaturedContentSource(settings.featuredContentSource);
}, [settings]);
+ // Load catalogs progressively on mount and when settings change
+ useEffect(() => {
+ loadCatalogsProgressively();
+ }, [loadCatalogsProgressively]);
+
+ // Listen for catalog changes (addon additions/removals) and reload catalogs
+ useEffect(() => {
+ loadCatalogsProgressively();
+ }, [lastUpdate, loadCatalogsProgressively]);
+
+ // Create a refresh function for catalogs
+ const refreshCatalogs = useCallback(() => {
+ return loadCatalogsProgressively();
+ }, [loadCatalogsProgressively]);
+
// Subscribe directly to settings emitter for immediate updates
useEffect(() => {
const handleSettingsChange = () => {
setShowHeroSection(settings.showHeroSection);
setFeaturedContentSource(settings.featuredContentSource);
-
- // The featured content refresh is now handled by the useFeaturedContent hook
- // No need to call refreshFeatured() here to avoid duplicate refreshes
};
// Subscribe to settings changes
@@ -410,18 +554,6 @@ const HomeScreen = () => {
return unsubscribe;
}, [settings]);
- // Update the featured content refresh logic to handle persistence
- useEffect(() => {
- // This effect was causing duplicate refreshes - it's now handled in useFeaturedContent
- // We'll keep it just to sync the local state with settings
- if (showHeroSection && featuredContentSource !== settings.featuredContentSource) {
- // Just update the local state
- setFeaturedContentSource(settings.featuredContentSource);
- }
-
- // No timeout needed since we're not refreshing here
- }, [settings.featuredContentSource, showHeroSection]);
-
useFocusEffect(
useCallback(() => {
const statusBarConfig = () => {
@@ -451,16 +583,15 @@ const HomeScreen = () => {
StatusBar.setTranslucent(false);
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
}
+
+ // Clean up any lingering timeouts
+ if (refreshTimeoutRef.current) {
+ clearTimeout(refreshTimeoutRef.current);
+ }
};
}, [currentTheme.colors.darkBackground]);
- useEffect(() => {
- navigation.addListener('beforeRemove', () => {});
- return () => {
- navigation.removeListener('beforeRemove', () => {});
- };
- }, [navigation]);
-
+ // Preload images function - memoized to avoid recreating on every render
const preloadImages = useCallback(async (content: StreamingContent[]) => {
if (!content.length) return;
@@ -481,69 +612,120 @@ const HomeScreen = () => {
await Promise.all(imagePromises);
} catch (error) {
- console.error('Error preloading images:', error);
+ // Silently handle preload errors
}
}, []);
- const handleRefresh = useCallback(async () => {
- try {
- const refreshTasks = [
- refreshCatalogs(),
- continueWatchingRef.current?.refresh(),
- ];
-
- // Only refresh featured content if hero section is enabled,
- // and force refresh to bypass the cache
- if (showHeroSection) {
- refreshTasks.push(refreshFeatured());
- }
-
- await Promise.all(refreshTasks);
- } catch (error) {
- logger.error('Error during refresh:', error);
- }
- }, [refreshFeatured, refreshCatalogs, showHeroSection]);
-
const handleContentPress = useCallback((id: string, type: string) => {
navigation.navigate('Metadata', { id, type });
}, [navigation]);
- const handlePlayStream = useCallback((stream: Stream) => {
+ const handlePlayStream = useCallback(async (stream: Stream) => {
if (!featuredContent) return;
- navigation.navigate('Player', {
- uri: stream.url,
- title: featuredContent.name,
- year: featuredContent.year,
- quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
- streamProvider: stream.name,
- id: featuredContent.id,
- type: featuredContent.type
- });
+ try {
+ // Lock orientation to landscape before navigation to prevent glitches
+ await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
+
+ // Small delay to ensure orientation is set before navigation
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ navigation.navigate('Player', {
+ uri: stream.url,
+ title: featuredContent.name,
+ year: featuredContent.year,
+ quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
+ streamProvider: stream.name,
+ id: featuredContent.id,
+ type: featuredContent.type
+ });
+ } catch (error) {
+ // Fallback: navigate anyway
+ navigation.navigate('Player', {
+ uri: stream.url,
+ title: featuredContent.name,
+ year: featuredContent.year,
+ quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
+ streamProvider: stream.name,
+ id: featuredContent.id,
+ type: featuredContent.type
+ });
+ }
}, [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');
}
}, []);
useEffect(() => {
- const handlePlaybackComplete = () => {
- refreshContinueWatching();
- };
-
const unsubscribe = navigation.addListener('focus', () => {
+ // Only refresh continue watching section on focus
refreshContinueWatching();
+ // Don't reload catalogs unless they haven't been loaded yet
+ // Catalogs will be refreshed through context updates when addons change
+ if (catalogs.length === 0 && !catalogsLoading) {
+ loadCatalogsProgressively();
+ }
});
- return () => {
- unsubscribe();
- };
- }, [navigation, refreshContinueWatching]);
+ return unsubscribe;
+ }, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
- if (isLoading && !isRefreshing) {
+ // Memoize the loading screen to prevent unnecessary re-renders
+ const renderLoadingScreen = useMemo(() => {
+ if (isLoading) {
+ return (
+
+
+
+
+ Loading your content...
+
+
+ );
+ }
+ return null;
+ }, [isLoading, currentTheme.colors]);
+
+ // Memoize the main content section
+ const renderMainContent = useMemo(() => {
+ if (isLoading) return null;
+
return (
{
backgroundColor="transparent"
translucent
/>
-
-
- Loading your content...
-
-
- );
- }
+
+ {showHeroSection && (
+
+ )}
- return (
-
-
-
- }
- contentContainerStyle={[
- styles.scrollContent,
- { paddingTop: Platform.OS === 'ios' ? 100 : 90 }
- ]}
- showsVerticalScrollIndicator={false}
- >
- {showHeroSection && (
-
- )}
+
+
+
-
-
-
+
- {hasContinueWatching && (
-
-
-
- )}
+ {/* Show catalogs as they load */}
+ {catalogs.map((catalog, index) => {
+ if (!catalog) {
+ // Show placeholder for loading catalog
+ return (
+
+
+
+
+
+
+ {[...Array(4)].map((_, posterIndex) => (
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ })}
- {catalogs.length > 0 ? (
- catalogs.map((catalog, index) => (
-
-
+ {/* Show loading indicator for remaining catalogs */}
+ {catalogsLoading && catalogs.length < totalCatalogsRef.current && (
+
+
+
+ Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
+
- ))
- ) : (
- !catalogsLoading && (
+ )}
+
+ {/* Show empty state only if all catalogs are loaded and none are available */}
+ {!catalogsLoading && catalogs.length === 0 && (
@@ -621,33 +813,122 @@ const HomeScreen = () => {
Add Catalogs
- )
- )}
-
-
- );
+ )}
+
+
+ );
+ }, [
+ isLoading,
+ currentTheme.colors,
+ showHeroSection,
+ featuredContent,
+ isSaved,
+ handleSaveToLibrary,
+ hasContinueWatching,
+ catalogs,
+ catalogsLoading,
+ navigation,
+ featuredContentSource
+ ]);
+
+ return isLoading ? renderLoadingScreen : renderMainContent;
};
const { width, height } = Dimensions.get('window');
-const POSTER_WIDTH = (width - 50) / 3;
+
+// Dynamic poster calculation based on screen width - show 1/4 of next poster
+const calculatePosterLayout = (screenWidth: number) => {
+ const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
+ const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
+ const LEFT_PADDING = 16; // Left padding
+ const SPACING = 8; // Space between posters
+
+ // Calculate available width for posters (reserve space for left padding)
+ const availableWidth = screenWidth - LEFT_PADDING;
+
+ // Try different numbers of full posters to find the best fit
+ let bestLayout = { numFullPosters: 3, posterWidth: 120 };
+
+ for (let n = 3; n <= 6; n++) {
+ // Calculate poster width needed for N full posters + 0.25 partial poster
+ // Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
+ // Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
+ // We'll use minimal right padding (8px) to maximize space
+ 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`);
+ }
+ }
+
+ return {
+ numFullPosters: bestLayout.numFullPosters,
+ posterWidth: bestLayout.posterWidth,
+ spacing: SPACING,
+ partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
+ };
+};
+
+const posterLayout = calculatePosterLayout(width);
+const POSTER_WIDTH = posterLayout.posterWidth;
const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
- paddingBottom: 40,
+ paddingBottom: 90,
},
loadingMainContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
- paddingBottom: 40,
+ paddingBottom: 90,
},
loadingText: {
marginTop: 12,
fontSize: 14,
},
+ loadingMoreCatalogs: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ justifyContent: 'center',
+ padding: 16,
+ marginHorizontal: 16,
+ marginBottom: 16,
+ },
+ loadingMoreText: {
+ marginLeft: 12,
+ fontSize: 14,
+ },
+ catalogPlaceholder: {
+ marginBottom: 24,
+ paddingHorizontal: 16,
+ },
+ placeholderHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 12,
+ },
+ placeholderTitle: {
+ width: 150,
+ height: 20,
+ borderRadius: 4,
+ },
+ placeholderPosters: {
+ flexDirection: 'row',
+ gap: 8,
+ },
+ placeholderPoster: {
+ width: POSTER_WIDTH,
+ aspectRatio: 2/3,
+ borderRadius: 4,
+ },
emptyCatalog: {
padding: 32,
alignItems: 'center',
@@ -810,19 +1091,19 @@ const styles = StyleSheet.create({
position: 'relative',
},
catalogTitle: {
- fontSize: 18,
- fontWeight: '800',
- textTransform: 'uppercase',
- letterSpacing: 0.5,
- marginBottom: 6,
+ fontSize: 19,
+ fontWeight: '700',
+ letterSpacing: 0.2,
+ marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
- bottom: -4,
+ bottom: -2,
left: 0,
- width: 60,
- height: 3,
- borderRadius: 1.5,
+ width: 35,
+ height: 2,
+ borderRadius: 1,
+ opacity: 0.8,
},
seeAllButton: {
flexDirection: 'row',
@@ -837,7 +1118,8 @@ const styles = StyleSheet.create({
marginRight: 4,
},
catalogList: {
- paddingHorizontal: 16,
+ paddingLeft: 16,
+ paddingRight: 16 - posterLayout.partialPosterWidth,
paddingBottom: 12,
paddingTop: 6,
},
@@ -845,21 +1127,21 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
- borderRadius: 16,
+ borderRadius: 4,
overflow: 'hidden',
position: 'relative',
- elevation: 8,
+ elevation: 6,
shadowColor: '#000',
- shadowOffset: { width: 0, height: 4 },
- shadowOpacity: 0.3,
- shadowRadius: 8,
- borderWidth: 1,
- borderColor: 'rgba(255,255,255,0.08)',
+ shadowOffset: { width: 0, height: 3 },
+ shadowOpacity: 0.25,
+ shadowRadius: 6,
+ borderWidth: 0.5,
+ borderColor: 'rgba(255,255,255,0.12)',
},
poster: {
width: '100%',
height: '100%',
- borderRadius: 16,
+ borderRadius: 4,
},
imdbLogo: {
width: 35,
@@ -898,7 +1180,7 @@ const styles = StyleSheet.create({
contentItemContainer: {
width: '100%',
height: '100%',
- borderRadius: 16,
+ borderRadius: 4,
overflow: 'hidden',
position: 'relative',
},
@@ -1009,7 +1291,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
- borderRadius: 16,
+ borderRadius: 8,
},
featuredImage: {
width: '100%',
@@ -1045,4 +1327,4 @@ const styles = StyleSheet.create({
},
});
-export default HomeScreen;
\ No newline at end of file
+export default React.memo(HomeScreen);
\ No newline at end of file
diff --git a/src/screens/InternalProvidersSettings.tsx b/src/screens/InternalProvidersSettings.tsx
new file mode 100644
index 0000000..996d3d1
--- /dev/null
+++ b/src/screens/InternalProvidersSettings.tsx
@@ -0,0 +1,491 @@
+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 = ({
+ title,
+ description,
+ icon,
+ value,
+ onValueChange,
+ isLast,
+ badge,
+}) => {
+ const { currentTheme } = useTheme();
+
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+ {badge && (
+
+ {badge}
+
+ )}
+
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+ );
+};
+
+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 (
+
+
+
+
+
+
+
+
+ Internal Providers
+
+
+
+
+ {/* Master Toggle Section */}
+
+
+ MASTER CONTROL
+
+
+
+
+
+
+ {/* Individual Providers Section */}
+ {settings.enableInternalProviders && (
+
+
+ INDIVIDUAL PROVIDERS
+
+
+
+
+
+ )}
+
+ {/* Information Section */}
+
+
+ INFORMATION
+
+
+
+
+
+ About Internal Providers
+
+
+ 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.
+
+
+
+
+
+ No addon installation required
+
+
+
+
+
+ Multiple quality options
+
+
+
+
+
+ Fast and reliable streaming
+
+
+
+
+
+
+
+
+ );
+};
+
+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 160ac13..22f61a2 100644
--- a/src/screens/LibraryScreen.tsx
+++ b/src/screens/LibraryScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useEffect } from 'react';
+import React, { useState, useEffect, useMemo, useCallback } from 'react';
import {
View,
Text,
@@ -12,6 +12,7 @@ import {
Animated as RNAnimated,
ActivityIndicator,
Platform,
+ ScrollView,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@@ -25,13 +26,38 @@ import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger';
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';
-// Types
+// Define interfaces for proper typing
interface LibraryItem extends StreamingContent {
progress?: number;
lastWatched?: string;
}
+interface TraktDisplayItem {
+ id: string;
+ name: string;
+ type: 'movie' | 'series';
+ poster: string;
+ year?: number;
+ lastWatched?: string;
+ plays?: number;
+ rating?: number;
+ imdbId?: string;
+ traktId: number;
+}
+
+interface TraktFolder {
+ id: string;
+ name: string;
+ icon: keyof typeof MaterialIcons.glyphMap;
+ description: string;
+ itemCount: number;
+ gradient: [string, string];
+}
+
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const SkeletonLoader = () => {
@@ -99,8 +125,26 @@ const LibraryScreen = () => {
const [loading, setLoading] = useState(true);
const [libraryItems, setLibraryItems] = useState([]);
const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all');
+ const [showTraktContent, setShowTraktContent] = useState(false);
+ const [selectedTraktFolder, setSelectedTraktFolder] = useState(null);
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
+
+ // Trakt integration
+ const {
+ isAuthenticated: traktAuthenticated,
+ isLoading: traktLoading,
+ watchedMovies,
+ watchedShows,
+ watchlistMovies,
+ watchlistShows,
+ collectionMovies,
+ collectionShows,
+ continueWatching,
+ ratedContent,
+ loadWatchedItems,
+ loadAllCollections
+ } = useTraktContext();
// Force consistent status bar settings
useEffect(() => {
@@ -151,6 +195,187 @@ const LibraryScreen = () => {
return true;
});
+ // Generate Trakt collection folders
+ const traktFolders = useMemo((): TraktFolder[] => {
+ if (!traktAuthenticated) return [];
+
+ const folders: TraktFolder[] = [
+ {
+ id: 'watched',
+ name: 'Watched',
+ icon: 'visibility',
+ description: 'Your watched content',
+ itemCount: (watchedMovies?.length || 0) + (watchedShows?.length || 0),
+ gradient: ['#4CAF50', '#2E7D32']
+ },
+ {
+ id: 'continue-watching',
+ name: 'Continue Watching',
+ icon: 'play-circle-outline',
+ description: 'Resume your progress',
+ itemCount: continueWatching?.length || 0,
+ gradient: ['#FF9800', '#F57C00']
+ },
+ {
+ id: 'watchlist',
+ name: 'Watchlist',
+ icon: 'bookmark',
+ description: 'Want to watch',
+ itemCount: (watchlistMovies?.length || 0) + (watchlistShows?.length || 0),
+ gradient: ['#2196F3', '#1976D2']
+ },
+ {
+ id: 'collection',
+ name: 'Collection',
+ icon: 'library-add',
+ description: 'Your collection',
+ itemCount: (collectionMovies?.length || 0) + (collectionShows?.length || 0),
+ gradient: ['#9C27B0', '#7B1FA2']
+ },
+ {
+ id: 'ratings',
+ name: 'Rated',
+ icon: 'star',
+ description: 'Your ratings',
+ itemCount: ratedContent?.length || 0,
+ gradient: ['#FF5722', '#D84315']
+ }
+ ];
+
+ // Only return folders that have content
+ 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