Merge pull request #14 from tapframe/ios

Ios
This commit is contained in:
tapframe 2025-06-20 19:24:29 +05:30 committed by GitHub
commit 68a347d808
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 19595 additions and 4029 deletions

2
.gitignore vendored
View file

@ -36,3 +36,5 @@ yarn-error.*
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
plan.md
release_announcement.md

12
App.tsx
View file

@ -5,7 +5,7 @@
* @format * @format
*/ */
import React from 'react'; import React, { useState } from 'react';
import { import {
View, View,
StyleSheet StyleSheet
@ -24,6 +24,7 @@ import { CatalogProvider } from './src/contexts/CatalogContext';
import { GenreProvider } from './src/contexts/GenreContext'; import { GenreProvider } from './src/contexts/GenreContext';
import { TraktProvider } from './src/contexts/TraktContext'; import { TraktProvider } from './src/contexts/TraktContext';
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext'; import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
import SplashScreen from './src/components/SplashScreen';
// This fixes many navigation layout issues by using native screen containers // This fixes many navigation layout issues by using native screen containers
enableScreens(true); enableScreens(true);
@ -31,6 +32,7 @@ enableScreens(true);
// Inner app component that uses the theme context // Inner app component that uses the theme context
const ThemedApp = () => { const ThemedApp = () => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const [isAppReady, setIsAppReady] = useState(false);
// Create custom themes based on current theme // Create custom themes based on current theme
const customDarkTheme = { const customDarkTheme = {
@ -51,6 +53,11 @@ const ThemedApp = () => {
} }
}; };
// Handler for splash screen completion
const handleSplashComplete = () => {
setIsAppReady(true);
};
return ( return (
<PaperProvider theme={customDarkTheme}> <PaperProvider theme={customDarkTheme}>
<NavigationContainer <NavigationContainer
@ -62,7 +69,8 @@ const ThemedApp = () => {
<StatusBar <StatusBar
style="light" style="light"
/> />
<AppNavigator /> {!isAppReady && <SplashScreen onFinish={handleSplashComplete} />}
{isAppReady && <AppNavigator />}
</View> </View>
</NavigationContainer> </NavigationContainer>
</PaperProvider> </PaperProvider>

View file

@ -5,13 +5,13 @@
"version": "1.0.0", "version": "1.0.0",
"orientation": "default", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "userInterfaceStyle": "dark",
"scheme": "stremioexpo", "scheme": "stremioexpo",
"newArchEnabled": true, "newArchEnabled": true,
"splash": { "splash": {
"image": "./assets/splash-icon.png", "image": "./assets/splash-icon.png",
"resizeMode": "contain", "resizeMode": "contain",
"backgroundColor": "#ffffff" "backgroundColor": "#020404"
}, },
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
@ -41,15 +41,21 @@
}, },
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/icon.png",
"backgroundColor": "#ffffff" "backgroundColor": "#020404",
"monochromeImage": "./assets/icon.png"
}, },
"permissions": [ "permissions": [
"INTERNET", "INTERNET",
"WAKE_LOCK" "WAKE_LOCK"
], ],
"package": "com.nuvio.app", "package": "com.nuvio.app",
"enableSplitAPKs": true "enableSplitAPKs": true,
"versionCode": 1,
"enableProguardInReleaseBuilds": true,
"enableHermes": true,
"enableSeparateBuildPerCPUArchitecture": true,
"enableVectorDrawables": true
}, },
"web": { "web": {
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"

View file

@ -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<VideoPlayerProps> = ({
src,
paused,
volume,
currentTime,
selectedAudioTrack,
selectedTextTrack,
onProgress,
onLoad,
onError,
onBuffer,
onSeek,
onEnd,
}) => {
const videoRef = useRef<VideoRef>(null);
const [isLoaded, setIsLoaded] = useState(false);
const [isSeeking, setIsSeeking] = useState(false);
const [lastSeekTime, setLastSeekTime] = useState<number>(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 (
<Video
ref={videoRef}
source={{ uri: src }}
style={{ flex: 1 }}
paused={paused}
volume={volume}
selectedAudioTrack={selectedAudioTrack}
selectedTextTrack={selectedTextTrack}
onLoad={handleLoad}
onProgress={handleProgress}
onSeek={handleSeek}
onBuffer={handleBuffer}
onError={handleError}
onEnd={handleEnd}
resizeMode="contain"
controls={false}
playInBackground={false}
playWhenInactive={false}
progressUpdateInterval={250}
allowsExternalPlayback={false}
bufferingStrategy={BufferingStrategyType.DEFAULT}
ignoreSilentSwitch="ignore"
mixWithOthers="inherit"
rate={1.0}
repeat={false}
reportBandwidth={true}
textTracks={[]}
useTextureView={false}
disableFocus={false}
minLoadRetryCount={3}
automaticallyWaitsToMinimizeStalling={true}
hideShutterView={false}
shutterColor="#000000"
/>
);
};
export default AndroidVideoPlayer;

View file

@ -12,7 +12,13 @@
"distribution": "internal" "distribution": "internal"
}, },
"production": { "production": {
"autoIncrement": true "autoIncrement": true,
"extends": "apk",
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease",
"image": "latest"
}
}, },
"release": { "release": {
"distribution": "store", "distribution": "store",
@ -22,7 +28,8 @@
}, },
"apk": { "apk": {
"android": { "android": {
"buildType": "apk" "buildType": "apk",
"gradleCommand": ":app:assembleRelease"
} }
} }
}, },

516
hdrezkas.js Normal file
View file

@ -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> Title to search for
--type, -m <type> Media type (movie or show)
--year, -y <year> Release year
--season, -s <number> Season number (for shows)
--episode, -e <number> Episode number (for shows)
--help, -h Show this help message
Examples:
node hdrezka-test.js --title "Breaking Bad" --type show --season 1 --episode 3
node hdrezka-test.js --title "Inception" --type movie --year 2010
node hdrezka-test.js (interactive mode)
`);
process.exit(0);
}
}
// Create readline interface for user input
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// Function to prompt user for input
function prompt(question) {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer);
});
});
}
// Helper functions
function generateRandomFavs() {
const randomHex = () => Math.floor(Math.random() * 16).toString(16);
const generateSegment = (length) => Array.from({ length }, randomHex).join('');
return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`;
}
function extractTitleAndYear(input) {
const regex = /^(.*?),.*?(\d{4})/;
const match = input.match(regex);
if (match) {
const title = match[1];
const year = match[2];
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
}
return null;
}
function parseVideoLinks(inputString) {
if (!inputString) {
throw new Error('No video links found');
}
console.log(`[PARSE] Parsing video links from stream URL data`);
const linksArray = inputString.split(',');
const result = {};
linksArray.forEach((link) => {
// Handle different quality formats:
// 1. Simple format: [360p]https://example.com/video.mp4
// 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4
// Try simple format first (non-HTML)
let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/);
// If not found, try HTML format with more flexible pattern
if (!match) {
// Extract quality text from HTML span
const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/);
// Extract URL separately
const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/);
if (qualityMatch && urlMatch) {
match = [null, qualityMatch[1].trim(), urlMatch[1]];
}
}
if (match) {
const qualityText = match[1].trim();
const mp4Url = match[2];
// Extract the quality value (e.g., "360p", "1080p Ultra")
let quality = qualityText;
// Skip null URLs (premium content that requires login)
if (mp4Url !== 'null') {
result[quality] = { type: 'mp4', url: mp4Url };
console.log(`[QUALITY] Found ${quality}: ${mp4Url}`);
} else {
console.log(`[QUALITY] Premium quality ${quality} requires login (null URL)`);
}
} else {
console.log(`[WARNING] Could not parse quality from: ${link}`);
}
});
console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`);
return result;
}
function parseSubtitles(inputString) {
if (!inputString) {
console.log('[SUBTITLES] No subtitles found');
return [];
}
console.log(`[PARSE] Parsing subtitles data`);
const linksArray = inputString.split(',');
const captions = [];
linksArray.forEach((link) => {
const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/);
if (match) {
const language = match[1];
const url = match[2];
captions.push({
id: url,
language,
hasCorsRestrictions: false,
type: 'vtt',
url: url,
});
console.log(`[SUBTITLE] Found ${language}: ${url}`);
}
});
console.log(`[PARSE] Found ${captions.length} subtitles`);
return captions;
}
// Main scraper functions
async function searchAndFindMediaId(media) {
console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`);
const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g;
const idRegexPattern = /\/(\d+)-[^/]+\.html$/;
const fullUrl = new URL('/engine/ajax/search.php', rezkaBase);
fullUrl.searchParams.append('q', media.title);
console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`);
const response = await fetch(fullUrl.toString(), {
headers: baseHeaders
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const searchData = await response.text();
console.log(`[RESPONSE] Search response length: ${searchData.length}`);
const movieData = [];
let match;
while ((match = itemRegexPattern.exec(searchData)) !== null) {
const url = match[1];
const titleAndYear = match[3];
const result = extractTitleAndYear(titleAndYear);
if (result !== null) {
const id = url.match(idRegexPattern)?.[1] || null;
const isMovie = url.includes('/films/');
const isShow = url.includes('/series/');
const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown';
movieData.push({
id: id ?? '',
year: result.year ?? 0,
type,
url,
title: match[2]
});
console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`);
}
}
// If year is provided, filter by year
let filteredItems = movieData;
if (media.releaseYear) {
filteredItems = movieData.filter(item => item.year === media.releaseYear);
console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`);
}
// If type is provided, filter by type
if (media.type) {
filteredItems = filteredItems.filter(item => item.type === media.type);
console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`);
}
if (filteredItems.length === 0 && movieData.length > 0) {
console.log(`[WARNING] No items match the exact criteria. Showing all results:`);
movieData.forEach((item, index) => {
console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`);
});
// Let user select from results
const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): ");
const selectedIndex = parseInt(selection) - 1;
if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) {
console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`);
return movieData[selectedIndex];
} else if (movieData.length > 0) {
console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`);
return movieData[0];
}
return null;
}
if (filteredItems.length > 0) {
console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`);
return filteredItems[0];
} else {
console.log(`[ERROR] No matching items found`);
return null;
}
}
async function getTranslatorId(url, id, media) {
console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`);
// Make sure the URL is absolute
const fullUrl = url.startsWith('http') ? url : `${rezkaBase}${url.startsWith('/') ? url.substring(1) : url}`;
console.log(`[REQUEST] Making request to: ${fullUrl}`);
const response = await fetch(fullUrl, {
headers: baseHeaders,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseText = await response.text();
console.log(`[RESPONSE] Translator page response length: ${responseText.length}`);
// Translator ID 238 represents the Original + subtitles player.
if (responseText.includes(`data-translator_id="238"`)) {
console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`);
return '238';
}
const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents';
const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i');
const match = responseText.match(regexPattern);
const translatorId = match ? match[1] : null;
console.log(`[RESULT] Extracted translator ID: ${translatorId}`);
return translatorId;
}
async function getStream(id, translatorId, media) {
console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`);
const searchParams = new URLSearchParams();
searchParams.append('id', id);
searchParams.append('translator_id', translatorId);
if (media.type === 'show') {
searchParams.append('season', media.season.number.toString());
searchParams.append('episode', media.episode.number.toString());
console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`);
}
const randomFavs = generateRandomFavs();
searchParams.append('favs', randomFavs);
searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie');
const fullUrl = `${rezkaBase}ajax/get_cdn_series/`;
console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`);
// Log the request details
console.log('[HDRezka][FETCH DEBUG]', {
url: fullUrl,
method: 'POST',
headers: baseHeaders,
body: searchParams.toString()
});
const response = await fetch(fullUrl, {
method: 'POST',
body: searchParams,
headers: baseHeaders,
});
// Log the response details
let responseHeaders = {};
if (response.headers && typeof response.headers.forEach === 'function') {
response.headers.forEach((value, key) => {
responseHeaders[key] = value;
});
} else if (response.headers && response.headers.entries) {
for (const [key, value] of response.headers.entries()) {
responseHeaders[key] = value;
}
}
const responseText = await response.clone().text();
console.log('[HDRezka][FETCH RESPONSE]', {
status: response.status,
headers: responseHeaders,
text: responseText
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const rawText = await response.text();
console.log(`[RESPONSE] Stream response length: ${rawText.length}`);
// Response content-type is text/html, but it's actually JSON
try {
const parsedResponse = JSON.parse(rawText);
console.log(`[RESULT] Parsed response successfully`);
// Process video qualities and subtitles
const qualities = parseVideoLinks(parsedResponse.url);
const captions = parseSubtitles(parsedResponse.subtitle);
// Add the parsed data to the response
parsedResponse.formattedQualities = qualities;
parsedResponse.formattedCaptions = captions;
return parsedResponse;
} catch (e) {
console.error(`[ERROR] Failed to parse JSON response: ${e.message}`);
console.log(`[ERROR] Raw response: ${rawText.substring(0, 200)}...`);
return null;
}
}
// Main execution
async function main() {
try {
console.log('=== HDREZKA SCRAPER TEST ===');
let media;
// Check if we have command line arguments
if (argOptions.title) {
// Use command line arguments
media = {
type: argOptions.type || 'show',
title: argOptions.title,
releaseYear: argOptions.year || null
};
// If it's a show, add season and episode
if (media.type === 'show') {
media.season = { number: argOptions.season || 1 };
media.episode = { number: argOptions.episode || 1 };
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`);
} else {
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`);
}
} else {
// Get user input interactively
const title = await prompt('Enter title to search: ');
const mediaType = await prompt('Enter media type (movie/show): ').then(type =>
type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show'
);
const releaseYear = await prompt('Enter release year (optional): ').then(year =>
year ? parseInt(year) : null
);
// Create media object
media = {
type: mediaType,
title: title,
releaseYear: releaseYear
};
// If it's a show, get season and episode
if (mediaType === 'show') {
const seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1);
const episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1);
media.season = { number: seasonNum };
media.episode = { number: episodeNum };
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${media.season.number}E${media.episode.number}`);
} else {
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`);
}
}
// Step 1: Search and find media ID
const result = await searchAndFindMediaId(media);
if (!result || !result.id) {
console.log('No result found, exiting');
rl.close();
return;
}
// Step 2: Get translator ID
const translatorId = await getTranslatorId(result.url, result.id, media);
if (!translatorId) {
console.log('No translator ID found, exiting');
rl.close();
return;
}
// Step 3: Get stream
const streamData = await getStream(result.id, translatorId, media);
if (!streamData) {
console.log('No stream data found, exiting');
rl.close();
return;
}
// Format output in clean JSON similar to CLI output
const formattedOutput = {
embeds: [],
stream: [
{
id: 'primary',
type: 'file',
flags: ['cors-allowed', 'ip-locked'],
captions: streamData.formattedCaptions.map(caption => ({
id: caption.url,
language: caption.language === 'Русский' ? 'ru' :
caption.language === 'Українська' ? 'uk' :
caption.language === 'English' ? 'en' : caption.language.toLowerCase(),
hasCorsRestrictions: false,
type: 'vtt',
url: caption.url
})),
qualities: Object.entries(streamData.formattedQualities).reduce((acc, [quality, data]) => {
// Convert quality format to match CLI output
// "360p" -> "360", "1080p Ultra" -> "1080" (or keep as is if needed)
let qualityKey = quality;
const numericMatch = quality.match(/^(\d+)p/);
if (numericMatch) {
qualityKey = numericMatch[1];
}
acc[qualityKey] = {
type: data.type,
url: data.url
};
return acc;
}, {})
}
]
};
// Display the formatted output
console.log('✓ Done!');
console.log(JSON.stringify(formattedOutput, null, 2).replace(/"([^"]+)":/g, '$1:'));
console.log('=== SCRAPING COMPLETE ===');
} catch (error) {
console.error(`Error: ${error.message}`);
if (error.cause) {
console.error(`Cause: ${error.cause.message}`);
}
} finally {
rl.close();
}
}
main();

View file

@ -1,28 +1,31 @@
const { getDefaultConfig } = require('expo/metro-config'); const { getDefaultConfig } = require('expo/metro-config');
module.exports = (() => { const config = getDefaultConfig(__dirname);
const config = getDefaultConfig(__dirname);
const { transformer, resolver } = config; // Enable tree shaking and better minification
config.transformer = {
config.transformer = { ...config.transformer,
...transformer,
babelTransformerPath: require.resolve('react-native-svg-transformer'), babelTransformerPath: require.resolve('react-native-svg-transformer'),
minifierConfig: { minifierConfig: {
ecma: 8,
keep_fnames: true,
mangle: {
keep_fnames: true,
},
compress: { compress: {
// Remove console.* statements in release builds
drop_console: true, drop_console: true,
// Keep error logging for critical issues drop_debugger: true,
pure_funcs: ['console.info', 'console.log', 'console.debug', 'console.warn'], pure_funcs: ['console.log', 'console.info', 'console.debug'],
}, },
}, },
}; };
config.resolver = { // Optimize resolver for better tree shaking and SVG support
...resolver, config.resolver = {
assetExts: resolver.assetExts.filter((ext) => ext !== 'svg'), ...config.resolver,
sourceExts: [...resolver.sourceExts, 'svg'], assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
}; sourceExts: [...config.resolver.sourceExts, 'svg'],
resolverMainFields: ['react-native', 'browser', 'main'],
};
return config; module.exports = config;
})();

1634
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,13 +6,13 @@
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",
"ios": "expo run:ios", "ios": "expo run:ios",
"web": "expo start --web", "web": "expo start --web"
"postinstall": "node patch-package.js"
}, },
"dependencies": { "dependencies": {
"@expo/metro-runtime": "~4.0.1", "@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "^14.1.0", "@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.2", "@gorhom/bottom-sheet": "^5.1.2",
"@movie-web/providers": "^2.4.13",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1", "@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6", "@react-native-community/slider": "^4.5.6",
@ -25,8 +25,10 @@
"@shopify/flash-list": "1.7.3", "@shopify/flash-list": "1.7.3",
"@types/lodash": "^4.17.16", "@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20", "@types/react-native-video": "^5.0.20",
"axios": "^1.8.4", "axios": "^1.10.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"cheerio": "^1.1.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"expo": "~52.0.43", "expo": "~52.0.43",
@ -44,7 +46,10 @@
"expo-status-bar": "~2.0.1", "expo-status-bar": "~2.0.1",
"expo-system-ui": "^4.0.9", "expo-system-ui": "^4.0.9",
"expo-web-browser": "~14.0.2", "expo-web-browser": "~14.0.2",
"express": "^5.1.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"node-fetch": "^2.6.7",
"puppeteer": "^24.10.1",
"react": "18.3.1", "react": "18.3.1",
"react-native": "0.76.9", "react-native": "0.76.9",
"react-native-awesome-slider": "^2.9.0", "react-native-awesome-slider": "^2.9.0",
@ -61,6 +66,7 @@
"react-native-tab-view": "^4.0.10", "react-native-tab-view": "^4.0.10",
"react-native-url-polyfill": "^2.0.0", "react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.12.0", "react-native-video": "^6.12.0",
"react-native-vlc-media-player": "^1.0.87",
"react-native-web": "~0.19.13", "react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1", "react-native-wheel-color-picker": "^1.3.1",
"subsrt": "^1.1.1" "subsrt": "^1.1.1"

View file

@ -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.');

434
scripts/test-hdrezka.js Normal file
View file

@ -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(/\[<span[^>]*>([^<]+)/);
const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/);
if (qualityMatch && urlMatch) {
match = [null, qualityMatch[1].trim(), urlMatch[1]];
}
}
if (match) {
const qualityText = match[1].trim();
const mp4Url = match[2];
// Skip null URLs (premium content that requires login)
if (mp4Url !== 'null') {
result[qualityText] = { type: 'mp4', url: mp4Url };
console.log(`[QUALITY] Found ${qualityText}: ${mp4Url}`);
} else {
console.log(`[QUALITY] Premium quality ${qualityText} requires login (null URL)`);
}
} else {
console.log(`[WARNING] Could not parse quality from: ${link}`);
}
});
console.log(`[PARSE] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`);
return result;
}
function parseSubtitles(inputString) {
if (!inputString) {
console.log('[SUBTITLES] No subtitles found');
return [];
}
console.log(`[PARSE] Parsing subtitles data`);
const linksArray = inputString.split(',');
const captions = [];
linksArray.forEach((link) => {
const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/);
if (match) {
const language = match[1];
const url = match[2];
captions.push({
id: url,
language,
hasCorsRestrictions: false,
type: 'vtt',
url: url,
});
console.log(`[SUBTITLE] Found ${language}: ${url}`);
}
});
console.log(`[PARSE] Found ${captions.length} subtitles`);
return captions;
}
// Main scraper functions
async function searchAndFindMediaId(media) {
console.log(`[STEP 1] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`);
const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g;
const idRegexPattern = /\/(\d+)-[^/]+\.html$/;
const fullUrl = new URL('/engine/ajax/search.php', REZKA_BASE);
fullUrl.searchParams.append('q', media.title);
console.log(`[REQUEST] Making search request to: ${fullUrl.toString()}`);
const response = await fetch(fullUrl.toString(), {
headers: BASE_HEADERS
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const searchData = await response.text();
console.log(`[RESPONSE] Search response length: ${searchData.length}`);
const movieData = [];
let match;
while ((match = itemRegexPattern.exec(searchData)) !== null) {
const url = match[1];
const titleAndYear = match[3];
const result = extractTitleAndYear(titleAndYear);
if (result !== null) {
const id = url.match(idRegexPattern)?.[1] || null;
const isMovie = url.includes('/films/');
const isShow = url.includes('/series/');
const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown';
movieData.push({
id: id ?? '',
year: result.year ?? 0,
type,
url,
title: match[2]
});
console.log(`[MATCH] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`);
}
}
// If year is provided, filter by year
let filteredItems = movieData;
if (media.releaseYear) {
filteredItems = movieData.filter(item => item.year === media.releaseYear);
console.log(`[FILTER] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`);
}
// If type is provided, filter by type
if (media.type) {
filteredItems = filteredItems.filter(item => item.type === media.type);
console.log(`[FILTER] Items filtered by type ${media.type}: ${filteredItems.length}`);
}
if (filteredItems.length === 0 && movieData.length > 0) {
console.log(`[WARNING] No items match the exact criteria. Showing all results:`);
movieData.forEach((item, index) => {
console.log(` ${index + 1}. ${item.title} (${item.year}) - ${item.type}`);
});
// Let user select from results
const selection = await prompt("Enter the number of the item you want to select (or press Enter to use the first result): ");
const selectedIndex = parseInt(selection) - 1;
if (!isNaN(selectedIndex) && selectedIndex >= 0 && selectedIndex < movieData.length) {
console.log(`[RESULT] Selected item: id=${movieData[selectedIndex].id}, title=${movieData[selectedIndex].title}`);
return movieData[selectedIndex];
} else if (movieData.length > 0) {
console.log(`[RESULT] Using first result: id=${movieData[0].id}, title=${movieData[0].title}`);
return movieData[0];
}
return null;
}
if (filteredItems.length > 0) {
console.log(`[RESULT] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`);
return filteredItems[0];
} else {
console.log(`[ERROR] No matching items found`);
return null;
}
}
async function getTranslatorId(url, id, media) {
console.log(`[STEP 2] Getting translator ID for url=${url}, id=${id}`);
// Make sure the URL is absolute
const fullUrl = url.startsWith('http') ? url : `${REZKA_BASE}${url.startsWith('/') ? url.substring(1) : url}`;
console.log(`[REQUEST] Making request to: ${fullUrl}`);
const response = await fetch(fullUrl, {
headers: BASE_HEADERS,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseText = await response.text();
console.log(`[RESPONSE] Translator page response length: ${responseText.length}`);
// Translator ID 238 represents the Original + subtitles player.
if (responseText.includes(`data-translator_id="238"`)) {
console.log(`[RESULT] Found translator ID 238 (Original + subtitles)`);
return '238';
}
const functionName = media.type === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents';
const regexPattern = new RegExp(`sof\\.tv\\.${functionName}\\(${id}, ([^,]+)`, 'i');
const match = responseText.match(regexPattern);
const translatorId = match ? match[1] : null;
console.log(`[RESULT] Extracted translator ID: ${translatorId}`);
return translatorId;
}
async function getStream(id, translatorId, media) {
console.log(`[STEP 3] Getting stream for id=${id}, translatorId=${translatorId}`);
const searchParams = new URLSearchParams();
searchParams.append('id', id);
searchParams.append('translator_id', translatorId);
if (media.type === 'show') {
searchParams.append('season', media.season.number.toString());
searchParams.append('episode', media.episode.number.toString());
console.log(`[PARAMS] Show params: season=${media.season.number}, episode=${media.episode.number}`);
}
const randomFavs = generateRandomFavs();
searchParams.append('favs', randomFavs);
searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie');
const fullUrl = `${REZKA_BASE}ajax/get_cdn_series/`;
console.log(`[REQUEST] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`);
const response = await fetch(fullUrl, {
method: 'POST',
body: searchParams,
headers: BASE_HEADERS,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseText = await response.text();
console.log(`[RESPONSE] Stream response length: ${responseText.length}`);
// Response content-type is text/html, but it's actually JSON
try {
const parsedResponse = JSON.parse(responseText);
console.log(`[RESULT] Parsed response successfully`);
// Process video qualities and subtitles
const qualities = parseVideoLinks(parsedResponse.url);
const captions = parseSubtitles(parsedResponse.subtitle);
return {
qualities,
captions
};
} catch (e) {
console.error(`[ERROR] Failed to parse JSON response: ${e.message}`);
return null;
}
}
async function getStreams(mediaId, mediaType, season, episode) {
try {
console.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`);
// Check if the mediaId appears to be an ID rather than a title
let title = mediaId;
let year;
// If it's an ID format (starts with 'tt' for IMDB or contains ':' like TMDB IDs)
// For testing, we'll replace it with an example title instead of implementing full TMDB API calls
if (mediaId.startsWith('tt') || mediaId.includes(':')) {
console.log(`[HDRezka] ID format detected for "${mediaId}". Using title search instead.`);
// For demo purposes only - you would actually get this from TMDB API in real implementation
if (mediaType === 'movie') {
title = "Inception"; // Example movie
year = 2010;
} else {
title = "Breaking Bad"; // Example show
year = 2008;
}
console.log(`[HDRezka] Using title "${title}" (${year}) for search instead of ID`);
}
const media = {
title,
type: mediaType === 'movie' ? 'movie' : 'show',
releaseYear: year
};
// Step 1: Search and find media ID
const searchResult = await searchAndFindMediaId(media);
if (!searchResult || !searchResult.id) {
console.log('[HDRezka] No search results found');
return [];
}
// Step 2: Get translator ID
const translatorId = await getTranslatorId(
searchResult.url,
searchResult.id,
media
);
if (!translatorId) {
console.log('[HDRezka] No translator ID found');
return [];
}
// Step 3: Get stream
const streamParams = {
type: media.type,
season: season ? { number: season } : undefined,
episode: episode ? { number: episode } : undefined
};
const streamData = await getStream(searchResult.id, translatorId, streamParams);
if (!streamData) {
console.log('[HDRezka] No stream data found');
return [];
}
// Convert to Stream format
const streams = [];
Object.entries(streamData.qualities).forEach(([quality, data]) => {
streams.push({
name: 'HDRezka',
title: quality,
url: data.url,
behaviorHints: {
notWebReady: false
}
});
});
console.log(`[HDRezka] Found ${streams.length} streams`);
return streams;
} catch (error) {
console.error(`[HDRezka] Error getting streams: ${error}`);
return [];
}
}
// Main execution
async function main() {
try {
console.log('=== HDREZKA SCRAPER TEST ===');
// Get user input interactively
const title = await prompt('Enter title to search: ');
const mediaType = await prompt('Enter media type (movie/show): ').then(type =>
type.toLowerCase() === 'movie' || type.toLowerCase() === 'show' ? type.toLowerCase() : 'show'
);
const releaseYear = await prompt('Enter release year (optional): ').then(year =>
year ? parseInt(year) : null
);
// Create media object
let media = {
title,
type: mediaType,
releaseYear
};
let seasonNum, episodeNum;
// If it's a show, get season and episode
if (mediaType === 'show') {
seasonNum = await prompt('Enter season number: ').then(num => parseInt(num) || 1);
episodeNum = await prompt('Enter episode number: ').then(num => parseInt(num) || 1);
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''} S${seasonNum}E${episodeNum}`);
} else {
console.log(`Testing scrape for ${media.type}: ${media.title} ${media.releaseYear ? `(${media.releaseYear})` : ''}`);
}
const streams = await getStreams(title, mediaType, seasonNum, episodeNum);
if (streams && streams.length > 0) {
console.log('✓ Found streams:');
console.log(JSON.stringify(streams, null, 2));
} else {
console.log('✗ No streams found');
}
} catch (error) {
console.error(`Error: ${error.message}`);
} finally {
rl.close();
}
}
main();

View file

@ -81,7 +81,7 @@ const styles = StyleSheet.create({
}, },
headerContainer: { headerContainer: {
height: Platform.OS === 'ios' ? 100 : 90, 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)', backgroundColor: 'rgba(0,0,0,0.3)',
}, },
blurOverlay: { blurOverlay: {

View file

@ -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 (
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
<Image
source={require('../../assets/splash-icon.png')}
style={styles.image}
resizeMode="contain"
/>
</Animated.View>
);
};
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;

View file

@ -37,6 +37,7 @@ const CatalogsList = ({ catalogs, selectedCategory }: CatalogsListProps) => {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
paddingVertical: 8, paddingVertical: 8,
paddingBottom: 90,
}, },
}); });

View file

@ -51,7 +51,7 @@ const styles = StyleSheet.create({
marginHorizontal: 0, marginHorizontal: 0,
}, },
posterContainer: { posterContainer: {
borderRadius: 16, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: 'rgba(255,255,255,0.03)', backgroundColor: 'rgba(255,255,255,0.03)',
elevation: 5, elevation: 5,

View file

@ -14,14 +14,53 @@ interface CatalogSectionProps {
} }
const { width } = Dimensions.get('window'); 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 CatalogSection = ({ catalog }: CatalogSectionProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const handleContentPress = (id: string, type: string) => { 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 }) => { const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => {
@ -73,18 +112,18 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
keyExtractor={(item) => `${item.id}-${item.type}`} keyExtractor={(item) => `${item.id}-${item.type}`}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.catalogList} contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]}
snapToInterval={POSTER_WIDTH + 12} snapToInterval={POSTER_WIDTH + 8}
decelerationRate="fast" decelerationRate="fast"
snapToAlignment="start" snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 12 }} />} ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
initialNumToRender={4} initialNumToRender={4}
maxToRenderPerBatch={4} maxToRenderPerBatch={4}
windowSize={5} windowSize={5}
removeClippedSubviews={Platform.OS === 'android'} removeClippedSubviews={Platform.OS === 'android'}
getItemLayout={(data, index) => ({ getItemLayout={(data, index) => ({
length: POSTER_WIDTH + 12, length: POSTER_WIDTH + 8,
offset: (POSTER_WIDTH + 12) * index, offset: (POSTER_WIDTH + 8) * index,
index, index,
})} })}
/> />
@ -107,19 +146,19 @@ const styles = StyleSheet.create({
position: 'relative', position: 'relative',
}, },
catalogTitle: { catalogTitle: {
fontSize: 18, fontSize: 19,
fontWeight: '800', fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 0.2,
letterSpacing: 0.5, marginBottom: 4,
marginBottom: 6,
}, },
titleUnderline: { titleUnderline: {
position: 'absolute', position: 'absolute',
bottom: -4, bottom: -2,
left: 0, left: 0,
width: 60, width: 35,
height: 3, height: 2,
borderRadius: 1.5, borderRadius: 1,
opacity: 0.8,
}, },
seeAllButton: { seeAllButton: {
flexDirection: 'row', flexDirection: 'row',

View file

@ -12,7 +12,46 @@ interface ContentItemProps {
} }
const { width } = Dimensions.get('window'); 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 ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false); const [menuVisible, setMenuVisible] = useState(false);
@ -132,28 +171,28 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH, width: POSTER_WIDTH,
aspectRatio: 2/3, aspectRatio: 2/3,
margin: 0, margin: 0,
borderRadius: 16, borderRadius: 4,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
elevation: 8, elevation: 6,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.3, shadowOpacity: 0.25,
shadowRadius: 8, shadowRadius: 6,
borderWidth: 1, borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.08)', borderColor: 'rgba(255,255,255,0.12)',
}, },
contentItemContainer: { contentItemContainer: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 16, borderRadius: 4,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
}, },
poster: { poster: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 16, borderRadius: 4,
}, },
loadingOverlay: { loadingOverlay: {
position: 'absolute', position: 'absolute',
@ -163,7 +202,7 @@ const styles = StyleSheet.create({
bottom: 0, bottom: 0,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderRadius: 16, borderRadius: 8,
}, },
watchedIndicator: { watchedIndicator: {
position: 'absolute', position: 'absolute',

View file

@ -9,6 +9,7 @@ import {
AppState, AppState,
AppStateStatus AppStateStatus
} from 'react-native'; } from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator'; import { RootStackParamList } from '../../navigation/AppNavigator';
@ -33,8 +34,39 @@ interface ContinueWatchingRef {
refresh: () => Promise<boolean>; refresh: () => Promise<boolean>;
} }
// 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 { 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 // Create a proper imperative handle with React.forwardRef and updated type
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => { const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
@ -50,6 +82,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
try { try {
setLoading(true); setLoading(true);
const allProgress = await storageService.getAllWatchProgress(); const allProgress = await storageService.getAllWatchProgress();
if (Object.keys(allProgress).length === 0) { if (Object.keys(allProgress).length === 0) {
setContinueWatchingItems([]); setContinueWatchingItems([]);
return; return;
@ -62,19 +95,29 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Process each saved progress // Process each saved progress
for (const key in allProgress) { for (const key in allProgress) {
// Parse the key to get type and id // 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]; 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; const progressPercent = (progress.currentTime / progress.duration) * 100;
if (progressPercent >= 95) continue;
if (progressPercent >= 85) {
continue;
}
const contentPromise = (async () => { const contentPromise = (async () => {
try { try {
// Validate IMDB ID format before attempting to fetch
if (!isValidImdbId(id)) {
return;
}
let content: StreamingContent | null = null; let content: StreamingContent | null = null;
// Get content details using catalogService // Get basic content details using catalogService (no enhanced metadata needed for continue watching)
content = await catalogService.getContentDetails(type, id); content = await catalogService.getBasicContentDetails(type, id);
if (content) { if (content) {
// Extract season and episode info from episodeId if available // Extract season and episode info from episodeId if available
@ -83,11 +126,28 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
let episodeTitle: string | undefined; let episodeTitle: string | undefined;
if (episodeId && type === 'series') { 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) { if (match) {
season = parseInt(match[1], 10); season = parseInt(match[1], 10);
episode = parseInt(match[2], 10); episode = parseInt(match[2], 10);
episodeTitle = `Episode ${episode}`; 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<ContinueWatchingRef>((props, re
progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated); progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated);
// Limit to 10 items // Limit to 10 items
setContinueWatchingItems(progressItems.slice(0, 10)); const finalItems = progressItems.slice(0, 10);
setContinueWatchingItems(finalItems);
} catch (error) { } catch (error) {
logger.error('Failed to load continue watching items:', error); logger.error('Failed to load continue watching items:', error);
} finally { } finally {
@ -197,7 +259,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
refresh: async () => { refresh: async () => {
await loadContinueWatching(); await loadContinueWatching();
// Return whether there are items to help parent determine visibility // 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<ContinueWatchingRef>((props, re
}, [navigation]); }, [navigation]);
// If no continue watching items, don't render anything // If no continue watching items, don't render anything
if (continueWatchingItems.length === 0 && !loading) { if (continueWatchingItems.length === 0) {
return null; return null;
} }
return ( return (
<View style={styles.container}> <Animated.View entering={FadeIn.duration(400).delay(250)} style={styles.container}>
<View style={styles.header}> <View style={styles.header}>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text> <Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text>
@ -228,55 +291,96 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
data={continueWatchingItems} data={continueWatchingItems}
renderItem={({ item }) => ( renderItem={({ item }) => (
<TouchableOpacity <TouchableOpacity
style={[styles.contentItem, { style={[styles.wideContentItem, {
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border, borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black shadowColor: currentTheme.colors.black
}]} }]}
activeOpacity={0.7} activeOpacity={0.8}
onPress={() => handleContentPress(item.id, item.type)} onPress={() => handleContentPress(item.id, item.type)}
> >
<View style={styles.contentItemContainer}> {/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage <ExpoImage
source={{ uri: item.poster }} source={{ uri: item.poster }}
style={styles.poster} style={styles.widePoster}
contentFit="cover" contentFit="cover"
transition={200} transition={200}
cachePolicy="memory-disk" cachePolicy="memory-disk"
/> />
{item.type === 'series' && item.season && item.episode && ( </View>
<View style={[styles.episodeInfoContainer, { backgroundColor: 'rgba(0, 0, 0, 0.7)' }]}>
<Text style={[styles.episodeInfo, { color: currentTheme.colors.white }]}> {/* Content Details */}
S{item.season.toString().padStart(2, '0')}E{item.episode.toString().padStart(2, '0')} <View style={styles.contentDetails}>
<View style={styles.titleRow}>
<Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{item.name}
</Text>
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.progressText}>{Math.round(item.progress)}%</Text>
</View>
</View>
{/* Episode Info or Year */}
{(() => {
if (item.type === 'series' && item.season && item.episode) {
return (
<View style={styles.episodeRow}>
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
Season {item.season}
</Text> </Text>
{item.episodeTitle && ( {item.episodeTitle && (
<Text style={[styles.episodeTitle, { color: currentTheme.colors.white, opacity: 0.9 }]} numberOfLines={1}> <Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={1}
>
{item.episodeTitle} {item.episodeTitle}
</Text> </Text>
)} )}
</View> </View>
)} );
{/* Progress bar indicator */} } else {
<View style={styles.progressBarContainer}> return (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.year} {item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
);
}
})()}
{/* Progress Bar */}
<View style={styles.wideProgressContainer}>
<View style={styles.wideProgressTrack}>
<View <View
style={[ style={[
styles.progressBar, styles.wideProgressBar,
{ width: `${item.progress}%`, backgroundColor: currentTheme.colors.primary } {
width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]} ]}
/> />
</View> </View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}>
{Math.round(item.progress)}% watched
</Text>
</View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
)} )}
keyExtractor={(item) => `continue-${item.id}-${item.type}`} keyExtractor={(item) => `continue-${item.id}-${item.type}`}
horizontal horizontal
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.list} contentContainerStyle={styles.wideList}
snapToInterval={POSTER_WIDTH + 10} snapToInterval={280 + 16} // Card width + margin
decelerationRate="fast" decelerationRate="fast"
snapToAlignment="start" snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 10 }} />} ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
/> />
</View> </Animated.View>
); );
}); });
@ -291,26 +395,116 @@ const styles = StyleSheet.create({
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 16, paddingHorizontal: 16,
marginBottom: 8, marginBottom: 12,
}, },
titleContainer: { titleContainer: {
position: 'relative', position: 'relative',
}, },
title: { title: {
fontSize: 18, fontSize: 20,
fontWeight: '800', fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 0.3,
letterSpacing: 0.5, marginBottom: 4,
marginBottom: 6,
}, },
titleUnderline: { titleUnderline: {
position: 'absolute', position: 'absolute',
bottom: -4, bottom: -2,
left: 0, left: 0,
width: 60, width: 40,
height: 3, height: 2,
borderRadius: 1.5, 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: { list: {
paddingHorizontal: 16, paddingHorizontal: 16,
paddingBottom: 8, paddingBottom: 8,
@ -320,7 +514,7 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH, width: POSTER_WIDTH,
aspectRatio: 2/3, aspectRatio: 2/3,
margin: 0, margin: 0,
borderRadius: 12, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
elevation: 8, elevation: 8,
@ -332,14 +526,14 @@ const styles = StyleSheet.create({
contentItemContainer: { contentItemContainer: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 12, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
}, },
poster: { poster: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 12, borderRadius: 8,
}, },
episodeInfoContainer: { episodeInfoContainer: {
position: 'absolute', position: 'absolute',
@ -353,9 +547,6 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontWeight: 'bold', fontWeight: 'bold',
}, },
episodeTitle: {
fontSize: 10,
},
progressBarContainer: { progressBarContainer: {
position: 'absolute', position: 'absolute',
bottom: 0, bottom: 0,

View file

@ -64,9 +64,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
// Add a ref to track logo fetch in progress // Add a ref to track logo fetch in progress
const logoFetchInProgress = useRef<boolean>(false); const logoFetchInProgress = useRef<boolean>(false);
// Enhanced poster transition animations
const posterScale = useSharedValue(1);
const posterTranslateY = useSharedValue(0);
const overlayOpacity = useSharedValue(0.15);
// Animation values // Animation values
const posterAnimatedStyle = useAnimatedStyle(() => ({ const posterAnimatedStyle = useAnimatedStyle(() => ({
opacity: posterOpacity.value, opacity: posterOpacity.value,
transform: [
{ scale: posterScale.value },
{ translateY: posterTranslateY.value }
],
})); }));
const logoAnimatedStyle = useAnimatedStyle(() => ({ const logoAnimatedStyle = useAnimatedStyle(() => ({
@ -84,6 +93,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
opacity: buttonsOpacity.value, opacity: buttonsOpacity.value,
})); }));
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
}));
// Preload the image // Preload the image
const preloadImage = async (url: string): Promise<boolean> => { const preloadImage = async (url: string): Promise<boolean> => {
if (!url) return false; if (!url) return false;
@ -122,153 +135,132 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
if (!featuredContent || logoFetchInProgress.current) return; if (!featuredContent || logoFetchInProgress.current) return;
const fetchLogo = async () => { const fetchLogo = async () => {
// Set fetch in progress flag
logoFetchInProgress.current = true; logoFetchInProgress.current = true;
try { try {
const contentId = featuredContent.id; const contentId = featuredContent.id;
const contentData = featuredContent; // Use a clearer variable name
const currentLogo = contentData.logo;
// Get logo source preference from settings // Get preferences
const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set const logoPreference = settings.logoSourcePreference || 'metahub';
const preferredLanguage = settings.tmdbLanguagePreference || 'en'; // Get preferred language const preferredLanguage = settings.tmdbLanguagePreference || 'en';
// Check if current logo matches preferences // Reset state for new fetch
const currentLogo = featuredContent.logo; setLogoUrl(null);
if (currentLogo) { setLogoLoadError(false);
const isCurrentMetahub = isMetahubUrl(currentLogo);
const isCurrentTmdb = isTmdbUrl(currentLogo);
// If logo already matches preference, use it // Extract IDs
if ((logoPreference === 'metahub' && isCurrentMetahub) || let imdbId: string | null = null;
(logoPreference === 'tmdb' && isCurrentTmdb)) { if (contentData.id.startsWith('tt')) {
setLogoUrl(currentLogo); imdbId = contentData.id;
logoFetchInProgress.current = false; } else if ((contentData as any).imdbId) {
return; 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 tmdbId: string | null = null;
let imdbId = null; if (contentData.id.startsWith('tmdb:')) {
if (featuredContent.id.startsWith('tt')) { tmdbId = contentData.id.split(':')[1];
// If the ID itself is an IMDB ID } else if ((contentData as any).tmdb_id) {
imdbId = featuredContent.id; tmdbId = String((contentData as any).tmdb_id);
} else if ((featuredContent as any).imdbId) {
// Try to get IMDB ID from the content object if available
imdbId = (featuredContent as any).imdbId;
} }
// Extract TMDB ID if available // If we only have IMDB ID, try to find TMDB ID proactively
let tmdbId = null; if (imdbId && !tmdbId) {
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`;
try {
const response = await fetch(metahubUrl, { method: 'HEAD' });
if (response.ok) {
setLogoUrl(metahubUrl);
logoFetchInProgress.current = false;
return; // Exit if Metahub logo was found
}
} catch (error) {
// Removed logger.warn
}
// Fall back to TMDB if Metahub fails and we have a TMDB ID
if (tmdbId) {
const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie';
try { try {
const tmdbService = TMDBService.getInstance(); const tmdbService = TMDBService.getInstance();
const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage); const foundData = await tmdbService.findTMDBIdByIMDB(imdbId);
if (foundData) {
if (logoUrl) { tmdbId = String(foundData);
setLogoUrl(logoUrl);
} else if (currentLogo) {
// If TMDB fails too, use existing logo if any
setLogoUrl(currentLogo);
} }
} catch (error) { } catch (findError) {
// Removed logger.error // logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError);
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) { if (imdbId) {
primaryAttempted = true;
const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`; const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
try { try {
const response = await fetch(metahubUrl, { method: 'HEAD' }); const response = await fetch(metahubUrl, { method: 'HEAD' });
if (response.ok) { if (response.ok) {
setLogoUrl(metahubUrl); finalLogoUrl = metahubUrl;
}
} 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) { } else if (currentLogo) {
// If Metahub fails too, use existing logo if any // Use existing logo only if primary and fallback failed or weren't applicable
setLogoUrl(currentLogo); 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) { } catch (error) {
// Removed logger.warn // logger.error('[FeaturedContent] Error in fetchLogo:', error);
if (currentLogo) setLogoUrl(currentLogo); setLogoLoadError(true);
}
} else if (currentLogo) {
// Use existing logo if we don't have IMDB ID
setLogoUrl(currentLogo);
}
}
} 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
} finally { } finally {
logoFetchInProgress.current = false; logoFetchInProgress.current = false;
} }
}; };
// Trigger fetch when content changes
fetchLogo(); fetchLogo();
}, [featuredContent?.id, settings.logoSourcePreference, settings.tmdbLanguagePreference]); }, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
// Load poster and logo // Load poster and logo
useEffect(() => { useEffect(() => {
@ -276,41 +268,92 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
const posterUrl = featuredContent.banner || featuredContent.poster; const posterUrl = featuredContent.banner || featuredContent.poster;
const contentId = featuredContent.id; const contentId = featuredContent.id;
const isContentChange = contentId !== prevContentIdRef.current;
// Reset states for new content // Enhanced content change detection and animations
if (contentId !== prevContentIdRef.current) { 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; posterOpacity.value = 0;
posterScale.value = 1.1;
overlayOpacity.value = 0;
contentOpacity.value = 0;
buttonsOpacity.value = 0;
}
logoOpacity.value = 0; logoOpacity.value = 0;
} }
prevContentIdRef.current = contentId; prevContentIdRef.current = contentId;
// Set poster URL immediately for instant display // Set poster URL for immediate display
if (posterUrl) setBannerUrl(posterUrl); if (posterUrl) setBannerUrl(posterUrl);
// Load images in background // Load images with enhanced animations
const loadImages = async () => { 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) { if (posterUrl) {
const posterSuccess = await preloadImage(posterUrl); const posterSuccess = await preloadImage(posterUrl);
if (posterSuccess) { if (posterSuccess) {
posterOpacity.value = withTiming(1, { // Animate in new poster with scale and fade
duration: 600, posterScale.value = withTiming(1, {
easing: Easing.bezier(0.25, 0.1, 0.25, 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) { if (logoUrl) {
const logoSuccess = await preloadImage(logoUrl); const logoSuccess = await preloadImage(logoUrl);
if (logoSuccess) { if (logoSuccess) {
logoOpacity.value = withDelay(300, withTiming(1, { logoOpacity.value = withDelay(500, withTiming(1, {
duration: 500, duration: 600,
easing: Easing.bezier(0.25, 0.1, 0.25, 1) easing: Easing.out(Easing.cubic)
})); }));
} else { } else {
// If prefetch fails, mark as error to show title text instead
setLogoLoadError(true); setLogoLoadError(true);
console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`); console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`);
} }
@ -325,8 +368,11 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
} }
return ( return (
<Animated.View
entering={FadeIn.duration(800).easing(Easing.out(Easing.cubic))}
>
<TouchableOpacity <TouchableOpacity
activeOpacity={0.9} activeOpacity={0.95}
onPress={() => { onPress={() => {
navigation.navigate('Metadata', { navigation.navigate('Metadata', {
id: featuredContent.id, id: featuredContent.id,
@ -341,14 +387,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
style={styles.featuredImage as ViewStyle} style={styles.featuredImage as ViewStyle}
resizeMode="cover" resizeMode="cover"
> >
{/* Subtle content overlay for better readability */}
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
<LinearGradient <LinearGradient
colors={[ colors={[
'transparent',
'rgba(0,0,0,0.1)', 'rgba(0,0,0,0.1)',
'rgba(0,0,0,0.7)', 'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.8)',
currentTheme.colors.darkBackground, currentTheme.colors.darkBackground,
]} ]}
locations={[0, 0.3, 0.7, 1]} locations={[0, 0.2, 0.5, 0.8, 1]}
style={styles.featuredGradient as ViewStyle} style={styles.featuredGradient as ViewStyle}
> >
<Animated.View <Animated.View
@ -391,6 +441,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
<TouchableOpacity <TouchableOpacity
style={styles.myListButton as ViewStyle} style={styles.myListButton as ViewStyle}
onPress={handleSaveToLibrary} onPress={handleSaveToLibrary}
activeOpacity={0.7}
> >
<MaterialIcons <MaterialIcons
name={isSaved ? "bookmark" : "bookmark-border"} name={isSaved ? "bookmark" : "bookmark-border"}
@ -412,6 +463,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
}); });
} }
}} }}
activeOpacity={0.8}
> >
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} /> <MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}> <Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
@ -429,6 +481,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
}); });
} }
}} }}
activeOpacity={0.7}
> >
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} /> <MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}> <Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
@ -440,16 +493,24 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
</ImageBackground> </ImageBackground>
</Animated.View> </Animated.View>
</TouchableOpacity> </TouchableOpacity>
</Animated.View>
); );
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
featuredContainer: { featuredContainer: {
width: '100%', width: '100%',
height: height * 0.48, height: height * 0.55, // Slightly taller for better proportions
marginTop: 0, marginTop: 0,
marginBottom: 8, marginBottom: 12,
position: 'relative', position: 'relative',
borderRadius: 12,
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
}, },
imageContainer: { imageContainer: {
width: '100%', width: '100%',
@ -464,6 +525,7 @@ const styles = StyleSheet.create({
featuredImage: { featuredImage: {
width: '100%', width: '100%',
height: '100%', height: '100%',
transform: [{ scale: 1.05 }], // Subtle zoom for depth
}, },
backgroundFallback: { backgroundFallback: {
position: 'absolute', position: 'absolute',
@ -479,12 +541,14 @@ const styles = StyleSheet.create({
width: '100%', width: '100%',
height: '100%', height: '100%',
justifyContent: 'space-between', justifyContent: 'space-between',
paddingTop: 20,
}, },
featuredContentContainer: { featuredContentContainer: {
flex: 1, flex: 1,
justifyContent: 'flex-end', justifyContent: 'flex-end',
paddingHorizontal: 16, paddingHorizontal: 20,
paddingBottom: 4, paddingBottom: 8,
paddingTop: 40,
}, },
featuredLogo: { featuredLogo: {
width: width * 0.7, width: width * 0.7,
@ -523,19 +587,20 @@ const styles = StyleSheet.create({
}, },
featuredButtons: { featuredButtons: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-end', alignItems: 'center',
justifyContent: 'space-evenly', justifyContent: 'space-evenly',
width: '100%', width: '100%',
flex: 1, minHeight: 70,
maxHeight: 55, paddingTop: 12,
paddingTop: 0, paddingBottom: 20,
paddingHorizontal: 8,
}, },
playButton: { playButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 14, paddingVertical: 12,
paddingHorizontal: 32, paddingHorizontal: 28,
borderRadius: 30, borderRadius: 30,
elevation: 4, elevation: 4,
shadowColor: '#000', shadowColor: '#000',
@ -543,7 +608,7 @@ const styles = StyleSheet.create({
shadowOpacity: 0.3, shadowOpacity: 0.3,
shadowRadius: 4, shadowRadius: 4,
flex: 0, flex: 0,
width: 150, width: 140,
}, },
myListButton: { myListButton: {
flexDirection: 'column', flexDirection: 'column',
@ -578,6 +643,16 @@ const styles = StyleSheet.create({
fontSize: 12, fontSize: 12,
fontWeight: '500', 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; export default FeaturedContent;

View file

@ -303,8 +303,9 @@ const styles = StyleSheet.create({
marginBottom: 12, marginBottom: 12,
}, },
title: { title: {
fontSize: 18, fontSize: 19,
fontWeight: 'bold', fontWeight: '700',
letterSpacing: 0.2,
}, },
viewAllButton: { viewAllButton: {
flexDirection: 'row', flexDirection: 'row',
@ -329,7 +330,7 @@ const styles = StyleSheet.create({
episodeItem: { episodeItem: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 12, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
}, },
poster: { poster: {

View file

@ -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<MetadataLoadingScreenProps> = ({
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;
}) => (
<View style={[
{
width: elementWidth,
height: elementHeight,
borderRadius,
marginBottom,
backgroundColor: currentTheme.colors.card,
overflow: 'hidden',
},
style
]}>
<Animated.View style={[
StyleSheet.absoluteFill,
{
opacity: pulseAnim,
backgroundColor: currentTheme.colors.primary + '20',
}
]} />
<Animated.View style={[
StyleSheet.absoluteFill,
{
transform: [{ translateX: shimmerTranslateX }],
}
]}>
<LinearGradient
colors={[
'transparent',
currentTheme.colors.white + '20',
'transparent'
]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
</Animated.View>
</View>
);
return (
<SafeAreaView
style={[styles.container, {
backgroundColor: currentTheme.colors.darkBackground,
}]}
edges={['bottom']}
>
<StatusBar
translucent={true}
backgroundColor="transparent"
barStyle="light-content"
/>
<View style={styles.content}>
{/* Hero Skeleton */}
<View style={styles.heroSection}>
<SkeletonElement
width="100%"
height={height * 0.6}
borderRadius={0}
marginBottom={0}
/>
{/* Overlay content on hero */}
<View style={styles.heroOverlay}>
<LinearGradient
colors={[
'transparent',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.8)',
currentTheme.colors.darkBackground,
]}
style={StyleSheet.absoluteFill}
/>
{/* Bottom hero content skeleton */}
<View style={styles.heroBottomContent}>
<SkeletonElement width="60%" height={32} borderRadius={16} />
<SkeletonElement width="40%" height={20} borderRadius={10} />
<View style={styles.genresRow}>
<SkeletonElement width={80} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
<SkeletonElement width={90} height={24} borderRadius={12} marginBottom={0} style={{ marginRight: 8 }} />
<SkeletonElement width={70} height={24} borderRadius={12} marginBottom={0} />
</View>
<View style={styles.buttonsRow}>
<SkeletonElement width={120} height={44} borderRadius={22} marginBottom={0} style={{ marginRight: 12 }} />
<SkeletonElement width={100} height={44} borderRadius={22} marginBottom={0} />
</View>
</View>
</View>
</View>
{/* Content Section Skeletons */}
<View style={styles.contentSection}>
{/* Synopsis skeleton */}
<View style={styles.synopsisSection}>
<SkeletonElement width="30%" height={24} borderRadius={12} />
<SkeletonElement width="100%" height={16} borderRadius={8} />
<SkeletonElement width="95%" height={16} borderRadius={8} />
<SkeletonElement width="80%" height={16} borderRadius={8} />
</View>
{/* Cast section skeleton */}
<View style={styles.castSection}>
<SkeletonElement width="20%" height={24} borderRadius={12} />
<View style={styles.castRow}>
{[1, 2, 3, 4].map((item) => (
<View key={item} style={styles.castItem}>
<SkeletonElement width={80} height={80} borderRadius={40} marginBottom={8} />
<SkeletonElement width={60} height={12} borderRadius={6} marginBottom={4} />
<SkeletonElement width={70} height={10} borderRadius={5} marginBottom={0} />
</View>
))}
</View>
</View>
{/* Episodes/Details skeleton based on type */}
{type === 'series' ? (
<View style={styles.episodesSection}>
<SkeletonElement width="25%" height={24} borderRadius={12} />
<SkeletonElement width={150} height={36} borderRadius={18} />
{[1, 2, 3].map((item) => (
<View key={item} style={styles.episodeItem}>
<SkeletonElement width={120} height={68} borderRadius={8} marginBottom={0} style={{ marginRight: 12 }} />
<View style={styles.episodeInfo}>
<SkeletonElement width="80%" height={16} borderRadius={8} />
<SkeletonElement width="60%" height={14} borderRadius={7} />
<SkeletonElement width="90%" height={12} borderRadius={6} />
</View>
</View>
))}
</View>
) : (
<View style={styles.detailsSection}>
<SkeletonElement width="25%" height={24} borderRadius={12} />
<View style={styles.detailsGrid}>
<SkeletonElement width="48%" height={60} borderRadius={8} />
<SkeletonElement width="48%" height={60} borderRadius={8} />
</View>
</View>
)}
</View>
</View>
</SafeAreaView>
);
};
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;

File diff suppressed because it is too large Load diff

View file

@ -19,7 +19,32 @@ import { TMDBService } from '../../services/tmdbService';
import { catalogService } from '../../services/catalogService'; import { catalogService } from '../../services/catalogService';
const { width } = Dimensions.get('window'); 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; const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
interface MoreLikeThisSectionProps { interface MoreLikeThisSectionProps {

View file

@ -10,7 +10,7 @@ interface MovieContentProps {
export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => { export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0; 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 ( return (
<View style={styles.container}> <View style={styles.container}>
@ -23,12 +23,6 @@ export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
</View> </View>
)} )}
{metadata.writer && (
<View style={styles.metadataRow}>
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Writer:</Text>
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.writer}</Text>
</View>
)}
{hasCast && ( {hasCast && (
<View style={styles.metadataRow}> <View style={styles.metadataRow}>

View file

@ -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 { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
import { Image } from 'expo-image'; import { Image } from 'expo-image';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons'; import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { LinearGradient } from 'expo-linear-gradient';
import { useTheme } from '../../contexts/ThemeContext'; import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { Episode } from '../../types/metadata'; import { Episode } from '../../types/metadata';
import { tmdbService } from '../../services/tmdbService'; import { tmdbService } from '../../services/tmdbService';
import { storageService } from '../../services/storageService'; import { storageService } from '../../services/storageService';
@ -34,19 +36,21 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
metadata metadata
}) => { }) => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings();
const { width } = useWindowDimensions(); const { width } = useWindowDimensions();
const isTablet = width > 768; const isTablet = width > 768;
const isDarkMode = useColorScheme() === 'dark'; 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<ScrollView | null>(null); const seasonScrollViewRef = useRef<ScrollView | null>(null);
const episodeScrollViewRef = useRef<ScrollView | null>(null);
const loadEpisodesProgress = async () => { const loadEpisodesProgress = async () => {
if (!metadata?.id) return; if (!metadata?.id) return;
const allProgress = await storageService.getAllWatchProgress(); 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 => { episodes.forEach(episode => {
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`; const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
@ -54,7 +58,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
if (allProgress[key]) { if (allProgress[key]) {
progress[episodeId] = { progress[episodeId] = {
currentTime: allProgress[key].currentTime, 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<SeriesContentProps> = ({
setEpisodeProgress(progress); 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 // Initial load of watch progress
useEffect(() => { useEffect(() => {
loadEpisodesProgress(); loadEpisodesProgress();
@ -93,6 +159,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
} }
}, [selectedSeason, groupedEpisodes]); }, [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) { if (loadingSeasons) {
return ( return (
<View style={styles.centeredContainer}> <View style={styles.centeredContainer}>
@ -159,6 +232,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</View> </View>
<Text <Text
style={[ style={[
styles.seasonButtonText,
{ color: currentTheme.colors.mediumEmphasis }, { color: currentTheme.colors.mediumEmphasis },
selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }] selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }]
]} ]}
@ -173,7 +247,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
); );
}; };
const renderEpisodeCard = (episode: Episode) => { // Vertical layout episode card (traditional)
const renderVerticalEpisodeCard = (episode: Episode) => {
let episodeImage = EPISODE_PLACEHOLDER; let episodeImage = EPISODE_PLACEHOLDER;
if (episode.still_path) { if (episode.still_path) {
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500'); const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
@ -210,15 +285,15 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
const progress = episodeProgress[episodeId]; const progress = episodeProgress[episodeId];
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0; const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
// Don't show progress bar if episode is complete (>= 95%) // Don't show progress bar if episode is complete (>= 85%)
const showProgress = progress && progressPercent < 95; const showProgress = progress && progressPercent < 85;
return ( return (
<TouchableOpacity <TouchableOpacity
key={episode.id} key={episode.id}
style={[ style={[
styles.episodeCard, styles.episodeCardVertical,
isTablet && styles.episodeCardTablet, isTablet && styles.episodeCardVerticalTablet,
{ backgroundColor: currentTheme.colors.elevation2 } { backgroundColor: currentTheme.colors.elevation2 }
]} ]}
onPress={() => onSelectEpisode(episode)} onPress={() => onSelectEpisode(episode)}
@ -243,7 +318,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
/> />
</View> </View>
)} )}
{progressPercent >= 95 && ( {progressPercent >= 85 && (
<View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}> <View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="check" size={12} color={currentTheme.colors.white} /> <MaterialIcons name="check" size={12} color={currentTheme.colors.white} />
</View> </View>
@ -291,6 +366,170 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
); );
}; };
// 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 (
<TouchableOpacity
key={episode.id}
style={[
styles.episodeCardHorizontal,
isTablet && styles.episodeCardHorizontalTablet,
// Gradient border styling
{
borderWidth: 1,
borderColor: 'transparent',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 12,
}
]}
onPress={() => onSelectEpisode(episode)}
activeOpacity={0.85}
>
{/* Gradient Border Container */}
<View style={{
position: 'absolute',
top: -1,
left: -1,
right: -1,
bottom: -1,
borderRadius: 17,
zIndex: -1,
}}>
<LinearGradient
colors={[
'#ffffff80', // White with 50% opacity
'#ffffff40', // White with 25% opacity
'#ffffff20', // White with 12% opacity
'#ffffff40', // White with 25% opacity
'#ffffff80', // White with 50% opacity
]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={{
flex: 1,
borderRadius: 17,
}}
/>
</View>
{/* Background Image */}
<Image
source={{ uri: episodeImage }}
style={styles.episodeBackgroundImage}
contentFit="cover"
/>
{/* Standard Gradient Overlay */}
<LinearGradient
colors={[
'rgba(0,0,0,0.05)',
'rgba(0,0,0,0.2)',
'rgba(0,0,0,0.6)',
'rgba(0,0,0,0.85)',
'rgba(0,0,0,0.95)'
]}
locations={[0, 0.2, 0.5, 0.8, 1]}
style={styles.episodeGradient}
>
{/* Content Container */}
<View style={styles.episodeContent}>
{/* Episode Number Badge */}
<View style={styles.episodeNumberBadgeHorizontal}>
<Text style={styles.episodeNumberHorizontal}>{episodeString}</Text>
</View>
{/* Episode Title */}
<Text style={styles.episodeTitleHorizontal} numberOfLines={2}>
{episode.name}
</Text>
{/* Episode Description */}
<Text style={styles.episodeDescriptionHorizontal} numberOfLines={3}>
{episode.overview || 'No description available'}
</Text>
{/* Metadata Row */}
<View style={styles.episodeMetadataRowHorizontal}>
{episode.runtime && (
<View style={styles.runtimeContainerHorizontal}>
<Text style={styles.runtimeTextHorizontal}>
{formatRuntime(episode.runtime)}
</Text>
</View>
)}
{episode.vote_average > 0 && (
<View style={styles.ratingContainerHorizontal}>
<MaterialIcons name="star" size={14} color="#FFD700" />
<Text style={styles.ratingTextHorizontal}>
{episode.vote_average.toFixed(1)}
</Text>
</View>
)}
</View>
</View>
{/* Progress Bar */}
{showProgress && (
<View style={styles.progressBarContainerHorizontal}>
<View
style={[
styles.progressBarHorizontal,
{
width: `${progressPercent}%`,
backgroundColor: currentTheme.colors.primary,
}
]}
/>
</View>
)}
{/* Completed Badge */}
{progressPercent >= 85 && (
<View style={[styles.completedBadgeHorizontal, {
backgroundColor: currentTheme.colors.primary,
}]}>
<MaterialIcons name="check" size={16} color="#fff" />
</View>
)}
</LinearGradient>
</TouchableOpacity>
);
};
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || []; const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
return ( return (
@ -308,21 +547,48 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'} {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
</Text> </Text>
{settings.episodeLayoutStyle === 'horizontal' ? (
// Horizontal Layout (Netflix-style)
<ScrollView
ref={episodeScrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.episodeList}
contentContainerStyle={styles.episodeListContentHorizontal}
decelerationRate="fast"
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
snapToAlignment="start"
>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
style={[
styles.episodeCardWrapperHorizontal,
isTablet && styles.episodeCardWrapperHorizontalTablet
]}
>
{renderHorizontalEpisodeCard(episode)}
</Animated.View>
))}
</ScrollView>
) : (
// Vertical Layout (Traditional)
<ScrollView <ScrollView
style={styles.episodeList} style={styles.episodeList}
contentContainerStyle={[ contentContainerStyle={[
styles.episodeListContent, styles.episodeListContentVertical,
isTablet && styles.episodeListContentTablet isTablet && styles.episodeListContentVerticalTablet
]} ]}
> >
{isTablet ? ( {isTablet ? (
<View style={styles.episodeGrid}> <View style={styles.episodeGridVertical}>
{currentSeasonEpisodes.map((episode, index) => ( {currentSeasonEpisodes.map((episode, index) => (
<Animated.View <Animated.View
key={episode.id} key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)} entering={FadeIn.duration(400).delay(300 + index * 50)}
> >
{renderEpisodeCard(episode)} {renderVerticalEpisodeCard(episode)}
</Animated.View> </Animated.View>
))} ))}
</View> </View>
@ -332,11 +598,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
key={episode.id} key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)} entering={FadeIn.duration(400).delay(300 + index * 50)}
> >
{renderEpisodeCard(episode)} {renderVerticalEpisodeCard(episode)}
</Animated.View> </Animated.View>
)) ))
)} )}
</ScrollView> </ScrollView>
)}
</Animated.View> </Animated.View>
</View> </View>
); );
@ -345,7 +612,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
padding: 16, paddingVertical: 16,
}, },
centeredContainer: { centeredContainer: {
flex: 1, flex: 1,
@ -362,22 +629,26 @@ const styles = StyleSheet.create({
fontSize: 20, fontSize: 20,
fontWeight: '700', fontWeight: '700',
marginBottom: 16, marginBottom: 16,
paddingHorizontal: 16,
}, },
episodeList: { episodeList: {
flex: 1, flex: 1,
}, },
episodeListContent: {
// Vertical Layout Styles
episodeListContentVertical: {
paddingBottom: 20, paddingBottom: 20,
paddingHorizontal: 16,
}, },
episodeListContentTablet: { episodeListContentVerticalTablet: {
paddingHorizontal: 8, paddingHorizontal: 8,
}, },
episodeGrid: { episodeGridVertical: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
justifyContent: 'space-between', justifyContent: 'space-between',
}, },
episodeCard: { episodeCardVertical: {
flexDirection: 'row', flexDirection: 'row',
borderRadius: 16, borderRadius: 16,
marginBottom: 16, marginBottom: 16,
@ -391,7 +662,7 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255,255,255,0.1)', borderColor: 'rgba(255,255,255,0.1)',
height: 120, height: 120,
}, },
episodeCardTablet: { episodeCardVerticalTablet: {
width: '48%', width: '48%',
flexDirection: 'column', flexDirection: 'column',
height: 120, height: 120,
@ -461,6 +732,19 @@ const styles = StyleSheet.create({
fontWeight: '700', fontWeight: '700',
marginLeft: 4, 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: { airDateText: {
fontSize: 12, fontSize: 12,
opacity: 0.8, opacity: 0.8,
@ -469,8 +753,170 @@ const styles = StyleSheet.create({
fontSize: 13, fontSize: 13,
lineHeight: 18, 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: { seasonSelectorWrapper: {
marginBottom: 20, marginBottom: 20,
paddingHorizontal: 16,
}, },
seasonSelectorTitle: { seasonSelectorTitle: {
fontSize: 18, fontSize: 18,
@ -517,54 +963,4 @@ const styles = StyleSheet.create({
selectedSeasonButtonText: { selectedSeasonButtonText: {
fontWeight: '700', 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,
},
}); });

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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<View>;
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<PlayerControlsProps> = ({
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 (
<Animated.View
style={[StyleSheet.absoluteFill, { opacity: fadeAnim }]}
pointerEvents={showControls ? 'auto' : 'none'}
>
{/* Progress bar with enhanced touch handling */}
<View style={styles.sliderContainer}>
<View
style={styles.progressTouchArea}
onTouchStart={handleProgressBarDragStart}
onTouchMove={handleProgressBarDragMove}
onTouchEnd={handleProgressBarDragEnd}
>
<TouchableOpacity
activeOpacity={0.8}
onPress={handleProgressBarTouch}
style={{width: '100%'}}
>
<View
ref={progressBarRef}
style={styles.progressBarContainer}
>
{/* Buffered Progress */}
<View style={[styles.bufferProgress, {
width: `${(buffered / (duration || 1)) * 100}%`
}]} />
{/* Animated Progress */}
<Animated.View
style={[
styles.progressBarFill,
{
width: progressAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', '100%']
})
}
]}
/>
</View>
</TouchableOpacity>
</View>
<View style={styles.timeDisplay}>
<Text style={styles.duration}>{formatTime(currentTime)}</Text>
<Text style={styles.duration}>{formatTime(duration)}</Text>
</View>
</View>
{/* Controls Overlay */}
<View style={styles.controlsContainer}>
{/* Top Gradient & Header */}
<LinearGradient
colors={['rgba(0,0,0,0.7)', 'transparent']}
style={styles.topGradient}
>
<View style={styles.header}>
{/* Title Section - Enhanced with metadata */}
<View style={styles.titleSection}>
<Text style={styles.title}>{title}</Text>
{/* Show season and episode for series */}
{season && episode && (
<Text style={styles.episodeInfo}>
S{season}E{episode} {episodeTitle && `${episodeTitle}`}
</Text>
)}
{/* Show year, quality, and provider */}
<View style={styles.metadataRow}>
{year && <Text style={styles.metadataText}>{year}</Text>}
{quality && <View style={styles.qualityBadge}><Text style={styles.qualityText}>{quality}</Text></View>}
{streamName && <Text style={styles.providerText}>via {streamName}</Text>}
</View>
</View>
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
<Ionicons name="close" size={24} color="white" />
</TouchableOpacity>
</View>
</LinearGradient>
{/* Center Controls (Play/Pause, Skip) */}
<View style={styles.controls}>
<TouchableOpacity onPress={() => skip(-10)} style={styles.skipButton}>
<Ionicons name="play-back" size={24} color="white" />
<Text style={styles.skipText}>10</Text>
</TouchableOpacity>
<TouchableOpacity onPress={togglePlayback} style={styles.playButton}>
<Ionicons name={paused ? "play" : "pause"} size={40} color="white" />
</TouchableOpacity>
<TouchableOpacity onPress={() => skip(10)} style={styles.skipButton}>
<Ionicons name="play-forward" size={24} color="white" />
<Text style={styles.skipText}>10</Text>
</TouchableOpacity>
</View>
{/* Bottom Gradient */}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.7)']}
style={styles.bottomGradient}
>
<View style={styles.bottomControls}>
{/* Bottom Buttons Row */}
<View style={styles.bottomButtons}>
{/* Fill/Cover Button - Updated to show fill/cover modes */}
<TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}>
<Ionicons name="resize" size={20} color="white" />
<Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}>
{zoomScale === 1.1 ? 'Fill' : 'Cover'}
</Text>
</TouchableOpacity>
{/* Audio Button - Updated to use vlcAudioTracks */}
<TouchableOpacity
style={styles.bottomButton}
onPress={() => setShowAudioModal(true)}
disabled={vlcAudioTracks.length <= 1}
>
<Ionicons name="volume-high" size={20} color={vlcAudioTracks.length <= 1 ? 'grey' : 'white'} />
<Text style={[styles.bottomButtonText, vlcAudioTracks.length <= 1 && {color: 'grey'}]}>
{`Audio: ${getTrackDisplayName(vlcAudioTracks.find(t => t.id === selectedAudioTrack) || {id: -1, name: 'Default'})}`}
</Text>
</TouchableOpacity>
{/* Subtitle Button - Always available for external subtitle search */}
<TouchableOpacity
style={styles.bottomButton}
onPress={() => setShowSubtitleModal(true)}
>
<Ionicons name="text" size={20} color="white" />
<Text style={styles.bottomButtonText}>
Subtitles
</Text>
</TouchableOpacity>
{/* Change Source Button */}
{setShowSourcesModal && (
<TouchableOpacity
style={styles.bottomButton}
onPress={() => setShowSourcesModal(true)}
>
<Ionicons name="swap-horizontal" size={20} color="white" />
<Text style={styles.bottomButtonText}>
Change Source
</Text>
</TouchableOpacity>
)}
</View>
</View>
</LinearGradient>
</View>
</Animated.View>
);
};
export default PlayerControls;

View file

@ -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;
}) => (
<Animated.View
entering={FadeInUp.duration(200).delay(delay)}
style={{
backgroundColor: bgColor,
borderColor: `${color}40`,
borderWidth: 1,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
elevation: 2,
shadowColor: color,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.3,
shadowRadius: 2,
}}
>
{icon && (
<MaterialIcons name={icon as any} size={12} color={color} style={{ marginRight: 4 }} />
)}
<Text style={{
color: color,
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.3,
}}>
{text}
</Text>
</Animated.View>
);
export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
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 (
<Animated.View
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(200)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
padding: 16,
}}
>
{/* Backdrop */}
<TouchableOpacity
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onPress={handleClose}
activeOpacity={1}
/>
{/* Modal Content */}
<Animated.View
style={[
{
width: MODAL_WIDTH,
maxHeight: MODAL_MAX_HEIGHT,
minHeight: height * 0.3,
overflow: 'hidden',
elevation: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.4,
shadowRadius: 25,
alignSelf: 'center',
},
modalStyle,
]}
>
{/* Glassmorphism Background */}
<BlurView
intensity={100}
tint="dark"
style={{
borderRadius: 28,
overflow: 'hidden',
backgroundColor: 'rgba(26, 26, 26, 0.8)',
width: '100%',
height: '100%',
}}
>
{/* Header */}
<LinearGradient
colors={[
'rgba(249, 115, 22, 0.95)',
'rgba(234, 88, 12, 0.95)',
'rgba(194, 65, 12, 0.9)'
]}
locations={[0, 0.6, 1]}
style={{
paddingHorizontal: 28,
paddingVertical: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
width: '100%',
}}
>
<Animated.View
entering={FadeInDown.duration(300).delay(100)}
style={{ flex: 1 }}
>
<Text style={{
color: '#fff',
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.8,
textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
}}>
Audio Tracks
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.85)',
fontSize: 14,
marginTop: 4,
fontWeight: '500',
letterSpacing: 0.2,
}}>
Choose from {vlcAudioTracks.length} available track{vlcAudioTracks.length !== 1 ? 's' : ''}
</Text>
</Animated.View>
<Animated.View entering={BounceIn.duration(400).delay(200)}>
<TouchableOpacity
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#fff" />
</TouchableOpacity>
</Animated.View>
</LinearGradient>
{/* Content */}
<ScrollView
style={{
maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height
backgroundColor: 'transparent',
width: '100%',
}}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 24,
paddingBottom: 32,
width: '100%',
}}
bounces={false}
>
<View style={styles.modernTrackListContainer}>
{vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => (
<Animated.View
key={track.id}
entering={FadeInDown.duration(300).delay(150 + (index * 50))}
layout={Layout.springify()}
style={{
marginBottom: 16,
width: '100%',
}}
>
<TouchableOpacity
style={{
backgroundColor: selectedAudioTrack === track.id
? 'rgba(249, 115, 22, 0.08)'
: 'rgba(255, 255, 255, 0.03)',
borderRadius: 20,
padding: 20,
borderWidth: 2,
borderColor: selectedAudioTrack === track.id
? 'rgba(249, 115, 22, 0.4)'
: 'rgba(255, 255, 255, 0.08)',
elevation: selectedAudioTrack === track.id ? 8 : 3,
shadowColor: selectedAudioTrack === track.id ? '#F97316' : '#000',
shadowOffset: { width: 0, height: selectedAudioTrack === track.id ? 4 : 2 },
shadowOpacity: selectedAudioTrack === track.id ? 0.3 : 0.1,
shadowRadius: selectedAudioTrack === track.id ? 12 : 6,
width: '100%',
}}
onPress={() => {
selectAudioTrack(track.id);
handleClose();
}}
activeOpacity={0.85}
>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}>
<View style={{ flex: 1, marginRight: 16 }}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 12,
}}>
<Text style={{
color: selectedAudioTrack === track.id ? '#fff' : 'rgba(255, 255, 255, 0.95)',
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
flex: 1,
}}>
{getTrackDisplayName(track)}
</Text>
{selectedAudioTrack === track.id && (
<Animated.View
entering={BounceIn.duration(300)}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(249, 115, 22, 0.25)',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(249, 115, 22, 0.5)',
}}
>
<MaterialIcons name="volume-up" size={12} color="#F97316" />
<Text style={{
color: '#F97316',
fontSize: 10,
fontWeight: '800',
marginLeft: 3,
letterSpacing: 0.3,
}}>
ACTIVE
</Text>
</Animated.View>
)}
</View>
<View style={{
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
alignItems: 'center',
}}>
<AudioBadge
text="AUDIO TRACK"
color="#F97316"
bgColor="rgba(249, 115, 22, 0.15)"
icon="audiotrack"
/>
{track.language && (
<AudioBadge
text={track.language.toUpperCase()}
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
icon="language"
delay={50}
/>
)}
</View>
</View>
<View style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: selectedAudioTrack === track.id
? 'rgba(249, 115, 22, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: selectedAudioTrack === track.id
? 'rgba(249, 115, 22, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
}}>
{selectedAudioTrack === track.id ? (
<Animated.View entering={ZoomIn.duration(200)}>
<MaterialIcons name="check-circle" size={24} color="#F97316" />
</Animated.View>
) : (
<MaterialIcons name="volume-up" size={24} color="rgba(255,255,255,0.6)" />
)}
</View>
</View>
</TouchableOpacity>
</Animated.View>
)) : (
<Animated.View
entering={FadeInDown.duration(300).delay(150)}
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderRadius: 20,
padding: 40,
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.05)',
width: '100%',
}}
>
<MaterialIcons name="volume-off" size={48} color="rgba(255, 255, 255, 0.3)" />
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 18,
fontWeight: '700',
marginTop: 16,
textAlign: 'center',
letterSpacing: -0.3,
}}>
No audio tracks found
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.4)',
fontSize: 14,
marginTop: 8,
textAlign: 'center',
lineHeight: 20,
}}>
No audio tracks are available for this content.{'\n'}Try a different source or check your connection.
</Text>
</Animated.View>
)}
</View>
</ScrollView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
export default AudioTrackModal;

View file

@ -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<ResumeOverlayProps> = ({
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 (
<View style={styles.resumeOverlay}>
<LinearGradient
colors={['rgba(0,0,0,0.9)', 'rgba(0,0,0,0.7)']}
style={styles.resumeContainer}
>
<View style={styles.resumeContent}>
<View style={styles.resumeIconContainer}>
<Ionicons name="play-circle" size={40} color="#E50914" />
</View>
<View style={styles.resumeTextContainer}>
<Text style={styles.resumeTitle}>Continue Watching</Text>
<Text style={styles.resumeInfo}>
{title}
{season && episode && ` • S${season}E${episode}`}
</Text>
<View style={styles.resumeProgressContainer}>
<View style={styles.resumeProgressBar}>
<View
style={[
styles.resumeProgressFill,
{ width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` }
]}
/>
</View>
<Text style={styles.resumeTimeText}>
{formatTime(resumePosition)} {duration > 0 ? `/ ${formatTime(duration)}` : ''}
</Text>
</View>
</View>
</View>
{/* Remember choice checkbox */}
<TouchableOpacity
style={styles.rememberChoiceContainer}
onPress={() => setRememberChoice(!rememberChoice)}
activeOpacity={0.7}
>
<View style={styles.checkboxContainer}>
<View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}>
{rememberChoice && <Ionicons name="checkmark" size={12} color="white" />}
</View>
<Text style={styles.rememberChoiceText}>Remember my choice</Text>
</View>
{resumePreference && (
<TouchableOpacity
onPress={resetResumePreference}
style={styles.resetPreferenceButton}
>
<Text style={styles.resetPreferenceText}>Reset</Text>
</TouchableOpacity>
)}
</TouchableOpacity>
<View style={styles.resumeButtons}>
<TouchableOpacity
style={styles.resumeButton}
onPress={handleStartFromBeginning}
>
<Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Start Over</Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.resumeButton, styles.resumeFromButton]}
onPress={handleResume}
>
<Ionicons name="play" size={16} color="white" style={styles.buttonIcon} />
<Text style={styles.resumeButtonText}>Resume</Text>
</TouchableOpacity>
</View>
</LinearGradient>
</View>
);
};
export default ResumeOverlay;

View file

@ -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 (
<Animated.View
entering={ZoomIn.duration(200).delay(100)}
style={{
backgroundColor: `${color}20`,
borderColor: `${color}60`,
borderWidth: 1,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 8,
flexDirection: 'row',
alignItems: 'center',
}}
>
<View style={{
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: color,
marginRight: 4,
}} />
<Text style={{
color: color,
fontSize: 10,
fontWeight: '700',
letterSpacing: 0.5,
}}>
{label}
</Text>
</Animated.View>
);
};
const StreamMetaBadge = ({
text,
color,
bgColor,
icon,
delay = 0
}: {
text: string;
color: string;
bgColor: string;
icon?: string;
delay?: number;
}) => (
<Animated.View
entering={FadeInUp.duration(200).delay(delay)}
style={{
backgroundColor: bgColor,
borderColor: `${color}40`,
borderWidth: 1,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: 6,
flexDirection: 'row',
alignItems: 'center',
elevation: 2,
shadowColor: color,
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.3,
shadowRadius: 2,
}}
>
{icon && (
<MaterialIcons name={icon as any} size={10} color={color} style={{ marginRight: 2 }} />
)}
<Text style={{
color: color,
fontSize: 9,
fontWeight: '800',
letterSpacing: 0.3,
}}>
{text}
</Text>
</Animated.View>
);
const SourcesModal: React.FC<SourcesModalProps> = ({
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 (
<Animated.View
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(200)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.9)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
padding: 16,
}}
>
{/* Backdrop */}
<TouchableOpacity
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onPress={handleClose}
activeOpacity={1}
/>
{/* Modal Content */}
<Animated.View
style={[
{
width: MODAL_WIDTH,
maxHeight: MODAL_MAX_HEIGHT,
minHeight: height * 0.3,
overflow: 'hidden',
elevation: 25,
shadowColor: '#000',
shadowOffset: { width: 0, height: 12 },
shadowOpacity: 0.4,
shadowRadius: 25,
alignSelf: 'center',
},
modalStyle,
]}
>
{/* Glassmorphism Background */}
<BlurView
intensity={100}
tint="dark"
style={{
borderRadius: 28,
overflow: 'hidden',
backgroundColor: 'rgba(26, 26, 26, 0.8)',
width: '100%',
height: '100%',
}}
>
{/* Header */}
<LinearGradient
colors={[
'rgba(229, 9, 20, 0.95)',
'rgba(176, 6, 16, 0.95)',
'rgba(139, 5, 12, 0.9)'
]}
locations={[0, 0.6, 1]}
style={{
paddingHorizontal: 28,
paddingVertical: 24,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
width: '100%',
}}
>
<Animated.View
entering={FadeInDown.duration(300).delay(100)}
style={{ flex: 1 }}
>
<Text style={{
color: '#fff',
fontSize: 24,
fontWeight: '800',
letterSpacing: -0.8,
textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
}}>
Switch Source
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.85)',
fontSize: 14,
marginTop: 4,
fontWeight: '500',
letterSpacing: 0.2,
}}>
Choose from {Object.values(availableStreams).reduce((acc, curr) => acc + curr.streams.length, 0)} available streams
</Text>
</Animated.View>
<Animated.View entering={BounceIn.duration(400).delay(200)}>
<TouchableOpacity
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#fff" />
</TouchableOpacity>
</Animated.View>
</LinearGradient>
{/* Content */}
<ScrollView
style={{
maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height
backgroundColor: 'transparent',
width: '100%',
}}
showsVerticalScrollIndicator={false}
contentContainerStyle={{
padding: 24,
paddingBottom: 32,
width: '100%',
}}
bounces={false}
>
{sortedProviders.map(([providerId, { streams, addonName }], providerIndex) => (
<Animated.View
key={providerId}
entering={FadeInDown.duration(400).delay(150 + (providerIndex * 80))}
layout={Layout.springify()}
style={{
marginBottom: streams.length > 0 ? 32 : 0,
width: '100%',
}}
>
{/* Provider Header */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
width: '100%',
}}>
<LinearGradient
colors={providerId === 'hdrezka' ? ['#00d4aa', '#00a085'] : ['#E50914', '#B00610']}
style={{
width: 12,
height: 12,
borderRadius: 6,
marginRight: 16,
elevation: 3,
shadowColor: providerId === 'hdrezka' ? '#00d4aa' : '#E50914',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 4,
}}
/>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
fontSize: 18,
fontWeight: '700',
letterSpacing: -0.3,
}}>
{addonName}
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 12,
marginTop: 1,
fontWeight: '500',
}}>
Provider {streams.length} stream{streams.length !== 1 ? 's' : ''}
</Text>
</View>
<View style={{
backgroundColor: 'rgba(255, 255, 255, 0.08)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
}}>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.5,
}}>
{streams.length}
</Text>
</View>
</View>
{/* Streams Grid */}
<View style={{ gap: 16, width: '100%' }}>
{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 (
<Animated.View
key={`${stream.url}-${index}`}
entering={FadeInDown.duration(300).delay((providerIndex * 80) + (index * 40))}
layout={Layout.springify()}
style={{ width: '100%' }}
>
<TouchableOpacity
style={{
backgroundColor: isSelected
? 'rgba(229, 9, 20, 0.08)'
: 'rgba(255, 255, 255, 0.03)',
borderRadius: 20,
padding: 20,
borderWidth: 2,
borderColor: isSelected
? 'rgba(229, 9, 20, 0.4)'
: 'rgba(255, 255, 255, 0.08)',
elevation: isSelected ? 8 : 3,
shadowColor: isSelected ? '#E50914' : '#000',
shadowOffset: { width: 0, height: isSelected ? 4 : 2 },
shadowOpacity: isSelected ? 0.3 : 0.1,
shadowRadius: isSelected ? 12 : 6,
transform: [{ scale: isSelected ? 1.02 : 1 }],
width: '100%',
}}
onPress={() => handleStreamSelect(stream)}
disabled={isChangingSource || isSelected}
activeOpacity={0.85}
>
<View style={{
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
width: '100%',
}}>
{/* Stream Info */}
<View style={{ flex: 1, marginRight: 16 }}>
{/* Title Row */}
<View style={{
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
flexWrap: 'wrap',
gap: 8,
}}>
<Text style={{
color: isSelected ? '#fff' : 'rgba(255, 255, 255, 0.95)',
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
flex: 1,
lineHeight: 22,
}}>
{isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')}
</Text>
{isSelected && (
<Animated.View
entering={BounceIn.duration(300)}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(229, 9, 20, 0.25)',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(229, 9, 20, 0.5)',
elevation: 4,
shadowColor: '#E50914',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
}}
>
<MaterialIcons name="play-circle-filled" size={12} color="#E50914" />
<Text style={{
color: '#E50914',
fontSize: 10,
fontWeight: '800',
marginLeft: 3,
letterSpacing: 0.3,
}}>
PLAYING
</Text>
</Animated.View>
)}
{isChangingSource && isSelected && (
<Animated.View
entering={FadeIn.duration(200)}
style={{
backgroundColor: 'rgba(229, 9, 20, 0.2)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
}}
>
<ActivityIndicator size="small" color="#E50914" />
<Text style={{
color: '#E50914',
fontSize: 10,
fontWeight: '600',
marginLeft: 4,
}}>
Switching...
</Text>
</Animated.View>
)}
</View>
{/* Subtitle */}
{!isHDRezka && stream.title && stream.title !== stream.name && (
<Text style={{
color: 'rgba(255, 255, 255, 0.65)',
fontSize: 13,
marginBottom: 12,
lineHeight: 18,
fontWeight: '400',
}}>
{stream.title}
</Text>
)}
{/* Enhanced Meta Info */}
<View style={{
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
alignItems: 'center',
}}>
<QualityIndicator quality={quality} />
{isDolby && (
<StreamMetaBadge
text="DOLBY"
color="#8B5CF6"
bgColor="rgba(139, 92, 246, 0.15)"
icon="hd"
delay={100}
/>
)}
{isHDR && (
<StreamMetaBadge
text="HDR"
color="#F59E0B"
bgColor="rgba(245, 158, 11, 0.15)"
icon="brightness-high"
delay={120}
/>
)}
{size && (
<StreamMetaBadge
text={size}
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
icon="storage"
delay={140}
/>
)}
{isDebrid && (
<StreamMetaBadge
text="DEBRID"
color="#00d4aa"
bgColor="rgba(0, 212, 170, 0.15)"
icon="flash-on"
delay={160}
/>
)}
{isHDRezka && (
<StreamMetaBadge
text="HDREZKA"
color="#00d4aa"
bgColor="rgba(0, 212, 170, 0.15)"
icon="verified"
delay={180}
/>
)}
</View>
</View>
{/* Enhanced Action Icon */}
<View style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: isSelected
? 'rgba(229, 9, 20, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: isSelected
? 'rgba(229, 9, 20, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
elevation: 4,
shadowColor: isSelected ? '#E50914' : '#fff',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: isSelected ? 0.2 : 0.05,
shadowRadius: 4,
}}>
{isSelected ? (
<Animated.View entering={ZoomIn.duration(200)}>
<MaterialIcons name="check-circle" size={24} color="#E50914" />
</Animated.View>
) : (
<MaterialIcons name="play-arrow" size={24} color="rgba(255,255,255,0.6)" />
)}
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
})}
</View>
</Animated.View>
))}
</ScrollView>
</BlurView>
</Animated.View>
</Animated.View>
);
};
export default SourcesModal;

File diff suppressed because it is too large Load diff

View file

@ -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<CustomSubtitlesProps> = ({
useCustomSubtitles,
currentSubtitle,
subtitleSize,
}) => {
if (!useCustomSubtitles || !currentSubtitle) return null;
return (
<View style={styles.customSubtitleContainer} pointerEvents="none">
<View style={styles.customSubtitleWrapper}>
<Text style={[styles.customSubtitleText, { fontSize: subtitleSize }]}>
{currentSubtitle}
</Text>
</View>
</View>
);
};
export default CustomSubtitles;

View file

@ -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',
},
});

View file

@ -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;
}

View file

@ -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;
};

View file

@ -45,9 +45,9 @@ export const DEFAULT_THEMES: Theme[] = [
name: 'Moonlight', name: 'Moonlight',
colors: { colors: {
...defaultColors, ...defaultColors,
primary: '#a786df', primary: '#c084fc',
secondary: '#5e72e4', secondary: '#60a5fa',
darkBackground: '#0f0f1a', darkBackground: '#060609',
}, },
isEditable: false, isEditable: false,
}, },

View file

@ -1,6 +1,13 @@
import React, { createContext, useContext, ReactNode } from 'react'; import React, { createContext, useContext, ReactNode } from 'react';
import { useTraktIntegration } from '../hooks/useTraktIntegration'; import { useTraktIntegration } from '../hooks/useTraktIntegration';
import { TraktUser, TraktWatchedItem } from '../services/traktService'; import {
TraktUser,
TraktWatchedItem,
TraktWatchlistItem,
TraktCollectionItem,
TraktRatingItem,
TraktPlaybackItem
} from '../services/traktService';
interface TraktContextProps { interface TraktContextProps {
isAuthenticated: boolean; isAuthenticated: boolean;
@ -8,13 +15,21 @@ interface TraktContextProps {
userProfile: TraktUser | null; userProfile: TraktUser | null;
watchedMovies: TraktWatchedItem[]; watchedMovies: TraktWatchedItem[];
watchedShows: TraktWatchedItem[]; watchedShows: TraktWatchedItem[];
watchlistMovies: TraktWatchlistItem[];
watchlistShows: TraktWatchlistItem[];
collectionMovies: TraktCollectionItem[];
collectionShows: TraktCollectionItem[];
continueWatching: TraktPlaybackItem[];
ratedContent: TraktRatingItem[];
checkAuthStatus: () => Promise<void>; checkAuthStatus: () => Promise<void>;
refreshAuthStatus: () => Promise<void>; refreshAuthStatus: () => Promise<void>;
loadWatchedItems: () => Promise<void>; loadWatchedItems: () => Promise<void>;
loadAllCollections: () => Promise<void>;
isMovieWatched: (imdbId: string) => Promise<boolean>; isMovieWatched: (imdbId: string) => Promise<boolean>;
isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>; isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>;
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>; markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>;
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>; markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>;
forceSyncTraktProgress?: () => Promise<boolean>;
} }
const TraktContext = createContext<TraktContextProps | undefined>(undefined); const TraktContext = createContext<TraktContextProps | undefined>(undefined);

View file

@ -3,11 +3,15 @@ import { StreamingContent } from '../services/catalogService';
import { catalogService } from '../services/catalogService'; import { catalogService } from '../services/catalogService';
import { stremioService } from '../services/stremioService'; import { stremioService } from '../services/stremioService';
import { tmdbService } from '../services/tmdbService'; import { tmdbService } from '../services/tmdbService';
import { hdrezkaService } from '../services/hdrezkaService';
import { cacheService } from '../services/cacheService'; import { cacheService } from '../services/cacheService';
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata'; import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
import { TMDBService } from '../services/tmdbService'; import { TMDBService } from '../services/tmdbService';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { usePersistentSeasons } from './usePersistentSeasons'; 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 // Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds const API_TIMEOUT = 10000; // 10 seconds
@ -56,6 +60,7 @@ const withRetry = async <T>(
interface UseMetadataProps { interface UseMetadataProps {
id: string; id: string;
type: string; type: string;
addonId?: string;
} }
interface UseMetadataReturn { interface UseMetadataReturn {
@ -90,7 +95,7 @@ interface UseMetadataReturn {
imdbId: string | null; imdbId: string | null;
} }
export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn => { export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
const [metadata, setMetadata] = useState<StreamingContent | null>(null); const [metadata, setMetadata] = useState<StreamingContent | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -113,6 +118,8 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]); const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false); const [loadingRecommendations, setLoadingRecommendations] = useState(false);
const [imdbId, setImdbId] = useState<string | null>(null); const [imdbId, setImdbId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
// Add hook for persistent seasons // Add hook for persistent seasons
const { getSeason, saveSeason } = usePersistentSeasons(); const { getSeason, saveSeason } = usePersistentSeasons();
@ -150,8 +157,12 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
if (isEpisode) { if (isEpisode) {
setEpisodeStreams(updateState); setEpisodeStreams(updateState);
// Turn off loading when we get streams
setLoadingEpisodeStreams(false);
} else { } else {
setGroupedStreams(updateState); setGroupedStreams(updateState);
// Turn off loading when we get streams
setLoadingStreams(false);
} }
} else { } else {
logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`); logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
@ -173,22 +184,60 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Loading indicators should probably be managed based on callbacks completing. // Loading indicators should probably be managed based on callbacks completing.
}; };
const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => { const processHDRezkaSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => {
const sourceStartTime = Date.now(); const sourceStartTime = Date.now();
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams'; const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
const sourceName = 'hdrezka';
logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`);
try { try {
logger.log(`🔍 [${logPrefix}:${sourceType}] Starting fetch`); const streams = await hdrezkaService.getStreams(
id,
type,
season,
episode
);
const processTime = Date.now() - sourceStartTime;
if (streams && streams.length > 0) {
logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`);
// Format response similar to Stremio format for the UI
return {
'hdrezka': {
addonName: 'HDRezka',
streams
}
};
} else {
logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`);
return {};
}
} catch (error) {
logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error);
return {};
}
};
const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
try {
const startTime = Date.now();
const result = await promise; const result = await promise;
logger.log(`✅ [${logPrefix}:${sourceType}] Completed in ${Date.now() - sourceStartTime}ms`); const processingTime = Date.now() - startTime;
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`);
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 updateState = (prevState: GroupedStreams) => {
logger.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`); const newState = { ...prevState };
return { ...prevState, ...result };
// Merge in the new streams
Object.entries(result).forEach(([provider, data]: [string, any]) => {
newState[provider] = data;
});
return newState;
}; };
if (isEpisode) { if (isEpisode) {
@ -196,12 +245,19 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
} else { } else {
setGroupedStreams(updateState); setGroupedStreams(updateState);
} }
} else {
logger.log(`⚠️ [${logPrefix}:${sourceType}] No streams found`); 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; return result;
} else {
console.log(`⚠️ [processExternalSource:${sourceType}] No streams found after ${processingTime}ms`);
return {};
}
} catch (error) { } catch (error) {
logger.error(`❌ [${logPrefix}:${sourceType}] Error:`, error); console.error(`❌ [processExternalSource:${sourceType}] Error:`, error);
return {}; return {};
} }
}; };
@ -356,7 +412,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
if (writers.length > 0) { if (writers.length > 0) {
(formattedMovie as any).creators = writers; (formattedMovie as any).creators = writers;
(formattedMovie as StreamingContent & { writer: string }).writer = writers.join(', '); (formattedMovie as any).writer = writers;
} }
} }
} catch (error) { } catch (error) {
@ -459,7 +515,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Load content with timeout and retry // Load content with timeout and retry
withRetry(async () => { withRetry(async () => {
const result = await withTimeout( const result = await withTimeout(
catalogService.getContentDetails(type, actualId), catalogService.getEnhancedContentDetails(type, actualId, addonId),
API_TIMEOUT API_TIMEOUT
); );
// Store the actual ID used (could be IMDB) // 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); cacheService.setMetadata(id, type, content.value);
if (type === 'series') { 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); loadSeriesData().catch(console.error);
}, 100);
} }
} else { } else {
throw new Error('Content not found'); throw new Error('Content not found');
@ -509,6 +567,67 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const loadSeriesData = async () => { const loadSeriesData = async () => {
setLoadingSeasons(true); setLoadingSeasons(true);
try { 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); const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
if (tmdbIdResult) { if (tmdbIdResult) {
setTmdbId(tmdbIdResult); setTmdbId(tmdbIdResult);
@ -535,14 +654,61 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Get the first available season as fallback // Get the first available season as fallback
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number)); const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
// Get saved season from persistence, fallback to first season if not found // Check for watch progress to auto-select season
const persistedSeason = getSeason(id, firstSeason); let selectedSeasonNumber = firstSeason;
// Set the selected season from persistence try {
setSelectedSeason(persistedSeason); // 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 // Set episodes for the selected season
setEpisodes(transformedEpisodes[persistedSeason] || []); setEpisodes(transformedEpisodes[selectedSeasonNumber] || []);
} }
} catch (error) { } catch (error) {
console.error('Failed to load episodes:', 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); console.log('🚀 [loadStreams] START - Loading streams for:', id);
updateLoadingState(); updateLoadingState();
// Always clear streams first to ensure we don't show stale data // Get TMDB ID for external sources and determine the correct ID for Stremio addons
setGroupedStreams({});
// Get TMDB ID for external sources first before starting parallel requests
console.log('🔍 [loadStreams] Getting TMDB ID for:', id); console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
let tmdbId; let tmdbId;
let stremioId = id; // Default to original ID
if (id.startsWith('tmdb:')) { if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1]; tmdbId = id.split(':')[1];
console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId); 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')) { } 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...'); console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT); tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId); console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId);
} else { } else {
tmdbId = id; 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 converted ID format
console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
processStremioSource(type, stremioId, false);
// Start Stremio request using the callback method // Add HDRezka source
processStremioSource(type, id, false); const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false);
// No external sources are used anymore // Include HDRezka in fetchPromises array
const fetchPromises: Promise<any>[] = []; const fetchPromises: Promise<any>[] = [hdrezkaPromise];
// Wait only for external promises now (none in this case) // Wait only for external promises now
const results = await Promise.allSettled(fetchPromises); const results = await Promise.allSettled(fetchPromises);
const totalTime = Date.now() - startTime; const totalTime = Date.now() - startTime;
console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); 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) => { results.forEach((result, index) => {
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
console.log(`📊 [loadStreams:${source}] Status: ${result.status}`); console.log(`📊 [loadStreams:${source}] Status: ${result.status}`);
@ -634,15 +832,15 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
return prev; 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) { } catch (error) {
console.error('❌ [loadStreams] Failed to load streams:', error); console.error('❌ [loadStreams] Failed to load streams:', error);
setError('Failed to load streams'); setError('Failed to load streams');
} finally { setLoadingStreams(false);
// 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
} }
}; };
@ -652,42 +850,76 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId); console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
updateEpisodeLoadingState(); 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); console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
let tmdbId; let tmdbId;
let stremioEpisodeId = episodeId; // Default to original episode ID
if (id.startsWith('tmdb:')) { if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1]; tmdbId = id.split(':')[1];
console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId); 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')) { } 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...'); console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT); tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId); console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
} else { } else {
tmdbId = id; 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 [, season, episode] = episodeId.split(':');
const episodeQuery = `?s=${season}&e=${episode}`; const episodeQuery = `?s=${season}&e=${episode}`;
console.log(` [loadEpisodeStreams] Episode query: ${episodeQuery}`); console.log(` [loadEpisodeStreams] Episode query: ${episodeQuery}`);
console.log('🔄 [loadEpisodeStreams] Starting stream requests'); console.log('🔄 [loadEpisodeStreams] Starting stream requests');
const fetchPromises: Promise<any>[] = []; // 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 // Add HDRezka source for episodes
processStremioSource('series', episodeId, true); const hdrezkaEpisodePromise = processExternalSource('hdrezka',
processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true),
true
);
// No external sources are used anymore const fetchPromises: Promise<any>[] = [hdrezkaEpisodePromise];
// Wait only for external promises now (none in this case) // Wait only for external promises now
const results = await Promise.allSettled(fetchPromises); const results = await Promise.allSettled(fetchPromises);
const totalTime = Date.now() - startTime; const totalTime = Date.now() - startTime;
console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`); 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) => { results.forEach((result, index) => {
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)]; const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`); console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`);
@ -699,31 +931,23 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log('🧮 [loadEpisodeStreams] Summary:'); console.log('🧮 [loadEpisodeStreams] Summary:');
console.log(' Total time for external sources:', totalTime + 'ms'); console.log(' Total time for external sources:', totalTime + 'ms');
// Log the final states - might not include all Stremio addons yet // Update preloaded episode streams for future use
console.log('📦 [loadEpisodeStreams] Current combined streams count:', if (Object.keys(episodeStreams).length > 0) {
Object.keys(episodeStreams).length > 0 ? setPreloadedEpisodeStreams(prev => ({
Object.values(episodeStreams).reduce((acc, group: any) => acc + group.streams.length, 0) : ...prev,
0 [episodeId]: { ...episodeStreams }
);
// Cache the final streams state - Might be incomplete
setEpisodeStreams(prev => {
// Cache episode streams - maybe incrementally?
setPreloadedEpisodeStreams(currentPreloaded => ({
...currentPreloaded,
[episodeId]: prev
})); }));
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) { } catch (error) {
console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error); console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
setError('Failed to load episode streams'); setError('Failed to load episode streams');
} finally { setLoadingEpisodeStreams(false);
// 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
} }
}; };
@ -770,6 +994,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
loadMetadata(); loadMetadata();
}, [id, type]); }, [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 () => { const loadRecommendations = useCallback(async () => {
if (!tmdbId) return; if (!tmdbId) return;

View file

@ -6,242 +6,156 @@ import {
withSpring, withSpring,
Easing, Easing,
useAnimatedScrollHandler, useAnimatedScrollHandler,
interpolate, runOnUI,
Extrapolate,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
// Animation constants // Highly optimized animation configurations
const springConfig = { const fastSpring = {
damping: 20, damping: 15,
mass: 1, mass: 0.8,
stiffness: 100 stiffness: 150,
}; };
// Animation timing constants for staggered appearance const ultraFastSpring = {
const ANIMATION_DELAY_CONSTANTS = { damping: 12,
HERO: 100, mass: 0.6,
LOGO: 250, stiffness: 200,
PROGRESS: 350, };
GENRES: 400,
BUTTONS: 450, // Ultra-optimized easing functions
CONTENT: 500 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) => { export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => {
// Animation values for screen entrance // Consolidated entrance animations - start with visible values for Android compatibility
const screenScale = useSharedValue(0.92); const screenOpacity = useSharedValue(1);
const screenOpacity = useSharedValue(0); const contentOpacity = useSharedValue(1);
// Animation values for hero section // Combined hero animations
const heroHeight = useSharedValue(height * 0.5); const heroOpacity = useSharedValue(1);
const heroScale = useSharedValue(1.05); const heroScale = useSharedValue(1); // Start at 1 for Android compatibility
const heroOpacity = useSharedValue(0); const heroHeightValue = useSharedValue(height * 0.5);
// Animation values for content // Combined UI element animations
const contentTranslateY = useSharedValue(60); const uiElementsOpacity = useSharedValue(1);
const uiElementsTranslateY = useSharedValue(0);
// Animation values for logo // Progress animation - simplified to single value
const logoOpacity = useSharedValue(0); const progressOpacity = useSharedValue(0);
const logoScale = useSharedValue(0.9);
// Animation values for progress // Scroll values - minimal
const watchProgressOpacity = useSharedValue(0);
const watchProgressScaleY = 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
const scrollY = useSharedValue(0); const scrollY = useSharedValue(0);
const dampedScrollY = useSharedValue(0); const headerProgress = useSharedValue(0); // Single value for all header animations
// Header animation values // Static header elements Y for performance
const headerOpacity = useSharedValue(0); const staticHeaderElementsY = useSharedValue(0);
const headerElementsY = useSharedValue(-10);
const headerElementsOpacity = useSharedValue(0);
// Start entrance animation // Ultra-fast entrance sequence - batch animations for better performance
useEffect(() => { useEffect(() => {
// Use a timeout to ensure the animations starts after the component is mounted // Batch all entrance animations to run simultaneously
const animationTimeout = setTimeout(() => { const enterAnimations = () => {
// 1. First animate the container 'worklet';
screenScale.value = withSpring(1, springConfig);
screenOpacity.value = withSpring(1, springConfig);
// 2. Then animate the hero section with a slight delay // Start with slightly reduced values and animate to full visibility
setTimeout(() => { screenOpacity.value = withTiming(1, {
heroOpacity.value = withSpring(1, { duration: 250,
damping: 14, easing: easings.fast
stiffness: 80
}); });
heroScale.value = withSpring(1, {
damping: 18,
stiffness: 100
});
}, ANIMATION_DELAY_CONSTANTS.HERO);
// 3. Then animate the logo heroOpacity.value = withTiming(1, {
setTimeout(() => { duration: 300,
logoOpacity.value = withSpring(1, { easing: easings.fast
damping: 12,
stiffness: 100
}); });
logoScale.value = withSpring(1, {
damping: 14,
stiffness: 90
});
}, ANIMATION_DELAY_CONSTANTS.LOGO);
// 4. Then animate the watch progress if applicable heroScale.value = withSpring(1, ultraFastSpring);
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);
// 5. Then animate the genres uiElementsOpacity.value = withTiming(1, {
setTimeout(() => { duration: 400,
genresOpacity.value = withSpring(1, { easing: easings.natural
damping: 14,
stiffness: 100
}); });
genresTranslateY.value = withSpring(0, {
damping: 18,
stiffness: 120
});
}, ANIMATION_DELAY_CONSTANTS.GENRES);
// 6. Then animate the buttons uiElementsTranslateY.value = withSpring(0, fastSpring);
setTimeout(() => {
buttonsOpacity.value = withSpring(1, {
damping: 14,
stiffness: 100
});
buttonsTranslateY.value = withSpring(0, {
damping: 18,
stiffness: 120
});
}, ANIMATION_DELAY_CONSTANTS.BUTTONS);
// 7. Finally animate the content section contentOpacity.value = withTiming(1, {
setTimeout(() => { duration: 350,
contentTranslateY.value = withSpring(0, { easing: easings.fast
damping: 25,
mass: 1,
stiffness: 100
}); });
}, ANIMATION_DELAY_CONSTANTS.CONTENT);
}, 50); // Small timeout to ensure component is fully mounted
return () => clearTimeout(animationTimeout);
}, []);
// Effect to animate watch progress when it changes
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]);
// 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 // Use runOnUI for better performance
runOnUI(enterAnimations)();
}, []);
// Optimized watch progress animation
useEffect(() => {
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]);
// Ultra-optimized scroll handler with minimal calculations
const scrollHandler = useAnimatedScrollHandler({ const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => { onScroll: (event) => {
'worklet';
const rawScrollY = event.contentOffset.y; const rawScrollY = event.contentOffset.y;
scrollY.value = rawScrollY; scrollY.value = rawScrollY;
// Apply spring-like damping for smoother transitions // Single calculation for header threshold
dampedScrollY.value = withTiming(rawScrollY, { const threshold = height * 0.4 - safeAreaTop;
duration: 300, const progress = rawScrollY > threshold ? 1 : 0;
easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve
});
// Update header opacity based on scroll position // Use single progress value for all header animations
const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer if (headerProgress.value !== progress) {
if (rawScrollY > headerThreshold) { headerProgress.value = withTiming(progress, {
headerOpacity.value = withTiming(1, { duration: 200 }); duration: progress ? 200 : 150,
headerElementsY.value = withTiming(0, { duration: 300 }); easing: easings.ultraFast
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 });
} }
}, },
}); });
return { return {
// Animated values // Optimized shared values - reduced count
screenScale,
screenOpacity, screenOpacity,
heroHeight, contentOpacity,
heroScale,
heroOpacity, heroOpacity,
contentTranslateY, heroScale,
logoOpacity, uiElementsOpacity,
logoScale, uiElementsTranslateY,
watchProgressOpacity, progressOpacity,
watchProgressScaleY,
genresOpacity,
genresTranslateY,
buttonsOpacity,
buttonsTranslateY,
scrollY, scrollY,
dampedScrollY, headerProgress,
headerOpacity,
headerElementsY, // Computed values for compatibility (derived from optimized values)
headerElementsOpacity, 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 // Functions
scrollHandler, scrollHandler,
animateLogo, animateLogo: () => {}, // Simplified - no separate logo animation
}; };
}; };

View file

@ -196,7 +196,15 @@ export const useMetadataAssets = (
else if (shouldFetchLogo && logoFetchInProgress.current) { else if (shouldFetchLogo && logoFetchInProgress.current) {
logger.log(`[useMetadataAssets:Logo] Skipping logo fetch because logoFetchInProgress is true.`); 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 // Fetch banner image based on logo source preference - optimized version
useEffect(() => { useEffect(() => {
@ -218,8 +226,14 @@ export const useMetadataAssets = (
const fetchBanner = async () => { const fetchBanner = async () => {
logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`); logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`);
setLoadingBanner(true); setLoadingBanner(true);
setBannerImage(null); // Clear existing banner to prevent mixed sources
setBannerSource(null); // Clear source tracking // 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 finalBanner: string | null = null;
let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default'; let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default';
@ -411,17 +425,31 @@ export const useMetadataAssets = (
// Set the final state // Set the final state
logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`); logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`);
// Only update if the banner actually changed to avoid unnecessary re-renders
if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) {
setBannerImage(finalBanner); setBannerImage(finalBanner);
setBannerSource(bannerSourceType); // Track the source of the final image 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 forcedBannerRefreshDone.current = true; // Mark this cycle as complete
} catch (error) { } catch (error) {
logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error); logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error);
// Ensure fallback to default even on outer error // Ensure fallback to default even on outer error
const defaultBanner = metadata?.banner || metadata?.poster || null; const defaultBanner = metadata?.banner || metadata?.poster || null;
// Only set if it's different from current banner
if (defaultBanner !== bannerImage) {
setBannerImage(defaultBanner); setBannerImage(defaultBanner);
setBannerSource('default'); setBannerSource('default');
logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`); logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`);
} else {
logger.log(`[useMetadataAssets:Banner] Default banner already set, skipping update`);
}
} finally { } finally {
logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`); logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`);
setLoadingBanner(false); setLoadingBanner(false);

View file

@ -34,6 +34,9 @@ export interface AppSettings {
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code) 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 = { export const DEFAULT_SETTINGS: AppSettings = {
@ -50,6 +53,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
selectedHeroCatalogs: [], // Empty array means all catalogs are selected selectedHeroCatalogs: [], // Empty array means all catalogs are selected
logoSourcePreference: 'metahub', // Default to Metahub as first source logoSourcePreference: 'metahub', // Default to Metahub as first source
tmdbLanguagePreference: 'en', // Default to English 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'; const SETTINGS_STORAGE_KEY = 'app_settings';

View file

@ -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<string | null>(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
};
}

View file

@ -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<TraktAutosyncSettings>(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<boolean> => {
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
};
}

View file

@ -1,5 +1,16 @@
import { useState, useEffect, useCallback } from 'react'; 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'; import { logger } from '../utils/logger';
export function useTraktIntegration() { export function useTraktIntegration() {
@ -8,19 +19,30 @@ export function useTraktIntegration() {
const [userProfile, setUserProfile] = useState<TraktUser | null>(null); const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]); const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]);
const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]); const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]);
const [watchlistMovies, setWatchlistMovies] = useState<TraktWatchlistItem[]>([]);
const [watchlistShows, setWatchlistShows] = useState<TraktWatchlistItem[]>([]);
const [collectionMovies, setCollectionMovies] = useState<TraktCollectionItem[]>([]);
const [collectionShows, setCollectionShows] = useState<TraktCollectionItem[]>([]);
const [continueWatching, setContinueWatching] = useState<TraktPlaybackItem[]>([]);
const [ratedContent, setRatedContent] = useState<TraktRatingItem[]>([]);
const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now()); const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now());
// Check authentication status // Check authentication status
const checkAuthStatus = useCallback(async () => { const checkAuthStatus = useCallback(async () => {
logger.log('[useTraktIntegration] checkAuthStatus called');
setIsLoading(true); setIsLoading(true);
try { try {
const authenticated = await traktService.isAuthenticated(); const authenticated = await traktService.isAuthenticated();
logger.log(`[useTraktIntegration] Authentication check result: ${authenticated}`);
setIsAuthenticated(authenticated); setIsAuthenticated(authenticated);
if (authenticated) { if (authenticated) {
logger.log('[useTraktIntegration] User is authenticated, fetching profile...');
const profile = await traktService.getUserProfile(); const profile = await traktService.getUserProfile();
logger.log(`[useTraktIntegration] User profile: ${profile.username}`);
setUserProfile(profile); setUserProfile(profile);
} else { } else {
logger.log('[useTraktIntegration] User is not authenticated');
setUserProfile(null); setUserProfile(null);
} }
@ -46,8 +68,8 @@ export function useTraktIntegration() {
setIsLoading(true); setIsLoading(true);
try { try {
const [movies, shows] = await Promise.all([ const [movies, shows] = await Promise.all([
traktService.getWatchedMovies(), traktService.getWatchedMoviesWithImages(),
traktService.getWatchedShows() traktService.getWatchedShowsWithImages()
]); ]);
setWatchedMovies(movies); setWatchedMovies(movies);
setWatchedShows(shows); setWatchedShows(shows);
@ -58,6 +80,41 @@ export function useTraktIntegration() {
} }
}, [isAuthenticated]); }, [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 // Check if a movie is watched
const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => { const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => {
if (!isAuthenticated) return false; if (!isAuthenticated) return false;
@ -128,6 +185,224 @@ export function useTraktIntegration() {
} }
}, [isAuthenticated, loadWatchedItems]); }, [isAuthenticated, loadWatchedItems]);
// Start watching content (scrobble start)
const startWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => {
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<boolean> => {
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<boolean> => {
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<boolean> => {
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<TraktPlaybackItem[]> => {
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<boolean> => {
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<boolean> => {
logger.log(`[useTraktIntegration] fetchAndMergeTraktProgress called - isAuthenticated: ${isAuthenticated}`);
if (!isAuthenticated) {
logger.log('[useTraktIntegration] Not authenticated, skipping Trakt progress fetch');
return false;
}
try {
// Fetch both playback progress and recently watched movies
logger.log('[useTraktIntegration] Fetching Trakt playback progress and watched movies...');
const [traktProgress, watchedMovies] = await Promise.all([
getTraktPlaybackProgress(),
traktService.getWatchedMovies()
]);
logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items, ${watchedMovies.length} watched movies`);
// 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 // Initialize and check auth status
useEffect(() => { useEffect(() => {
checkAuthStatus(); checkAuthStatus();
@ -140,18 +415,98 @@ export function useTraktIntegration() {
} }
}, [isAuthenticated, loadWatchedItems]); }, [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<boolean> => {
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 { return {
isAuthenticated, isAuthenticated,
isLoading, isLoading,
userProfile, userProfile,
watchedMovies, watchedMovies,
watchedShows, watchedShows,
watchlistMovies,
watchlistShows,
collectionMovies,
collectionShows,
continueWatching,
ratedContent,
checkAuthStatus, checkAuthStatus,
loadWatchedItems, loadWatchedItems,
loadAllCollections,
isMovieWatched, isMovieWatched,
isEpisodeWatched, isEpisodeWatched,
markMovieAsWatched, markMovieAsWatched,
markEpisodeAsWatched, markEpisodeAsWatched,
refreshAuthStatus refreshAuthStatus,
startWatching,
updateProgress,
stopWatching,
syncProgress, // legacy
getTraktPlaybackProgress,
syncAllProgress,
fetchAndMergeTraktProgress,
forceSyncTraktProgress // For manual testing
}; };
} }

View file

@ -1,5 +1,6 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { useFocusEffect } from '@react-navigation/native'; import { useFocusEffect } from '@react-navigation/native';
import { useTraktContext } from '../contexts/TraktContext';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { storageService } from '../services/storageService'; import { storageService } from '../services/storageService';
@ -8,6 +9,8 @@ interface WatchProgressData {
duration: number; duration: number;
lastUpdated: number; lastUpdated: number;
episodeId?: string; episodeId?: string;
traktSynced?: boolean;
traktProgress?: number;
} }
export const useWatchProgress = ( export const useWatchProgress = (
@ -17,6 +20,7 @@ export const useWatchProgress = (
episodes: any[] = [] episodes: any[] = []
) => { ) => {
const [watchProgress, setWatchProgress] = useState<WatchProgressData | null>(null); const [watchProgress, setWatchProgress] = useState<WatchProgressData | null>(null);
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
// Function to get episode details from episodeId // Function to get episode details from episodeId
const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => { const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => {
@ -52,7 +56,7 @@ export const useWatchProgress = (
return null; return null;
}, [episodes]); }, [episodes]);
// Load watch progress // Enhanced load watch progress with Trakt integration
const loadWatchProgress = useCallback(async () => { const loadWatchProgress = useCallback(async () => {
try { try {
if (id && type) { if (id && type) {
@ -87,75 +91,39 @@ export const useWatchProgress = (
if (episodeId) { if (episodeId) {
const progress = await storageService.getWatchProgress(id, type, episodeId); const progress = await storageService.getWatchProgress(id, type, episodeId);
if (progress) { if (progress) {
const progressPercent = (progress.currentTime / progress.duration) * 100; // Always show the current episode progress when viewing it specifically
// This allows HeroSection to properly display watched state
// If current episode is finished (≥95%), try to find next unwatched episode setWatchProgress({
if (progressPercent >= 95) { ...progress,
const currentEpNum = getEpisodeNumber(episodeId); episodeId,
if (currentEpNum && episodes.length > 0) { traktSynced: progress.traktSynced,
// Find the next episode traktProgress: progress.traktProgress
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 });
} else { } else {
setWatchProgress(null); setWatchProgress(null);
} }
} else { } else {
// Find the first unfinished episode // FIXED: Find the most recently watched episode instead of first unfinished
const unfinishedEpisode = episodes.find(ep => { // Sort by lastUpdated timestamp (most recent first)
const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`; const sortedProgresses = seriesProgresses.sort((a, b) =>
const progress = seriesProgresses.find(p => p.episodeId === epId); b.progress.lastUpdated - a.progress.lastUpdated
if (!progress) return true; );
const percent = (progress.progress.currentTime / progress.progress.duration) * 100;
return percent < 95;
});
if (unfinishedEpisode) { if (sortedProgresses.length > 0) {
const epId = unfinishedEpisode.stremioId || // Use the most recently watched episode
`${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`; const mostRecentProgress = sortedProgresses[0];
const progress = await storageService.getWatchProgress(id, type, epId); const progress = mostRecentProgress.progress;
if (progress) {
setWatchProgress({ ...progress, episodeId: epId }); logger.log(`[useWatchProgress] Using most recent progress for ${mostRecentProgress.episodeId}, updated at ${new Date(progress.lastUpdated).toLocaleString()}`);
} else {
setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: epId }); setWatchProgress({
} ...progress,
episodeId: mostRecentProgress.episodeId,
traktSynced: progress.traktSynced,
traktProgress: progress.traktProgress
});
} else { } else {
// No watched episodes found
setWatchProgress(null); setWatchProgress(null);
} }
} }
@ -163,12 +131,14 @@ export const useWatchProgress = (
// For movies // For movies
const progress = await storageService.getWatchProgress(id, type, episodeId); const progress = await storageService.getWatchProgress(id, type, episodeId);
if (progress && progress.currentTime > 0) { if (progress && progress.currentTime > 0) {
const progressPercent = (progress.currentTime / progress.duration) * 100; // Always show progress data, even if watched (≥95%)
if (progressPercent >= 95) { // The HeroSection will handle the "watched" state display
setWatchProgress(null); setWatchProgress({
} else { ...progress,
setWatchProgress({ ...progress, episodeId }); episodeId,
} traktSynced: progress.traktSynced,
traktProgress: progress.traktProgress
});
} else { } else {
setWatchProgress(null); setWatchProgress(null);
} }
@ -180,21 +150,33 @@ export const useWatchProgress = (
} }
}, [id, type, episodeId, episodes]); }, [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(() => { const getPlayButtonText = useCallback(() => {
if (!watchProgress || watchProgress.currentTime <= 0) { if (!watchProgress || watchProgress.currentTime <= 0) {
return 'Play'; return 'Play';
} }
// Consider episode complete if progress is >= 95% // Consider episode complete if progress is >= 85%
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100; const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
if (progressPercent >= 95) { if (progressPercent >= 85) {
return 'Play'; return 'Play';
} }
// If we have Trakt data and it differs significantly from local, show "Resume"
// but the UI will show the discrepancy
return 'Resume'; return 'Resume';
}, [watchProgress]); }, [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 // Initial load
useEffect(() => { useEffect(() => {
loadWatchProgress(); loadWatchProgress();
@ -207,6 +189,16 @@ export const useWatchProgress = (
}, [loadWatchProgress]) }, [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 { return {
watchProgress, watchProgress,
getEpisodeDetails, getEpisodeDetails,

View file

@ -21,7 +21,7 @@ import DiscoverScreen from '../screens/DiscoverScreen';
import LibraryScreen from '../screens/LibraryScreen'; import LibraryScreen from '../screens/LibraryScreen';
import SettingsScreen from '../screens/SettingsScreen'; import SettingsScreen from '../screens/SettingsScreen';
import MetadataScreen from '../screens/MetadataScreen'; import MetadataScreen from '../screens/MetadataScreen';
import VideoPlayer from '../screens/VideoPlayer'; import VideoPlayer from '../components/player/VideoPlayer';
import CatalogScreen from '../screens/CatalogScreen'; import CatalogScreen from '../screens/CatalogScreen';
import AddonsScreen from '../screens/AddonsScreen'; import AddonsScreen from '../screens/AddonsScreen';
import SearchScreen from '../screens/SearchScreen'; import SearchScreen from '../screens/SearchScreen';
@ -39,6 +39,7 @@ import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
import LogoSourceSettings from '../screens/LogoSourceSettings'; import LogoSourceSettings from '../screens/LogoSourceSettings';
import ThemeScreen from '../screens/ThemeScreen'; import ThemeScreen from '../screens/ThemeScreen';
import ProfilesScreen from '../screens/ProfilesScreen'; import ProfilesScreen from '../screens/ProfilesScreen';
import InternalProvidersSettings from '../screens/InternalProvidersSettings';
// Stack navigator types // Stack navigator types
export type RootStackParamList = { export type RootStackParamList = {
@ -53,6 +54,7 @@ export type RootStackParamList = {
id: string; id: string;
type: string; type: string;
episodeId?: string; episodeId?: string;
addonId?: string;
}; };
Streams: { Streams: {
id: string; id: string;
@ -74,9 +76,12 @@ export type RootStackParamList = {
quality?: string; quality?: string;
year?: number; year?: number;
streamProvider?: string; streamProvider?: string;
streamName?: string;
id?: string; id?: string;
type?: string; type?: string;
episodeId?: string; episodeId?: string;
imdbId?: string;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
}; };
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string }; Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
Credits: { mediaId: string; mediaType: string }; Credits: { mediaId: string; mediaType: string };
@ -97,6 +102,7 @@ export type RootStackParamList = {
LogoSourceSettings: undefined; LogoSourceSettings: undefined;
ThemeSettings: undefined; ThemeSettings: undefined;
ProfilesSettings: undefined; ProfilesSettings: undefined;
InternalProvidersSettings: undefined;
}; };
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>; export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
@ -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 // Stack Navigator
const AppNavigator = () => { const AppNavigator = () => {
const { currentTheme } = useTheme(); 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 ( return (
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar <StatusBar
@ -669,60 +710,169 @@ const AppNavigator = () => {
barStyle="light-content" barStyle="light-content"
/> />
<PaperProvider theme={CustomDarkTheme}> <PaperProvider theme={CustomDarkTheme}>
<View style={{
flex: 1,
backgroundColor: currentTheme.colors.darkBackground,
...(Platform.OS === 'android' && {
// Prevent white flashes on Android
opacity: 1,
})
}}>
<Stack.Navigator <Stack.Navigator
screenOptions={{ screenOptions={{
headerShown: false, headerShown: false,
// Disable animations for smoother transitions // Use slide_from_right for consistency and smooth transitions
animation: 'none', animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
// Ensure content is not popping in and out animationDuration: Platform.OS === 'android' ? 250 : 300,
// Ensure consistent background during transitions
contentStyle: { contentStyle: {
backgroundColor: currentTheme.colors.darkBackground, backgroundColor: currentTheme.colors.darkBackground,
} },
// Improve Android performance with custom interpolator
...(Platform.OS === 'android' && {
cardStyleInterpolator: ({ current, layouts }: any) => {
return {
cardStyle: {
transform: [
{
translateX: current.progress.interpolate({
inputRange: [0, 1],
outputRange: [layouts.screen.width, 0],
}),
},
],
backgroundColor: currentTheme.colors.darkBackground,
},
};
},
}),
}} }}
> >
<Stack.Screen <Stack.Screen
name="MainTabs" name="MainTabs"
component={MainTabs as any} component={MainTabs as any}
options={{
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/> />
<Stack.Screen <Stack.Screen
name="Metadata" name="Metadata"
component={MetadataScreen as any} component={MetadataScreen}
options={{
headerShown: false,
animation: 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 300,
...(Platform.OS === 'ios' && {
cardStyleInterpolator: customFadeInterpolator,
animationTypeForReplace: 'push',
gestureEnabled: true,
gestureDirection: 'horizontal',
}),
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/> />
<Stack.Screen <Stack.Screen
name="Streams" name="Streams"
component={StreamsScreen as any} component={StreamsScreen as any}
options={{ options={{
headerShown: false, headerShown: false,
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade_from_bottom', animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'none',
animationDuration: Platform.OS === 'android' ? 0 : 300,
gestureEnabled: true,
gestureDirection: Platform.OS === 'ios' ? 'vertical' : 'horizontal',
...(Platform.OS === 'ios' && { presentation: 'modal' }), ...(Platform.OS === 'ios' && { presentation: 'modal' }),
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}} }}
/> />
<Stack.Screen <Stack.Screen
name="Player" name="Player"
component={VideoPlayer as any} component={VideoPlayer as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 200 : 300,
contentStyle: {
backgroundColor: '#000000', // Pure black for video player
},
}}
/> />
<Stack.Screen <Stack.Screen
name="Catalog" name="Catalog"
component={CatalogScreen as any} component={CatalogScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/> />
<Stack.Screen <Stack.Screen
name="Addons" name="Addons"
component={AddonsScreen as any} component={AddonsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/> />
<Stack.Screen <Stack.Screen
name="Search" name="Search"
component={SearchScreen as any} component={SearchScreen as any}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 350,
gestureEnabled: true,
gestureDirection: 'horizontal',
...(Platform.OS === 'android' && {
cardStyleInterpolator: ({ current, layouts }: any) => {
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,
},
}}
/> />
<Stack.Screen <Stack.Screen
name="CatalogSettings" name="CatalogSettings"
component={CatalogSettingsScreen as any} component={CatalogSettingsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/> />
<Stack.Screen <Stack.Screen
name="HomeScreenSettings" name="HomeScreenSettings"
component={HomeScreenSettings} component={HomeScreenSettings}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -736,8 +886,8 @@ const AppNavigator = () => {
name="HeroCatalogs" name="HeroCatalogs"
component={HeroCatalogsScreen} component={HeroCatalogsScreen}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -751,8 +901,8 @@ const AppNavigator = () => {
name="ShowRatings" name="ShowRatings"
component={ShowRatingsScreen} component={ShowRatingsScreen}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'fade_from_bottom' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 200 : 200,
...(Platform.OS === 'ios' && { presentation: 'modal' }), ...(Platform.OS === 'ios' && { presentation: 'modal' }),
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -765,17 +915,31 @@ const AppNavigator = () => {
<Stack.Screen <Stack.Screen
name="Calendar" name="Calendar"
component={CalendarScreen as any} component={CalendarScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/> />
<Stack.Screen <Stack.Screen
name="NotificationSettings" name="NotificationSettings"
component={NotificationSettingsScreen as any} component={NotificationSettingsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/> />
<Stack.Screen <Stack.Screen
name="MDBListSettings" name="MDBListSettings"
component={MDBListSettingsScreen} component={MDBListSettingsScreen}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -789,8 +953,8 @@ const AppNavigator = () => {
name="TMDBSettings" name="TMDBSettings"
component={TMDBSettingsScreen} component={TMDBSettingsScreen}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -804,8 +968,8 @@ const AppNavigator = () => {
name="TraktSettings" name="TraktSettings"
component={TraktSettingsScreen} component={TraktSettingsScreen}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -819,8 +983,8 @@ const AppNavigator = () => {
name="PlayerSettings" name="PlayerSettings"
component={PlayerSettingsScreen} component={PlayerSettingsScreen}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -834,8 +998,8 @@ const AppNavigator = () => {
name="LogoSourceSettings" name="LogoSourceSettings"
component={LogoSourceSettings} component={LogoSourceSettings}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -849,8 +1013,8 @@ const AppNavigator = () => {
name="ThemeSettings" name="ThemeSettings"
component={ThemeScreen} component={ThemeScreen}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -864,8 +1028,23 @@ const AppNavigator = () => {
name="ProfilesSettings" name="ProfilesSettings"
component={ProfilesScreen} component={ProfilesScreen}
options={{ options={{
animation: 'fade', animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: 200, animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="InternalProvidersSettings"
component={InternalProvidersSettings}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card', presentation: 'card',
gestureEnabled: true, gestureEnabled: true,
gestureDirection: 'horizontal', gestureDirection: 'horizontal',
@ -876,6 +1055,7 @@ const AppNavigator = () => {
}} }}
/> />
</Stack.Navigator> </Stack.Navigator>
</View>
</PaperProvider> </PaperProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );

View file

@ -29,7 +29,9 @@ import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage'; 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 axios from 'axios';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
@ -552,6 +554,36 @@ const createStyles = (colors: any) => StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', 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 = () => { const AddonsScreen = () => {
@ -1233,7 +1265,24 @@ const AddonsScreen = () => {
setAddonDetails(null); setAddonDetails(null);
}} }}
> >
<BlurView intensity={80} style={styles.modalContainer} tint="dark"> <View style={styles.modalContainer}>
{Platform.OS === 'ios' ? (
<ExpoBlurView intensity={80} style={styles.blurOverlay} tint="dark" />
) : (
Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? (
<View style={[styles.androidBlurContainer, styles.androidFallbackBlur]} />
) : (
<View style={styles.androidBlurContainer}>
<CommunityBlurView
style={styles.androidBlur}
blurType="dark"
blurAmount={8}
overlayColor="rgba(0,0,0,0.4)"
reducedTransparencyFallbackColor="black"
/>
</View>
)
)}
<View style={styles.modalContent}> <View style={styles.modalContent}>
{addonDetails && ( {addonDetails && (
<> <>
@ -1332,7 +1381,7 @@ const AddonsScreen = () => {
</> </>
)} )}
</View> </View>
</BlurView> </View>
</Modal> </Modal>
</SafeAreaView> </SafeAreaView>
); );

View file

@ -41,9 +41,38 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Screen dimensions and grid layout // Screen dimensions and grid layout
const { width } = Dimensions.get('window'); 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_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 // Create a styles creator function that accepts the theme colors
const createStyles = (colors: any) => StyleSheet.create({ const createStyles = (colors: any) => StyleSheet.create({
@ -79,13 +108,9 @@ const createStyles = (colors: any) => StyleSheet.create({
padding: SPACING.lg, padding: SPACING.lg,
paddingTop: SPACING.sm, paddingTop: SPACING.sm,
}, },
columnWrapper: {
justifyContent: 'space-between',
},
item: { item: {
width: ITEM_WIDTH,
marginBottom: SPACING.lg, marginBottom: SPACING.lg,
borderRadius: 12, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
backgroundColor: colors.elevation2, backgroundColor: colors.elevation2,
shadowColor: '#000', shadowColor: '#000',
@ -97,8 +122,8 @@ const createStyles = (colors: any) => StyleSheet.create({
poster: { poster: {
width: '100%', width: '100%',
aspectRatio: 2/3, aspectRatio: 2/3,
borderTopLeftRadius: 12, borderTopLeftRadius: 8,
borderTopRightRadius: 12, borderTopRightRadius: 8,
backgroundColor: colors.elevation3, backgroundColor: colors.elevation3,
}, },
itemContent: { itemContent: {
@ -168,13 +193,60 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS); const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
const [actualCatalogName, setActualCatalogName] = useState<string | null>(null);
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const colors = currentTheme.colors; const colors = currentTheme.colors;
const styles = createStyles(colors); const styles = createStyles(colors);
const isDarkMode = true; const isDarkMode = true;
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames(); 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 // Add effect to get data source preference when component mounts
useEffect(() => { useEffect(() => {
@ -415,11 +487,23 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
} }
}, [loading, hasMore, page, loadItems]); }, [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 ( return (
<TouchableOpacity <TouchableOpacity
style={styles.item} style={[
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })} 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} activeOpacity={0.7}
> >
<Image <Image
@ -443,7 +527,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</View> </View>
</TouchableOpacity> </TouchableOpacity>
); );
}, [navigation, styles]); }, [navigation, styles, NUM_COLUMNS, ITEM_WIDTH]);
const renderEmptyState = () => ( const renderEmptyState = () => (
<View style={styles.centered}> <View style={styles.centered}>
@ -542,6 +626,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
renderItem={renderItem} renderItem={renderItem}
keyExtractor={(item) => `${item.id}-${item.type}`} keyExtractor={(item) => `${item.id}-${item.type}`}
numColumns={NUM_COLUMNS} numColumns={NUM_COLUMNS}
key={NUM_COLUMNS}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}
@ -560,7 +645,6 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
) : null ) : null
} }
contentContainerStyle={styles.list} contentContainerStyle={styles.list}
columnWrapperStyle={styles.columnWrapper}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
/> />
) : renderEmptyState()} ) : renderEmptyState()}

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react'; import React, { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
import { import {
View, View,
Text, Text,
@ -6,7 +6,6 @@ import {
FlatList, FlatList,
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
RefreshControl,
SafeAreaView, SafeAreaView,
StatusBar, StatusBar,
useColorScheme, useColorScheme,
@ -16,12 +15,14 @@ import {
Platform, Platform,
Image, Image,
Modal, Modal,
Pressable Pressable,
Alert
} from 'react-native'; } from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService'; import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService';
import { stremioService } from '../services/stremioService';
import { Stream } from '../types/metadata'; import { Stream } from '../types/metadata';
import { MaterialIcons } from '@expo/vector-icons'; import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
@ -60,6 +61,7 @@ import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
import homeStyles, { sharedStyles } from '../styles/homeStyles'; import homeStyles, { sharedStyles } from '../styles/homeStyles';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import type { Theme } from '../contexts/ThemeContext'; import type { Theme } from '../contexts/ThemeContext';
import * as ScreenOrientation from 'expo-screen-orientation';
// Define interfaces for our data // Define interfaces for our data
interface Category { interface Category {
@ -83,7 +85,7 @@ interface ContinueWatchingRef {
refresh: () => Promise<boolean>; refresh: () => Promise<boolean>;
} }
const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => { const DropUpMenu = React.memo(({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
const translateY = useSharedValue(300); const translateY = useSharedValue(300);
const opacity = useSharedValue(0); const opacity = useSharedValue(0);
const isDarkMode = useColorScheme() === 'dark'; const isDarkMode = useColorScheme() === 'dark';
@ -98,9 +100,15 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
opacity.value = withTiming(0, { duration: 200 }); opacity.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(300, { duration: 300 }); translateY.value = withTiming(300, { duration: 300 });
} }
// Cleanup animations when component unmounts
return () => {
opacity.value = 0;
translateY.value = 300;
};
}, [visible]); }, [visible]);
const gesture = Gesture.Pan() const gesture = useMemo(() => Gesture.Pan()
.onStart(() => { .onStart(() => {
// Store initial position if needed // Store initial position if needed
}) })
@ -124,7 +132,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
translateY.value = withTiming(0, { duration: 300 }); translateY.value = withTiming(0, { duration: 300 });
opacity.value = withTiming(1, { duration: 200 }); opacity.value = withTiming(1, { duration: 200 });
} }
}); }), [onClose]);
const overlayStyle = useAnimatedStyle(() => ({ const overlayStyle = useAnimatedStyle(() => ({
opacity: opacity.value, opacity: opacity.value,
@ -138,7 +146,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white, backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white,
})); }));
const menuOptions = [ const menuOptions = useMemo(() => [
{ {
icon: item.inLibrary ? 'bookmark' : 'bookmark-border', icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
label: item.inLibrary ? 'Remove from Library' : 'Add to Library', label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
@ -159,7 +167,12 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
label: 'Share', label: 'Share',
action: 'share' action: 'share'
} }
]; ], [item.inLibrary]);
const handleOptionSelect = useCallback((action: string) => {
onOptionSelect(action);
onClose();
}, [onOptionSelect, onClose]);
return ( return (
<Modal <Modal
@ -200,10 +213,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' }, { borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' },
index === menuOptions.length - 1 && styles.lastMenuOption index === menuOptions.length - 1 && styles.lastMenuOption
]} ]}
onPress={() => { onPress={() => handleOptionSelect(option.action)}
onOptionSelect(option.action);
onClose();
}}
> >
<MaterialIcons <MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"} name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
@ -225,9 +235,9 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
</GestureHandlerRootView> </GestureHandlerRootView>
</Modal> </Modal>
); );
}; });
const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => { const ContentItem = React.memo(({ item: initialItem, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false); const [menuVisible, setMenuVisible] = useState(false);
const [localItem, setLocalItem] = useState(initialItem); const [localItem, setLocalItem] = useState(initialItem);
const [isWatched, setIsWatched] = useState(false); const [isWatched, setIsWatched] = useState(false);
@ -256,8 +266,8 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
setIsWatched(prev => !prev); setIsWatched(prev => !prev);
break; break;
case 'playlist': case 'playlist':
break;
case 'share': case 'share':
// These options don't have implementations yet
break; break;
} }
}, [localItem]); }, [localItem]);
@ -266,16 +276,20 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
setMenuVisible(false); setMenuVisible(false);
}, []); }, []);
// Only update localItem when initialItem changes
useEffect(() => { useEffect(() => {
setLocalItem(initialItem); setLocalItem(initialItem);
}, [initialItem]); }, [initialItem]);
// Subscribe to library updates
useEffect(() => { useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => { const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
const isInLibrary = libraryItems.some( const isInLibrary = libraryItems.some(
libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type
); );
if (isInLibrary !== localItem.inLibrary) {
setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary })); setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
}
}); });
return () => unsubscribe(); return () => unsubscribe();
@ -330,15 +344,24 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
</View> </View>
</TouchableOpacity> </TouchableOpacity>
{menuVisible && (
<DropUpMenu <DropUpMenu
visible={menuVisible} visible={menuVisible}
onClose={handleMenuClose} onClose={handleMenuClose}
item={localItem} item={localItem}
onOptionSelect={handleOptionSelect} onOptionSelect={handleOptionSelect}
/> />
)}
</> </>
); );
}; }, (prevProps, nextProps) => {
// Custom comparison function to prevent unnecessary re-renders
return (
prevProps.item.id === nextProps.item.id &&
prevProps.item.inLibrary === nextProps.item.inLibrary &&
prevProps.onPress === nextProps.onPress
);
});
// Sample categories (real app would get these from API) // Sample categories (real app would get these from API)
const SAMPLE_CATEGORIES: Category[] = [ const SAMPLE_CATEGORIES: Category[] = [
@ -347,7 +370,7 @@ const SAMPLE_CATEGORIES: Category[] = [
{ id: 'channel', name: 'Channels' }, { id: 'channel', name: 'Channels' },
]; ];
const SkeletonCatalog = () => { const SkeletonCatalog = React.memo(() => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
return ( return (
<View style={styles.catalogContainer}> <View style={styles.catalogContainer}>
@ -356,7 +379,7 @@ const SkeletonCatalog = () => {
</View> </View>
</View> </View>
); );
}; });
const HomeScreen = () => { const HomeScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -364,17 +387,16 @@ const HomeScreen = () => {
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const continueWatchingRef = useRef<ContinueWatchingRef>(null); const continueWatchingRef = useRef<ContinueWatchingRef>(null);
const { settings } = useSettings(); const { settings } = useSettings();
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection); const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource); const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null); const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [hasContinueWatching, setHasContinueWatching] = useState(false); const [hasContinueWatching, setHasContinueWatching] = useState(false);
const { const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
catalogs, const [catalogsLoading, setCatalogsLoading] = useState(true);
loading: catalogsLoading, const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
refreshing: catalogsRefreshing, const totalCatalogsRef = useRef(0);
refreshCatalogs
} = useHomeCatalogs();
const { const {
featuredContent, featuredContent,
@ -384,9 +406,119 @@ const HomeScreen = () => {
refreshFeatured refreshFeatured
} = useFeaturedContent(); } = 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<void>[] = [];
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 // Only count feature section as loading if it's enabled in settings
const isLoading = (showHeroSection ? featuredLoading : false) || catalogsLoading; // For catalogs, we show them progressively, so only show loading if no catalogs are loaded yet
const isRefreshing = catalogsRefreshing; const isLoading = useMemo(() =>
(showHeroSection ? featuredLoading : false) || (catalogsLoading && catalogs.length === 0),
[showHeroSection, featuredLoading, catalogsLoading, catalogs.length]
);
// React to settings changes // React to settings changes
useEffect(() => { useEffect(() => {
@ -394,14 +526,26 @@ const HomeScreen = () => {
setFeaturedContentSource(settings.featuredContentSource); setFeaturedContentSource(settings.featuredContentSource);
}, [settings]); }, [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 // Subscribe directly to settings emitter for immediate updates
useEffect(() => { useEffect(() => {
const handleSettingsChange = () => { const handleSettingsChange = () => {
setShowHeroSection(settings.showHeroSection); setShowHeroSection(settings.showHeroSection);
setFeaturedContentSource(settings.featuredContentSource); 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 // Subscribe to settings changes
@ -410,18 +554,6 @@ const HomeScreen = () => {
return unsubscribe; return unsubscribe;
}, [settings]); }, [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( useFocusEffect(
useCallback(() => { useCallback(() => {
const statusBarConfig = () => { const statusBarConfig = () => {
@ -451,16 +583,15 @@ const HomeScreen = () => {
StatusBar.setTranslucent(false); StatusBar.setTranslucent(false);
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground); StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
} }
// Clean up any lingering timeouts
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
}; };
}, [currentTheme.colors.darkBackground]); }, [currentTheme.colors.darkBackground]);
useEffect(() => { // Preload images function - memoized to avoid recreating on every render
navigation.addListener('beforeRemove', () => {});
return () => {
navigation.removeListener('beforeRemove', () => {});
};
}, [navigation]);
const preloadImages = useCallback(async (content: StreamingContent[]) => { const preloadImages = useCallback(async (content: StreamingContent[]) => {
if (!content.length) return; if (!content.length) return;
@ -481,36 +612,24 @@ const HomeScreen = () => {
await Promise.all(imagePromises); await Promise.all(imagePromises);
} catch (error) { } 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) => { const handleContentPress = useCallback((id: string, type: string) => {
navigation.navigate('Metadata', { id, type }); navigation.navigate('Metadata', { id, type });
}, [navigation]); }, [navigation]);
const handlePlayStream = useCallback((stream: Stream) => { const handlePlayStream = useCallback(async (stream: Stream) => {
if (!featuredContent) return; if (!featuredContent) return;
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', { navigation.navigate('Player', {
uri: stream.url, uri: stream.url,
title: featuredContent.name, title: featuredContent.name,
@ -520,30 +639,72 @@ const HomeScreen = () => {
id: featuredContent.id, id: featuredContent.id,
type: featuredContent.type 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]); }, [featuredContent, navigation]);
const refreshContinueWatching = useCallback(async () => { const refreshContinueWatching = useCallback(async () => {
console.log('[HomeScreen] Refreshing continue watching...');
if (continueWatchingRef.current) { if (continueWatchingRef.current) {
try {
const hasContent = await continueWatchingRef.current.refresh(); const hasContent = await continueWatchingRef.current.refresh();
console.log(`[HomeScreen] Continue watching has content: ${hasContent}`);
setHasContinueWatching(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(() => { useEffect(() => {
const handlePlaybackComplete = () => {
refreshContinueWatching();
};
const unsubscribe = navigation.addListener('focus', () => { const unsubscribe = navigation.addListener('focus', () => {
// Only refresh continue watching section on focus
refreshContinueWatching(); 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 () => { return unsubscribe;
unsubscribe(); }, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
};
}, [navigation, refreshContinueWatching]);
if (isLoading && !isRefreshing) { // Memoize the loading screen to prevent unnecessary re-renders
const renderLoadingScreen = useMemo(() => {
if (isLoading) {
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar <StatusBar
@ -558,6 +719,12 @@ const HomeScreen = () => {
</View> </View>
); );
} }
return null;
}, [isLoading, currentTheme.colors]);
// Memoize the main content section
const renderMainContent = useMemo(() => {
if (isLoading) return null;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
@ -567,23 +734,16 @@ const HomeScreen = () => {
translucent translucent
/> />
<ScrollView <ScrollView
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
/>
}
contentContainerStyle={[ contentContainerStyle={[
styles.scrollContent, styles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 } { paddingTop: Platform.OS === 'ios' ? 100 : 90 }
]} ]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
> >
{showHeroSection && ( {showHeroSection && (
<FeaturedContent <FeaturedContent
key={`featured-${showHeroSection}`} key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent} featuredContent={featuredContent}
isSaved={isSaved} isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary} handleSaveToLibrary={handleSaveToLibrary}
@ -594,20 +754,52 @@ const HomeScreen = () => {
<ThisWeekSection /> <ThisWeekSection />
</Animated.View> </Animated.View>
{hasContinueWatching && (
<Animated.View entering={FadeIn.duration(400).delay(250)}>
<ContinueWatchingSection ref={continueWatchingRef} /> <ContinueWatchingSection ref={continueWatchingRef} />
{/* Show catalogs as they load */}
{catalogs.map((catalog, index) => {
if (!catalog) {
// Show placeholder for loading catalog
return (
<View key={`placeholder-${index}`} style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
<View style={styles.placeholderPosters}>
{[...Array(4)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</View>
</View>
);
}
return (
<Animated.View
key={`${catalog.addon}-${catalog.id}-${index}`}
entering={FadeIn.duration(300)}
>
<CatalogSection catalog={catalog} />
</Animated.View> </Animated.View>
);
})}
{/* Show loading indicator for remaining catalogs */}
{catalogsLoading && catalogs.length < totalCatalogsRef.current && (
<View style={styles.loadingMoreCatalogs}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}>
Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
</Text>
</View>
)} )}
{catalogs.length > 0 ? ( {/* Show empty state only if all catalogs are loaded and none are available */}
catalogs.map((catalog, index) => ( {!catalogsLoading && catalogs.length === 0 && (
<View key={`${catalog.addon}-${catalog.id}-${index}`}>
<CatalogSection catalog={catalog} />
</View>
))
) : (
!catalogsLoading && (
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} /> <MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}> <Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
@ -621,33 +813,122 @@ const HomeScreen = () => {
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text> <Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)
)} )}
</ScrollView> </ScrollView>
</View> </View>
); );
}, [
isLoading,
currentTheme.colors,
showHeroSection,
featuredContent,
isSaved,
handleSaveToLibrary,
hasContinueWatching,
catalogs,
catalogsLoading,
navigation,
featuredContentSource
]);
return isLoading ? renderLoadingScreen : renderMainContent;
}; };
const { width, height } = Dimensions.get('window'); 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<any>({ const styles = StyleSheet.create<any>({
container: { container: {
flex: 1, flex: 1,
}, },
scrollContent: { scrollContent: {
paddingBottom: 40, paddingBottom: 90,
}, },
loadingMainContainer: { loadingMainContainer: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingBottom: 40, paddingBottom: 90,
}, },
loadingText: { loadingText: {
marginTop: 12, marginTop: 12,
fontSize: 14, 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: { emptyCatalog: {
padding: 32, padding: 32,
alignItems: 'center', alignItems: 'center',
@ -810,19 +1091,19 @@ const styles = StyleSheet.create<any>({
position: 'relative', position: 'relative',
}, },
catalogTitle: { catalogTitle: {
fontSize: 18, fontSize: 19,
fontWeight: '800', fontWeight: '700',
textTransform: 'uppercase', letterSpacing: 0.2,
letterSpacing: 0.5, marginBottom: 4,
marginBottom: 6,
}, },
titleUnderline: { titleUnderline: {
position: 'absolute', position: 'absolute',
bottom: -4, bottom: -2,
left: 0, left: 0,
width: 60, width: 35,
height: 3, height: 2,
borderRadius: 1.5, borderRadius: 1,
opacity: 0.8,
}, },
seeAllButton: { seeAllButton: {
flexDirection: 'row', flexDirection: 'row',
@ -837,7 +1118,8 @@ const styles = StyleSheet.create<any>({
marginRight: 4, marginRight: 4,
}, },
catalogList: { catalogList: {
paddingHorizontal: 16, paddingLeft: 16,
paddingRight: 16 - posterLayout.partialPosterWidth,
paddingBottom: 12, paddingBottom: 12,
paddingTop: 6, paddingTop: 6,
}, },
@ -845,21 +1127,21 @@ const styles = StyleSheet.create<any>({
width: POSTER_WIDTH, width: POSTER_WIDTH,
aspectRatio: 2/3, aspectRatio: 2/3,
margin: 0, margin: 0,
borderRadius: 16, borderRadius: 4,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
elevation: 8, elevation: 6,
shadowColor: '#000', shadowColor: '#000',
shadowOffset: { width: 0, height: 4 }, shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.3, shadowOpacity: 0.25,
shadowRadius: 8, shadowRadius: 6,
borderWidth: 1, borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.08)', borderColor: 'rgba(255,255,255,0.12)',
}, },
poster: { poster: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 16, borderRadius: 4,
}, },
imdbLogo: { imdbLogo: {
width: 35, width: 35,
@ -898,7 +1180,7 @@ const styles = StyleSheet.create<any>({
contentItemContainer: { contentItemContainer: {
width: '100%', width: '100%',
height: '100%', height: '100%',
borderRadius: 16, borderRadius: 4,
overflow: 'hidden', overflow: 'hidden',
position: 'relative', position: 'relative',
}, },
@ -1009,7 +1291,7 @@ const styles = StyleSheet.create<any>({
backgroundColor: 'rgba(0,0,0,0.5)', backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderRadius: 16, borderRadius: 8,
}, },
featuredImage: { featuredImage: {
width: '100%', width: '100%',
@ -1045,4 +1327,4 @@ const styles = StyleSheet.create<any>({
}, },
}); });
export default HomeScreen; export default React.memo(HomeScreen);

View file

@ -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<SettingItemProps> = ({
title,
description,
icon,
value,
onValueChange,
isLast,
badge,
}) => {
const { currentTheme } = useTheme();
return (
<View
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: 'rgba(255,255,255,0.08)' },
]}
>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name={icon}
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<View style={styles.titleRow}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
{title}
</Text>
{badge && (
<View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
)}
</View>
{description && (
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
{description}
</Text>
)}
</View>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (value ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</View>
</View>
);
};
const InternalProvidersSettings: React.FC = () => {
const { settings, updateSetting } = useSettings();
const { currentTheme } = useTheme();
const navigation = useNavigation();
// Individual provider states
const [hdrezkaEnabled, setHdrezkaEnabled] = useState(true);
// Load individual provider settings
useEffect(() => {
const loadProviderSettings = async () => {
try {
const hdrezkaSettings = await AsyncStorage.getItem('hdrezka_settings');
if (hdrezkaSettings) {
const parsed = JSON.parse(hdrezkaSettings);
setHdrezkaEnabled(parsed.enabled !== false);
}
} catch (error) {
console.error('Error loading provider settings:', error);
}
};
loadProviderSettings();
}, []);
const handleBack = () => {
navigation.goBack();
};
const handleMasterToggle = useCallback((enabled: boolean) => {
if (!enabled) {
Alert.alert(
'Disable Internal Providers',
'This will disable all built-in streaming providers (HDRezka). You can still use external Stremio addons.',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Disable',
style: 'destructive',
onPress: () => {
updateSetting('enableInternalProviders', false);
}
}
]
);
} else {
updateSetting('enableInternalProviders', true);
}
}, [updateSetting]);
const handleHdrezkaToggle = useCallback(async (enabled: boolean) => {
setHdrezkaEnabled(enabled);
try {
await AsyncStorage.setItem('hdrezka_settings', JSON.stringify({ enabled }));
} catch (error) {
console.error('Error saving HDRezka settings:', error);
}
}, []);
return (
<SafeAreaView
style={[
styles.container,
{ backgroundColor: currentTheme.colors.darkBackground },
]}
>
<StatusBar
translucent
backgroundColor="transparent"
barStyle="light-content"
/>
<View style={styles.header}>
<TouchableOpacity
onPress={handleBack}
style={styles.backButton}
activeOpacity={0.7}
>
<MaterialIcons
name="arrow-back"
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
<Text
style={[
styles.headerTitle,
{ color: currentTheme.colors.text },
]}
>
Internal Providers
</Text>
</View>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
>
{/* Master Toggle Section */}
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: currentTheme.colors.textMuted },
]}
>
MASTER CONTROL
</Text>
<View
style={[
styles.card,
{ backgroundColor: currentTheme.colors.elevation2 },
]}
>
<SettingItem
title="Enable Internal Providers"
description="Toggle all built-in streaming providers on/off"
icon="toggle-on"
value={settings.enableInternalProviders}
onValueChange={handleMasterToggle}
isLast={true}
/>
</View>
</View>
{/* Individual Providers Section */}
{settings.enableInternalProviders && (
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: currentTheme.colors.textMuted },
]}
>
INDIVIDUAL PROVIDERS
</Text>
<View
style={[
styles.card,
{ backgroundColor: currentTheme.colors.elevation2 },
]}
>
<SettingItem
title="HDRezka"
description="Popular streaming service with multiple quality options"
icon="hd"
value={hdrezkaEnabled}
onValueChange={handleHdrezkaToggle}
isLast={true}
/>
</View>
</View>
)}
{/* Information Section */}
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: currentTheme.colors.textMuted },
]}
>
INFORMATION
</Text>
<View
style={[
styles.infoCard,
{
backgroundColor: currentTheme.colors.elevation2,
borderColor: `${currentTheme.colors.primary}30`
},
]}
>
<MaterialIcons
name="info-outline"
size={24}
color={currentTheme.colors.primary}
style={styles.infoIcon}
/>
<View style={styles.infoContent}>
<Text
style={[
styles.infoTitle,
{ color: currentTheme.colors.text },
]}
>
About Internal Providers
</Text>
<Text
style={[
styles.infoDescription,
{ color: currentTheme.colors.textMuted },
]}
>
Internal providers are built directly into the app and don't require separate addon installation. They complement your Stremio addons by providing additional streaming sources.
</Text>
<View style={styles.featureList}>
<View style={styles.featureItem}>
<MaterialIcons
name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.textMuted },
]}
>
No addon installation required
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons
name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.textMuted },
]}
>
Multiple quality options
</Text>
</View>
<View style={styles.featureItem}>
<MaterialIcons
name="check-circle"
size={16}
color={currentTheme.colors.primary}
/>
<Text
style={[
styles.featureText,
{ color: currentTheme.colors.textMuted },
]}
>
Fast and reliable streaming
</Text>
</View>
</View>
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 16 : 16,
paddingBottom: 16,
},
backButton: {
padding: 8,
marginRight: 8,
},
headerTitle: {
fontSize: 24,
fontWeight: '700',
flex: 1,
},
scrollView: {
flex: 1,
},
scrollContent: {
padding: 16,
paddingBottom: 100,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.8,
marginBottom: 8,
paddingHorizontal: 4,
},
card: {
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
settingItem: {
padding: 16,
borderBottomWidth: 0.5,
},
settingItemBorder: {
borderBottomWidth: 0.5,
},
settingContent: {
flexDirection: 'row',
alignItems: 'center',
},
settingIconContainer: {
marginRight: 16,
width: 36,
height: 36,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
},
settingText: {
flex: 1,
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 4,
},
settingTitle: {
fontSize: 16,
fontWeight: '500',
},
settingDescription: {
fontSize: 14,
opacity: 0.8,
lineHeight: 20,
},
badge: {
height: 18,
minWidth: 18,
borderRadius: 9,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 6,
marginLeft: 8,
},
badgeText: {
color: 'white',
fontSize: 10,
fontWeight: '600',
},
infoCard: {
borderRadius: 16,
padding: 16,
flexDirection: 'row',
borderWidth: 1,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
infoIcon: {
marginRight: 12,
marginTop: 2,
},
infoContent: {
flex: 1,
},
infoTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 8,
},
infoDescription: {
fontSize: 14,
lineHeight: 20,
marginBottom: 12,
},
featureList: {
gap: 6,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
featureText: {
fontSize: 14,
flex: 1,
},
});
export default InternalProvidersSettings;

File diff suppressed because it is too large Load diff

View file

@ -348,48 +348,11 @@ const LogoSourceSettings = () => {
settings.logoSourcePreference || 'metahub' settings.logoSourcePreference || 'metahub'
); );
// TMDB Language Preference
const [selectedTmdbLanguage, setSelectedTmdbLanguage] = useState<string>(
settings.tmdbLanguagePreference || 'en'
);
// Make sure logoSource stays in sync with settings // Make sure logoSource stays in sync with settings
useEffect(() => { useEffect(() => {
setLogoSource(settings.logoSourcePreference || 'metahub'); setLogoSource(settings.logoSourcePreference || 'metahub');
}, [settings.logoSourcePreference]); }, [settings.logoSourcePreference]);
// Keep selectedTmdbLanguage in sync with settings
useEffect(() => {
setSelectedTmdbLanguage(settings.tmdbLanguagePreference || 'en');
}, [settings.tmdbLanguagePreference]);
// Force reload settings from AsyncStorage when component mounts
useEffect(() => {
const loadSettingsFromStorage = async () => {
try {
const settingsJson = await AsyncStorage.getItem('app_settings');
if (settingsJson) {
const storedSettings = JSON.parse(settingsJson);
// Update local state to match stored settings
if (storedSettings.logoSourcePreference) {
setLogoSource(storedSettings.logoSourcePreference);
}
if (storedSettings.tmdbLanguagePreference) {
setSelectedTmdbLanguage(storedSettings.tmdbLanguagePreference);
}
logger.log('[LogoSourceSettings] Successfully loaded settings from AsyncStorage');
}
} catch (error) {
logger.error('[LogoSourceSettings] Error loading settings from AsyncStorage:', error);
}
};
loadSettingsFromStorage();
}, []);
// Selected example show // Selected example show
const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]); const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]);
@ -429,6 +392,9 @@ const LogoSourceSettings = () => {
logger.log(`[LogoSourceSettings] Fetching ${show.name} with TMDB ID: ${tmdbId}, IMDB ID: ${imdbId}`); logger.log(`[LogoSourceSettings] Fetching ${show.name} with TMDB ID: ${tmdbId}, IMDB ID: ${imdbId}`);
// Get preferred language directly from settings
const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en';
// Get TMDB logo and banner // Get TMDB logo and banner
try { try {
const apiKey = TMDB_API_KEY; const apiKey = TMDB_API_KEY;
@ -451,15 +417,15 @@ const LogoSourceSettings = () => {
// Find initial logo (prefer selectedTmdbLanguage, then 'en') // Find initial logo (prefer selectedTmdbLanguage, then 'en')
let initialLogoPath: string | null = null; let initialLogoPath: string | null = null;
let initialLanguage = selectedTmdbLanguage; let initialLanguage = preferredTmdbLanguage;
// First try to find a logo in the user's preferred language // First try to find a logo in the user's preferred language
const preferredLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === selectedTmdbLanguage); const preferredLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === preferredTmdbLanguage);
if (preferredLogo) { if (preferredLogo) {
initialLogoPath = preferredLogo.file_path; initialLogoPath = preferredLogo.file_path;
initialLanguage = selectedTmdbLanguage; initialLanguage = preferredTmdbLanguage;
logger.log(`[LogoSourceSettings] Found initial ${selectedTmdbLanguage} TMDB logo for ${show.name}`); logger.log(`[LogoSourceSettings] Found initial ${preferredTmdbLanguage} TMDB logo for ${show.name}`);
} else { } else {
// Fallback to English logo // Fallback to English logo
const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en'); const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en');
@ -478,7 +444,6 @@ const LogoSourceSettings = () => {
if (initialLogoPath) { if (initialLogoPath) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`); setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`);
setSelectedTmdbLanguage(initialLanguage); // Set selected language based on found logo
} else { } else {
logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`); logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`);
} }
@ -588,9 +553,6 @@ const LogoSourceSettings = () => {
// Handle TMDB language selection // Handle TMDB language selection
const handleTmdbLanguageSelect = (languageCode: string) => { const handleTmdbLanguageSelect = (languageCode: string) => {
// First set local state for immediate UI updates
setSelectedTmdbLanguage(languageCode);
// Update the preview logo if possible // Update the preview logo if possible
if (tmdbLogosData) { if (tmdbLogosData) {
const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode); const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode);
@ -606,6 +568,9 @@ const LogoSourceSettings = () => {
saveLanguagePreference(languageCode); saveLanguagePreference(languageCode);
}; };
// Get preferred language directly from settings for UI rendering
const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en';
// Save language preference with proper persistence // Save language preference with proper persistence
const saveLanguagePreference = async (languageCode: string) => { const saveLanguagePreference = async (languageCode: string) => {
logger.log(`[LogoSourceSettings] Saving TMDB language preference: ${languageCode}`); logger.log(`[LogoSourceSettings] Saving TMDB language preference: ${languageCode}`);
@ -614,34 +579,6 @@ const LogoSourceSettings = () => {
// First use the settings hook to update the setting - this is crucial // First use the settings hook to update the setting - this is crucial
updateSetting('tmdbLanguagePreference', languageCode); updateSetting('tmdbLanguagePreference', languageCode);
// For extra assurance, also save directly to AsyncStorage
// Get current settings from AsyncStorage
const settingsJson = await AsyncStorage.getItem('app_settings');
if (settingsJson) {
const currentSettings = JSON.parse(settingsJson);
// Update the language preference
const updatedSettings = {
...currentSettings,
tmdbLanguagePreference: languageCode
};
// Save back to AsyncStorage using await to ensure it completes
await AsyncStorage.setItem('app_settings', JSON.stringify(updatedSettings));
logger.log(`[LogoSourceSettings] Successfully saved TMDB language preference '${languageCode}' to AsyncStorage`);
} else {
// If no settings exist yet, create new settings object with this preference
const newSettings = {
...DEFAULT_SETTINGS,
tmdbLanguagePreference: languageCode
};
// Save to AsyncStorage
await AsyncStorage.setItem('app_settings', JSON.stringify(newSettings));
logger.log(`[LogoSourceSettings] Created new settings with TMDB language preference '${languageCode}'`);
}
// Clear any cached logo data // Clear any cached logo data
await AsyncStorage.removeItem('_last_logos_'); await AsyncStorage.removeItem('_last_logos_');
@ -875,7 +812,7 @@ const LogoSourceSettings = () => {
key={langCode} // Use the unique code as key key={langCode} // Use the unique code as key
style={[ style={[
styles.languageItem, styles.languageItem,
selectedTmdbLanguage === langCode && styles.selectedLanguageItem preferredTmdbLanguage === langCode && styles.selectedLanguageItem
]} ]}
onPress={() => handleTmdbLanguageSelect(langCode)} onPress={() => handleTmdbLanguageSelect(langCode)}
activeOpacity={0.7} activeOpacity={0.7}
@ -884,7 +821,7 @@ const LogoSourceSettings = () => {
<Text <Text
style={[ style={[
styles.languageItemText, styles.languageItemText,
selectedTmdbLanguage === langCode && styles.selectedLanguageItemText preferredTmdbLanguage === langCode && styles.selectedLanguageItemText
]} ]}
> >
{(langCode || '').toUpperCase() || '??'} {(langCode || '').toUpperCase() || '??'}

View file

@ -540,7 +540,7 @@ const MDBListSettingsScreen = () => {
const openMDBListWebsite = () => { const openMDBListWebsite = () => {
logger.log('[MDBListSettingsScreen] Opening MDBList website'); logger.log('[MDBListSettingsScreen] Opening MDBList website');
Linking.openURL('https://mdblist.com/settings').catch(error => { Linking.openURL('https://mdblist.com/preferences').catch(error => {
logger.error('[MDBListSettingsScreen] Error opening website:', error); logger.error('[MDBListSettingsScreen] Error opening website:', error);
}); });
}; };

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { import {
View, View,
Text, Text,
@ -24,36 +24,42 @@ import Animated, {
useAnimatedStyle, useAnimatedStyle,
interpolate, interpolate,
Extrapolate, Extrapolate,
useSharedValue,
withTiming,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { RouteProp } from '@react-navigation/native'; import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native'; import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator'; import { RootStackParamList } from '../navigation/AppNavigator';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScreen';
// Import our new components and hooks // Import our optimized components and hooks
import HeroSection from '../components/metadata/HeroSection'; import HeroSection from '../components/metadata/HeroSection';
import FloatingHeader from '../components/metadata/FloatingHeader'; import FloatingHeader from '../components/metadata/FloatingHeader';
import MetadataDetails from '../components/metadata/MetadataDetails'; import MetadataDetails from '../components/metadata/MetadataDetails';
import { useMetadataAnimations } from '../hooks/useMetadataAnimations'; import { useMetadataAnimations } from '../hooks/useMetadataAnimations';
import { useMetadataAssets } from '../hooks/useMetadataAssets'; import { useMetadataAssets } from '../hooks/useMetadataAssets';
import { useWatchProgress } from '../hooks/useWatchProgress'; import { useWatchProgress } from '../hooks/useWatchProgress';
import { TraktService, TraktPlaybackItem } from '../services/traktService';
const { height } = Dimensions.get('window'); const { height } = Dimensions.get('window');
const MetadataScreen = () => { const MetadataScreen: React.FC = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>(); const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { id, type, episodeId } = route.params; const { id, type, episodeId, addonId } = route.params;
// Add settings hook // Consolidated hooks for better performance
const { settings } = useSettings(); const { settings } = useSettings();
// Get theme context
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
// Get safe area insets
const { top: safeAreaTop } = useSafeAreaInsets(); const { top: safeAreaTop } = useSafeAreaInsets();
// Optimized state management - reduced state variables
const [isContentReady, setIsContentReady] = useState(false);
const [showSkeleton, setShowSkeleton] = useState(true);
const transitionOpacity = useSharedValue(0);
const skeletonOpacity = useSharedValue(1);
const { const {
metadata, metadata,
loading, loading,
@ -72,222 +78,263 @@ const MetadataScreen = () => {
loadingRecommendations, loadingRecommendations,
setMetadata, setMetadata,
imdbId, imdbId,
} = useMetadata({ id, type }); } = useMetadata({ id, type, addonId });
// Use our new hooks // Optimized hooks with memoization
const { const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
watchProgress, const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
getEpisodeDetails, const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress);
getPlayButtonText,
} = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
const { // Fetch and log Trakt progress data when entering the screen
bannerImage, useEffect(() => {
loadingBanner, const fetchTraktProgress = async () => {
logoLoadError, try {
setLogoLoadError, const traktService = TraktService.getInstance();
setBannerImage, const isAuthenticated = await traktService.isAuthenticated();
} = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
const animations = useMetadataAnimations(safeAreaTop, watchProgress); console.log(`[MetadataScreen] === TRAKT PROGRESS DATA FOR ${type.toUpperCase()}: ${metadata?.name || id} ===`);
console.log(`[MetadataScreen] IMDB ID: ${id}`);
console.log(`[MetadataScreen] Trakt authenticated: ${isAuthenticated}`);
// Add wrapper for toggleLibrary that includes haptic feedback if (!isAuthenticated) {
const handleToggleLibrary = useCallback(() => { console.log(`[MetadataScreen] Not authenticated with Trakt, no progress data available`);
// Trigger appropriate haptic feedback based on action return;
if (inLibrary) {
// Removed from library - light impact
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} else {
// Added to library - success feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} }
// Call the original toggleLibrary function // Get all playback progress from Trakt
const allProgress = await traktService.getPlaybackProgress();
console.log(`[MetadataScreen] Total Trakt progress items: ${allProgress.length}`);
if (allProgress.length === 0) {
console.log(`[MetadataScreen] No Trakt progress data found`);
return;
}
// Filter progress for current content
let relevantProgress: TraktPlaybackItem[] = [];
if (type === 'movie') {
relevantProgress = allProgress.filter(item =>
item.type === 'movie' &&
item.movie?.ids.imdb === id.replace('tt', '')
);
} else if (type === 'series') {
relevantProgress = allProgress.filter(item =>
item.type === 'episode' &&
item.show?.ids.imdb === id.replace('tt', '')
);
}
console.log(`[MetadataScreen] Relevant progress items for this ${type}: ${relevantProgress.length}`);
if (relevantProgress.length === 0) {
console.log(`[MetadataScreen] No Trakt progress found for this ${type}`);
return;
}
// Log detailed progress information
relevantProgress.forEach((item, index) => {
console.log(`[MetadataScreen] --- Progress Item ${index + 1} ---`);
console.log(`[MetadataScreen] Type: ${item.type}`);
console.log(`[MetadataScreen] Progress: ${item.progress.toFixed(2)}%`);
console.log(`[MetadataScreen] Paused at: ${item.paused_at}`);
console.log(`[MetadataScreen] Trakt ID: ${item.id}`);
if (item.movie) {
console.log(`[MetadataScreen] Movie: ${item.movie.title} (${item.movie.year})`);
console.log(`[MetadataScreen] Movie IMDB: tt${item.movie.ids.imdb}`);
console.log(`[MetadataScreen] Movie TMDB: ${item.movie.ids.tmdb}`);
}
if (item.episode && item.show) {
console.log(`[MetadataScreen] Show: ${item.show.title} (${item.show.year})`);
console.log(`[MetadataScreen] Show IMDB: tt${item.show.ids.imdb}`);
console.log(`[MetadataScreen] Episode: S${item.episode.season}E${item.episode.number} - ${item.episode.title}`);
console.log(`[MetadataScreen] Episode IMDB: ${item.episode.ids.imdb || 'N/A'}`);
console.log(`[MetadataScreen] Episode TMDB: ${item.episode.ids.tmdb || 'N/A'}`);
}
console.log(`[MetadataScreen] Raw item:`, JSON.stringify(item, null, 2));
});
// Find most recent progress if multiple episodes
if (type === 'series' && relevantProgress.length > 1) {
const mostRecent = relevantProgress.sort((a, b) =>
new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()
)[0];
console.log(`[MetadataScreen] === MOST RECENT EPISODE PROGRESS ===`);
if (mostRecent.episode && mostRecent.show) {
console.log(`[MetadataScreen] Most recent: S${mostRecent.episode.season}E${mostRecent.episode.number} - ${mostRecent.episode.title}`);
console.log(`[MetadataScreen] Progress: ${mostRecent.progress.toFixed(2)}%`);
console.log(`[MetadataScreen] Watched on: ${new Date(mostRecent.paused_at).toLocaleString()}`);
}
}
console.log(`[MetadataScreen] === END TRAKT PROGRESS DATA ===`);
} catch (error) {
console.error(`[MetadataScreen] Failed to fetch Trakt progress:`, error);
}
};
// Only fetch when we have metadata loaded
if (metadata && id) {
fetchTraktProgress();
}
}, [metadata, id, type]);
// Memoized derived values for performance
const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]);
// Smooth skeleton to content transition
useEffect(() => {
if (isReady && !isContentReady) {
// Small delay to ensure skeleton is rendered before starting transition
setTimeout(() => {
// Start fade out skeleton and fade in content simultaneously
skeletonOpacity.value = withTiming(0, { duration: 300 });
transitionOpacity.value = withTiming(1, { duration: 400 });
// Hide skeleton after fade out completes
setTimeout(() => {
setShowSkeleton(false);
setIsContentReady(true);
}, 300);
}, 100);
} else if (!isReady && isContentReady) {
setIsContentReady(false);
setShowSkeleton(true);
transitionOpacity.value = 0;
skeletonOpacity.value = 1;
}
}, [isReady, isContentReady]);
// Optimized callback functions with reduced dependencies
const handleToggleLibrary = useCallback(() => {
Haptics.impactAsync(inLibrary ? Haptics.ImpactFeedbackStyle.Light : Haptics.ImpactFeedbackStyle.Medium);
toggleLibrary(); toggleLibrary();
}, [inLibrary, toggleLibrary]); }, [inLibrary, toggleLibrary]);
// Add wrapper for season change with distinctive haptic feedback
const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => { const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => {
// Change to Light impact for a more subtle feedback
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Wait a tiny bit before changing season, making the feedback more noticeable
setTimeout(() => {
handleSeasonChange(seasonNumber); handleSeasonChange(seasonNumber);
}, 10);
}, [handleSeasonChange]); }, [handleSeasonChange]);
// Handler functions
const handleShowStreams = useCallback(() => { const handleShowStreams = useCallback(() => {
const { watchProgress } = watchProgressData;
if (type === 'series') { if (type === 'series') {
// If we have watch progress with an episodeId, use that const targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ?
if (watchProgress?.episodeId) { (episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined);
navigation.navigate('Streams', {
id, if (targetEpisodeId) {
type, navigation.navigate('Streams', { id, type, episodeId: targetEpisodeId });
episodeId: watchProgress.episodeId
});
return; return;
} }
}
// If we have a specific episodeId from route params, use that
if (episodeId) {
navigation.navigate('Streams', { id, type, episodeId }); navigation.navigate('Streams', { id, type, episodeId });
return; }, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]);
}
// Otherwise, if we have episodes, start with the first one
if (episodes.length > 0) {
const firstEpisode = episodes[0];
const newEpisodeId = firstEpisode.stremioId || `${id}:${firstEpisode.season_number}:${firstEpisode.episode_number}`;
navigation.navigate('Streams', { id, type, episodeId: newEpisodeId });
return;
}
}
navigation.navigate('Streams', { id, type, episodeId });
}, [navigation, id, type, episodes, episodeId, watchProgress]);
const handleSelectCastMember = useCallback((castMember: any) => {
// Future implementation
}, []);
const handleEpisodeSelect = useCallback((episode: Episode) => { const handleEpisodeSelect = useCallback((episode: Episode) => {
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`; const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
navigation.navigate('Streams', { navigation.navigate('Streams', { id, type, episodeId });
id,
type,
episodeId
});
}, [navigation, id, type]); }, [navigation, id, type]);
const handleBack = useCallback(() => { const handleBack = useCallback(() => navigation.goBack(), [navigation]);
navigation.goBack(); const handleSelectCastMember = useCallback(() => {}, []); // Simplified for performance
}, [navigation]);
// Animated styles // Ultra-optimized animated styles - minimal calculations
const containerAnimatedStyle = useAnimatedStyle(() => ({ const containerStyle = useAnimatedStyle(() => ({
flex: 1, opacity: animations.screenOpacity.value,
transform: [{ scale: animations.screenScale.value }], }), []);
opacity: animations.screenOpacity.value
}));
const contentAnimatedStyle = useAnimatedStyle(() => ({ const contentStyle = useAnimatedStyle(() => ({
transform: [{ translateY: animations.contentTranslateY.value }], opacity: animations.contentOpacity.value,
opacity: interpolate( transform: [{ translateY: animations.uiElementsTranslateY.value }]
animations.contentTranslateY.value, }), []);
[60, 0],
[0, 1], const transitionStyle = useAnimatedStyle(() => ({
Extrapolate.CLAMP opacity: transitionOpacity.value,
) }), []);
}));
const skeletonStyle = useAnimatedStyle(() => ({
opacity: skeletonOpacity.value,
}), []);
// Memoized error component for performance
const ErrorComponent = useMemo(() => {
if (!metadataError) return null;
if (loading) {
return ( return (
<SafeAreaView <SafeAreaView
style={[styles.container, { style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
backgroundColor: currentTheme.colors.darkBackground
}]}
edges={['bottom']} edges={['bottom']}
> >
<StatusBar <StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
translucent={true}
backgroundColor="transparent"
barStyle="light-content"
/>
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, {
color: currentTheme.colors.mediumEmphasis
}]}>
Loading content...
</Text>
</View>
</SafeAreaView>
);
}
if (metadataError || !metadata) {
return (
<SafeAreaView
style={[styles.container, {
backgroundColor: currentTheme.colors.darkBackground
}]}
edges={['bottom']}
>
<StatusBar
translucent={true}
backgroundColor="transparent"
barStyle="light-content"
/>
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<MaterialIcons <MaterialIcons name="error-outline" size={64} color={currentTheme.colors.textMuted} />
name="error-outline" <Text style={[styles.errorText, { color: currentTheme.colors.highEmphasis }]}>
size={64}
color={currentTheme.colors.textMuted}
/>
<Text style={[styles.errorText, {
color: currentTheme.colors.highEmphasis
}]}>
{metadataError || 'Content not found'} {metadataError || 'Content not found'}
</Text> </Text>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
styles.retryButton,
{ backgroundColor: currentTheme.colors.primary }
]}
onPress={loadMetadata} onPress={loadMetadata}
> >
<MaterialIcons <MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} />
name="refresh"
size={20}
color={currentTheme.colors.white}
style={{ marginRight: 8 }}
/>
<Text style={styles.retryButtonText}>Try Again</Text> <Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[ style={[styles.backButton, { borderColor: currentTheme.colors.primary }]}
styles.backButton,
{ borderColor: currentTheme.colors.primary }
]}
onPress={handleBack} onPress={handleBack}
> >
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}> <Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>Go Back</Text>
Go Back
</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</SafeAreaView> </SafeAreaView>
); );
}, [metadataError, currentTheme, loadMetadata, handleBack]);
// Show error if exists
if (metadataError || (!loading && !metadata)) {
return ErrorComponent;
} }
return ( return (
<View style={StyleSheet.absoluteFill}>
{/* Skeleton Loading Screen - with fade out transition */}
{showSkeleton && (
<Animated.View
style={[StyleSheet.absoluteFill, skeletonStyle]}
pointerEvents={metadata ? 'none' : 'auto'}
>
<MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} />
</Animated.View>
)}
{/* Main Content - with fade in transition */}
{metadata && (
<Animated.View
style={[StyleSheet.absoluteFill, transitionStyle]}
pointerEvents={metadata ? 'auto' : 'none'}
>
<SafeAreaView <SafeAreaView
style={[containerAnimatedStyle, styles.container, { style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
backgroundColor: currentTheme.colors.darkBackground
}]}
edges={['bottom']} edges={['bottom']}
> >
<StatusBar <StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
translucent={true}
backgroundColor="transparent" {/* Floating Header - Optimized */}
barStyle="light-content"
animated={true}
/>
<Animated.View style={containerAnimatedStyle}>
{/* Floating Header */}
<FloatingHeader <FloatingHeader
metadata={metadata} metadata={metadata}
logoLoadError={logoLoadError} logoLoadError={assetData.logoLoadError}
handleBack={handleBack} handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary} handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary} inLibrary={inLibrary}
headerOpacity={animations.headerOpacity} headerOpacity={animations.headerOpacity}
headerElementsY={animations.headerElementsY}
headerElementsOpacity={animations.headerElementsOpacity} headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop} safeAreaTop={safeAreaTop}
setLogoLoadError={setLogoLoadError} setLogoLoadError={assetData.setLogoLoadError}
/> />
<Animated.ScrollView <Animated.ScrollView
@ -295,62 +342,54 @@ const MetadataScreen = () => {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler} onScroll={animations.scrollHandler}
scrollEventThrottle={16} scrollEventThrottle={16}
bounces={false}
overScrollMode="never"
contentContainerStyle={styles.scrollContent}
> >
{/* Hero Section */} {/* Hero Section - Optimized */}
<HeroSection <HeroSection
metadata={metadata} metadata={metadata}
bannerImage={bannerImage} bannerImage={assetData.bannerImage}
loadingBanner={loadingBanner} loadingBanner={assetData.loadingBanner}
logoLoadError={logoLoadError} logoLoadError={assetData.logoLoadError}
scrollY={animations.scrollY} scrollY={animations.scrollY}
dampedScrollY={animations.dampedScrollY}
heroHeight={animations.heroHeight} heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity} heroOpacity={animations.heroOpacity}
heroScale={animations.heroScale}
logoOpacity={animations.logoOpacity} logoOpacity={animations.logoOpacity}
logoScale={animations.logoScale}
genresOpacity={animations.genresOpacity}
genresTranslateY={animations.genresTranslateY}
buttonsOpacity={animations.buttonsOpacity} buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY} buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity} watchProgressOpacity={animations.watchProgressOpacity}
watchProgressScaleY={animations.watchProgressScaleY} watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgress} watchProgress={watchProgressData.watchProgress}
type={type as 'movie' | 'series'} type={type as 'movie' | 'series'}
getEpisodeDetails={getEpisodeDetails} getEpisodeDetails={watchProgressData.getEpisodeDetails}
handleShowStreams={handleShowStreams} handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary} handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary} inLibrary={inLibrary}
id={id} id={id}
navigation={navigation} navigation={navigation}
getPlayButtonText={getPlayButtonText} getPlayButtonText={watchProgressData.getPlayButtonText}
setBannerImage={setBannerImage} setBannerImage={assetData.setBannerImage}
setLogoLoadError={setLogoLoadError} setLogoLoadError={assetData.setLogoLoadError}
/> />
{/* Main Content */} {/* Main Content - Optimized */}
<Animated.View style={contentAnimatedStyle}> <Animated.View style={contentStyle}>
{/* Metadata Details */}
<MetadataDetails <MetadataDetails
metadata={metadata} metadata={metadata}
imdbId={imdbId} imdbId={imdbId}
type={type as 'movie' | 'series'} type={type as 'movie' | 'series'}
renderRatings={() => imdbId ? ( renderRatings={() => imdbId ? (
<RatingsSection <RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} />
imdbId={imdbId}
type={type === 'series' ? 'show' : 'movie'}
/>
) : null} ) : null}
/> />
{/* Cast Section */}
<CastSection <CastSection
cast={cast} cast={cast}
loadingCast={loadingCast} loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember} onSelectCastMember={handleSelectCastMember}
/> />
{/* More Like This Section - Only for movies */}
{type === 'movie' && ( {type === 'movie' && (
<MoreLikeThisSection <MoreLikeThisSection
recommendations={recommendations} recommendations={recommendations}
@ -358,7 +397,6 @@ const MetadataScreen = () => {
/> />
)} )}
{/* Type-specific content */}
{type === 'series' ? ( {type === 'series' ? (
<SeriesContent <SeriesContent
episodes={episodes} episodes={episodes}
@ -367,36 +405,30 @@ const MetadataScreen = () => {
onSeasonChange={handleSeasonChangeWithHaptics} onSeasonChange={handleSeasonChangeWithHaptics}
onSelectEpisode={handleEpisodeSelect} onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes} groupedEpisodes={groupedEpisodes}
metadata={metadata} metadata={metadata || undefined}
/> />
) : ( ) : (
<MovieContent metadata={metadata} /> metadata && <MovieContent metadata={metadata} />
)} )}
</Animated.View> </Animated.View>
</Animated.ScrollView> </Animated.ScrollView>
</Animated.View>
</SafeAreaView> </SafeAreaView>
</Animated.View>
)}
</View>
); );
}; };
// Optimized styles with minimal properties
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
backgroundColor: 'transparent',
paddingTop: 0,
}, },
scrollView: { scrollView: {
flex: 1, flex: 1,
}, },
loadingContainer: { scrollContent: {
flex: 1, flexGrow: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
loadingText: {
marginTop: 16,
fontSize: 16,
}, },
errorContainer: { errorContainer: {
flex: 1, flex: 1,
@ -422,13 +454,13 @@ const styles = StyleSheet.create({
retryButtonText: { retryButtonText: {
fontSize: 16, fontSize: 16,
fontWeight: '600', fontWeight: '600',
color: '#fff',
}, },
backButton: { backButton: {
width: 40, paddingHorizontal: 24,
height: 40, paddingVertical: 12,
alignItems: 'center', borderRadius: 24,
justifyContent: 'center', borderWidth: 2,
borderRadius: 20,
}, },
backButtonText: { backButtonText: {
fontSize: 16, fontSize: 16,

View file

@ -8,6 +8,7 @@ import {
Platform, Platform,
TouchableOpacity, TouchableOpacity,
StatusBar, StatusBar,
Switch,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { useSettings, AppSettings } from '../hooks/useSettings'; import { useSettings, AppSettings } from '../hooks/useSettings';
@ -219,6 +220,68 @@ const PlayerSettingsScreen: React.FC = () => {
))} ))}
</View> </View>
</View> </View>
<View style={styles.section}>
<Text
style={[
styles.sectionTitle,
{ color: currentTheme.colors.textMuted },
]}
>
PLAYBACK OPTIONS
</Text>
<View
style={[
styles.card,
{
backgroundColor: currentTheme.colors.elevation2,
},
]}
>
<View style={styles.settingItem}>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name="play-arrow"
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
Auto-play Best Stream
</Text>
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
Automatically play the highest quality stream when available
</Text>
</View>
<Switch
value={settings.autoplayBestStream}
onValueChange={(value) => updateSetting('autoplayBestStream', value)}
trackColor={{
false: 'rgba(255,255,255,0.2)',
true: currentTheme.colors.primary + '40'
}}
thumbColor={settings.autoplayBestStream ? currentTheme.colors.primary : 'rgba(255,255,255,0.8)'}
ios_backgroundColor="rgba(255,255,255,0.2)"
/>
</View>
</View>
</View>
</View>
</ScrollView> </ScrollView>
</SafeAreaView> </SafeAreaView>
); );

View file

@ -235,6 +235,11 @@ const SearchScreen = () => {
useEffect(() => { useEffect(() => {
loadRecentSearches(); loadRecentSearches();
// Cleanup function to cancel pending searches on unmount
return () => {
debouncedSearch.cancel();
};
}, []); }, []);
const animatedSearchBarStyle = useAnimatedStyle(() => { const animatedSearchBarStyle = useAnimatedStyle(() => {
@ -282,7 +287,14 @@ const SearchScreen = () => {
setShowRecent(true); setShowRecent(true);
loadRecentSearches(); loadRecentSearches();
} else { } else {
// Add a small delay to allow keyboard to dismiss smoothly before navigation
if (Platform.OS === 'android') {
setTimeout(() => {
navigation.goBack(); navigation.goBack();
}, 100);
} else {
navigation.goBack();
}
} }
}; };
@ -299,13 +311,17 @@ const SearchScreen = () => {
const saveRecentSearch = async (searchQuery: string) => { const saveRecentSearch = async (searchQuery: string) => {
try { try {
setRecentSearches(prevSearches => {
const newRecentSearches = [ const newRecentSearches = [
searchQuery, searchQuery,
...recentSearches.filter(s => s !== searchQuery) ...prevSearches.filter(s => s !== searchQuery)
].slice(0, MAX_RECENT_SEARCHES); ].slice(0, MAX_RECENT_SEARCHES);
setRecentSearches(newRecentSearches); // Save to AsyncStorage
await AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches)); AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
return newRecentSearches;
});
} catch (error) { } catch (error) {
logger.error('Failed to save recent search:', error); logger.error('Failed to save recent search:', error);
} }
@ -320,34 +336,50 @@ const SearchScreen = () => {
} }
try { try {
logger.info('Performing search for:', searchQuery);
const searchResults = await catalogService.searchContentCinemeta(searchQuery); const searchResults = await catalogService.searchContentCinemeta(searchQuery);
setResults(searchResults); setResults(searchResults);
if (searchResults.length > 0) { if (searchResults.length > 0) {
await saveRecentSearch(searchQuery); await saveRecentSearch(searchQuery);
} }
logger.info('Search completed, found', searchResults.length, 'results');
} catch (error) { } catch (error) {
logger.error('Search failed:', error); logger.error('Search failed:', error);
setResults([]); setResults([]);
} finally { } finally {
setSearching(false); setSearching(false);
} }
}, 200), }, 800),
[recentSearches] []
); );
useEffect(() => { useEffect(() => {
if (query.trim()) { if (query.trim() && query.trim().length >= 2) {
setSearching(true); setSearching(true);
setSearched(true); setSearched(true);
setShowRecent(false); setShowRecent(false);
debouncedSearch(query); debouncedSearch(query);
} else if (query.trim().length < 2 && query.trim().length > 0) {
// Show that we're waiting for more characters
setSearching(false);
setSearched(false);
setShowRecent(false);
setResults([]);
} else { } else {
// Cancel any pending search when query is cleared
debouncedSearch.cancel();
setResults([]); setResults([]);
setSearched(false); setSearched(false);
setSearching(false);
setShowRecent(true); setShowRecent(true);
loadRecentSearches(); loadRecentSearches();
} }
}, [query]);
// Cleanup function to cancel pending searches
return () => {
debouncedSearch.cancel();
};
}, [query, debouncedSearch]);
const handleClearSearch = () => { const handleClearSearch = () => {
setQuery(''); setQuery('');
@ -472,7 +504,14 @@ const SearchScreen = () => {
const headerHeight = headerBaseHeight + topSpacing + 60; const headerHeight = headerBaseHeight + topSpacing + 60;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <Animated.View
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
entering={Platform.OS === 'android' ? SlideInRight.duration(250) : FadeIn.duration(350)}
exiting={Platform.OS === 'android' ?
FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
FadeOut.duration(250)
}
>
<StatusBar <StatusBar
barStyle="light-content" barStyle="light-content"
backgroundColor="transparent" backgroundColor="transparent"
@ -544,6 +583,23 @@ const SearchScreen = () => {
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{searching ? ( {searching ? (
<SimpleSearchAnimation /> <SimpleSearchAnimation />
) : query.trim().length === 1 ? (
<Animated.View
style={styles.emptyContainer}
entering={FadeIn.duration(300)}
>
<MaterialIcons
name="search"
size={64}
color={currentTheme.colors.lightGray}
/>
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
Keep typing...
</Text>
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
Type at least 2 characters to search
</Text>
</Animated.View>
) : searched && !hasResultsToShow ? ( ) : searched && !hasResultsToShow ? (
<Animated.View <Animated.View
style={styles.emptyContainer} style={styles.emptyContainer}
@ -614,7 +670,7 @@ const SearchScreen = () => {
)} )}
</View> </View>
</View> </View>
</View> </Animated.View>
); );
}; };
@ -710,7 +766,7 @@ const styles = StyleSheet.create({
horizontalItemPosterContainer: { horizontalItemPosterContainer: {
width: HORIZONTAL_ITEM_WIDTH, width: HORIZONTAL_ITEM_WIDTH,
height: HORIZONTAL_POSTER_HEIGHT, height: HORIZONTAL_POSTER_HEIGHT,
borderRadius: 12, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
marginBottom: 8, marginBottom: 8,
borderWidth: 1, borderWidth: 1,

View file

@ -367,6 +367,51 @@ const SettingsScreen: React.FC = () => {
icon="palette" icon="palette"
renderControl={ChevronRight} renderControl={ChevronRight}
onPress={() => navigation.navigate('ThemeSettings')} onPress={() => navigation.navigate('ThemeSettings')}
/>
<SettingItem
title="Episode Layout"
description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
icon="view-module"
renderControl={() => (
<View style={styles.selectorContainer}>
<TouchableOpacity
style={[
styles.selectorButton,
settings.episodeLayoutStyle === 'vertical' && {
backgroundColor: currentTheme.colors.primary
}
]}
onPress={() => updateSetting('episodeLayoutStyle', 'vertical')}
>
<Text style={[
styles.selectorText,
{ color: currentTheme.colors.mediumEmphasis },
settings.episodeLayoutStyle === 'vertical' && {
color: currentTheme.colors.white,
fontWeight: '600'
}
]}>Vertical</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.selectorButton,
settings.episodeLayoutStyle === 'horizontal' && {
backgroundColor: currentTheme.colors.primary
}
]}
onPress={() => updateSetting('episodeLayoutStyle', 'horizontal')}
>
<Text style={[
styles.selectorText,
{ color: currentTheme.colors.mediumEmphasis },
settings.episodeLayoutStyle === 'horizontal' && {
color: currentTheme.colors.white,
fontWeight: '600'
}
]}>Horizontal</Text>
</TouchableOpacity>
</View>
)}
isLast={true} isLast={true}
/> />
</SettingsCard> </SettingsCard>
@ -406,6 +451,13 @@ const SettingsScreen: React.FC = () => {
onPress={() => navigation.navigate('CatalogSettings')} onPress={() => navigation.navigate('CatalogSettings')}
badge={catalogCount} badge={catalogCount}
/> />
<SettingItem
title="Internal Providers"
description="Enable or disable built-in providers like HDRezka"
icon="source"
renderControl={ChevronRight}
onPress={() => navigation.navigate('InternalProvidersSettings')}
/>
<SettingItem <SettingItem
title="Home Screen" title="Home Screen"
description="Customize layout and content" description="Customize layout and content"
@ -545,7 +597,7 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
width: '100%', width: '100%',
paddingBottom: 32, paddingBottom: 90,
}, },
cardContainer: { cardContainer: {
width: '100%', width: '100%',
@ -645,19 +697,20 @@ const styles = StyleSheet.create({
borderRadius: 8, borderRadius: 8,
overflow: 'hidden', overflow: 'hidden',
height: 36, height: 36,
width: 160, width: 180,
marginRight: 8, marginRight: 8,
}, },
selectorButton: { selectorButton: {
flex: 1, flex: 1,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 12, paddingHorizontal: 8,
backgroundColor: 'rgba(255,255,255,0.08)', backgroundColor: 'rgba(255,255,255,0.08)',
}, },
selectorText: { selectorText: {
fontSize: 14, fontSize: 13,
fontWeight: '500', fontWeight: '500',
textAlign: 'center',
}, },
profileLockContainer: { profileLockContainer: {
padding: 16, padding: 16,

File diff suppressed because it is too large Load diff

View file

@ -26,10 +26,10 @@ import { tmdbService } from '../services/tmdbService';
import { useSettings } from '../hooks/useSettings'; import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key'; const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key'; const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const TMDBSettingsScreen = () => { const TMDBSettingsScreen = () => {
const navigation = useNavigation(); const navigation = useNavigation();
@ -41,6 +41,7 @@ const TMDBSettingsScreen = () => {
const [isInputFocused, setIsInputFocused] = useState(false); const [isInputFocused, setIsInputFocused] = useState(false);
const apiKeyInputRef = useRef<TextInput>(null); const apiKeyInputRef = useRef<TextInput>(null);
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
logger.log('[TMDBSettingsScreen] Component mounted'); logger.log('[TMDBSettingsScreen] Component mounted');
@ -115,13 +116,12 @@ const TMDBSettingsScreen = () => {
const testApiKey = async (key: string): Promise<boolean> => { const testApiKey = async (key: string): Promise<boolean> => {
try { try {
// Simple API call to test the key // Simple API call to test the key using the API key parameter method
const response = await fetch( const response = await fetch(
'https://api.themoviedb.org/3/configuration', `https://api.themoviedb.org/3/configuration?api_key=${key}`,
{ {
method: 'GET', method: 'GET',
headers: { headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
} }
} }
@ -223,277 +223,66 @@ const TMDBSettingsScreen = () => {
}); });
}; };
const styles = StyleSheet.create({ const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
container: { const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
flex: 1, const headerHeight = headerBaseHeight + topSpacing;
backgroundColor: currentTheme.colors.darkBackground,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
color: currentTheme.colors.white,
},
header: {
flexDirection: 'row',
alignItems: 'center',
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
paddingHorizontal: 16,
paddingBottom: 16,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
},
backText: {
color: currentTheme.colors.primary,
fontSize: 16,
fontWeight: '500',
},
scrollView: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
},
titleContainer: {
paddingTop: 8,
},
title: {
fontSize: 28,
fontWeight: 'bold',
color: currentTheme.colors.white,
marginHorizontal: 16,
marginBottom: 16,
},
switchCard: {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
switchTextContainer: {
flex: 1,
marginRight: 12,
},
switchTitle: {
fontSize: 16,
fontWeight: '500',
color: currentTheme.colors.white,
},
switchDescription: {
fontSize: 14,
color: currentTheme.colors.mediumEmphasis,
lineHeight: 20,
},
statusCard: {
flexDirection: 'row',
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
},
statusIconContainer: {
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 16,
fontWeight: '500',
color: currentTheme.colors.white,
marginBottom: 4,
},
statusDescription: {
fontSize: 14,
color: currentTheme.colors.mediumEmphasis,
},
card: {
backgroundColor: currentTheme.colors.elevation2,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
},
cardTitle: {
fontSize: 16,
fontWeight: '500',
color: currentTheme.colors.white,
marginBottom: 16,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
input: {
flex: 1,
backgroundColor: currentTheme.colors.elevation1,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
color: currentTheme.colors.white,
fontSize: 15,
borderWidth: 1,
borderColor: 'transparent',
},
inputFocused: {
borderColor: currentTheme.colors.primary,
},
pasteButton: {
position: 'absolute',
right: 8,
padding: 4,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-between',
},
button: {
backgroundColor: currentTheme.colors.primary,
borderRadius: 8,
paddingVertical: 12,
paddingHorizontal: 16,
alignItems: 'center',
flex: 1,
marginRight: 8,
},
clearButton: {
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: currentTheme.colors.error,
marginRight: 0,
marginLeft: 8,
flex: 0,
},
buttonText: {
color: currentTheme.colors.white,
fontWeight: '500',
fontSize: 15,
},
clearButtonText: {
color: currentTheme.colors.error,
},
resultMessage: {
borderRadius: 8,
padding: 12,
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
},
successMessage: {
backgroundColor: currentTheme.colors.success + '1A', // 10% opacity
},
errorMessage: {
backgroundColor: currentTheme.colors.error + '1A', // 10% opacity
},
resultIcon: {
marginRight: 8,
},
resultText: {
flex: 1,
},
successText: {
color: currentTheme.colors.success,
},
errorText: {
color: currentTheme.colors.error,
},
helpLink: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
},
helpIcon: {
marginRight: 4,
},
helpText: {
color: currentTheme.colors.primary,
fontSize: 14,
},
infoCard: {
backgroundColor: currentTheme.colors.elevation1,
borderRadius: 12,
marginHorizontal: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
},
infoIcon: {
marginRight: 8,
marginTop: 2,
},
infoText: {
color: currentTheme.colors.mediumEmphasis,
fontSize: 14,
flex: 1,
lineHeight: 20,
},
});
if (isLoading) { if (isLoading) {
return ( return (
<SafeAreaView style={styles.container}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}> <View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} /> <ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={styles.loadingText}>Loading Settings...</Text> <Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>Loading Settings...</Text>
</View>
</View> </View>
</SafeAreaView>
); );
} }
return ( return (
<SafeAreaView style={styles.container}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" /> <StatusBar barStyle="light-content" />
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity <TouchableOpacity
style={styles.backButton} style={styles.backButton}
onPress={() => navigation.goBack()} onPress={() => navigation.goBack()}
> >
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} /> <MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={styles.backText}>Settings</Text> <Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<Text style={styles.title}>TMDb Settings</Text> <Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
TMDb Settings
</Text>
</View>
<ScrollView <ScrollView
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
> >
<View style={styles.switchCard}> <View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<View style={styles.switchTextContainer}> <View style={styles.switchTextContainer}>
<Text style={styles.switchTitle}>Use Custom TMDb API Key</Text> <Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Use Custom TMDb API Key</Text>
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
</View> </View>
<Switch <Switch
value={useCustomKey} value={useCustomKey}
onValueChange={toggleUseCustomKey} onValueChange={toggleUseCustomKey}
trackColor={{ false: currentTheme.colors.lightGray, true: currentTheme.colors.accentLight }} trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? currentTheme.colors.primary : ''} thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={currentTheme.colors.lightGray} ios_backgroundColor={'rgba(255,255,255,0.1)'}
/> />
</View> </View>
<Text style={styles.switchDescription}>
Enable to use your own TMDb API key instead of the built-in one.
Using your own API key may provide better performance and higher rate limits.
</Text>
{useCustomKey && ( {useCustomKey && (
<> <>
<View style={styles.statusCard}> <View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<MaterialIcons <MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"} name={isKeySet ? "check-circle" : "error-outline"}
size={28} size={28}
@ -501,10 +290,10 @@ const TMDBSettingsScreen = () => {
style={styles.statusIconContainer} style={styles.statusIconContainer}
/> />
<View style={styles.statusTextContainer}> <View style={styles.statusTextContainer}>
<Text style={styles.statusTitle}> <Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}>
{isKeySet ? "API Key Active" : "API Key Required"} {isKeySet ? "API Key Active" : "API Key Required"}
</Text> </Text>
<Text style={styles.statusDescription}> <Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
{isKeySet {isKeySet
? "Your custom TMDb API key is set and active." ? "Your custom TMDb API key is set and active."
: "Add your TMDb API key below."} : "Add your TMDb API key below."}
@ -512,19 +301,26 @@ const TMDBSettingsScreen = () => {
</View> </View>
</View> </View>
<View style={styles.card}> <View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={styles.cardTitle}>API Key</Text> <Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text>
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<TextInput <TextInput
ref={apiKeyInputRef} ref={apiKeyInputRef}
style={[styles.input, isInputFocused && styles.inputFocused]} style={[
styles.input,
{
backgroundColor: currentTheme.colors.elevation1,
color: currentTheme.colors.text,
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
}
]}
value={apiKey} value={apiKey}
onChangeText={(text) => { onChangeText={(text) => {
setApiKey(text); setApiKey(text);
if (testResult) setTestResult(null); if (testResult) setTestResult(null);
}} }}
placeholder="Paste your TMDb API key (v4 auth)" placeholder="Paste your TMDb API key (v3)"
placeholderTextColor={currentTheme.colors.mediumGray} placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false} autoCorrect={false}
spellCheck={false} spellCheck={false}
@ -541,18 +337,18 @@ const TMDBSettingsScreen = () => {
<View style={styles.buttonRow}> <View style={styles.buttonRow}>
<TouchableOpacity <TouchableOpacity
style={styles.button} style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey} onPress={saveApiKey}
> >
<Text style={styles.buttonText}>Save API Key</Text> <Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
</TouchableOpacity> </TouchableOpacity>
{isKeySet && ( {isKeySet && (
<TouchableOpacity <TouchableOpacity
style={[styles.button, styles.clearButton]} style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey} onPress={clearApiKey}
> >
<Text style={[styles.buttonText, styles.clearButtonText]}>Clear</Text> <Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@ -560,7 +356,7 @@ const TMDBSettingsScreen = () => {
{testResult && ( {testResult && (
<View style={[ <View style={[
styles.resultMessage, styles.resultMessage,
testResult.success ? styles.successMessage : styles.errorMessage { backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
]}> ]}>
<MaterialIcons <MaterialIcons
name={testResult.success ? "check-circle" : "error"} name={testResult.success ? "check-circle" : "error"}
@ -570,7 +366,7 @@ const TMDBSettingsScreen = () => {
/> />
<Text style={[ <Text style={[
styles.resultText, styles.resultText,
testResult.success ? styles.successText : styles.errorText { color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
]}> ]}>
{testResult.message} {testResult.message}
</Text> </Text>
@ -582,16 +378,16 @@ const TMDBSettingsScreen = () => {
onPress={openTMDBWebsite} onPress={openTMDBWebsite}
> >
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} /> <MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
<Text style={styles.helpText}> <Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
How to get a TMDb API key? How to get a TMDb API key?
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<View style={styles.infoCard}> <View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} /> <MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={styles.infoText}> <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
To get your own TMDb API key (v4 auth token), you need to create a TMDb account and request an API key from their website. To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website.
Using your own API key gives you dedicated quota and may improve app performance. Using your own API key gives you dedicated quota and may improve app performance.
</Text> </Text>
</View> </View>
@ -599,17 +395,226 @@ const TMDBSettingsScreen = () => {
)} )}
{!useCustomKey && ( {!useCustomKey && (
<View style={styles.infoCard}> <View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} /> <MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
<Text style={styles.infoText}> <Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
Currently using the built-in TMDb API key. This key is shared among all users. Currently using the built-in TMDb API key. This key is shared among all users.
For better performance and reliability, consider using your own API key. For better performance and reliability, consider using your own API key.
</Text> </Text>
</View> </View>
)} )}
</ScrollView> </ScrollView>
</SafeAreaView> </View>
); );
}; };
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
marginTop: 12,
fontSize: 16,
},
headerContainer: {
paddingHorizontal: 20,
paddingBottom: 8,
backgroundColor: 'transparent',
zIndex: 2,
},
header: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
},
backText: {
fontSize: 16,
fontWeight: '500',
marginLeft: 4,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.3,
paddingLeft: 4,
},
scrollView: {
flex: 1,
zIndex: 1,
},
scrollContent: {
paddingHorizontal: 16,
paddingBottom: 40,
},
switchCard: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
switchTextContainer: {
flex: 1,
marginRight: 16,
},
switchTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
switchDescription: {
fontSize: 14,
lineHeight: 20,
opacity: 0.8,
},
statusCard: {
flexDirection: 'row',
borderRadius: 16,
marginBottom: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
statusIconContainer: {
marginRight: 12,
},
statusTextContainer: {
flex: 1,
},
statusTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 4,
},
statusDescription: {
fontSize: 14,
opacity: 0.8,
},
card: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
},
cardTitle: {
fontSize: 16,
fontWeight: '600',
marginBottom: 16,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 16,
},
input: {
flex: 1,
borderRadius: 12,
paddingHorizontal: 16,
paddingVertical: 14,
fontSize: 15,
borderWidth: 2,
},
pasteButton: {
position: 'absolute',
right: 12,
padding: 8,
},
buttonRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
button: {
borderRadius: 12,
paddingVertical: 14,
paddingHorizontal: 20,
alignItems: 'center',
flex: 1,
marginRight: 8,
},
clearButton: {
backgroundColor: 'transparent',
borderWidth: 2,
marginRight: 0,
marginLeft: 8,
flex: 0,
paddingHorizontal: 16,
},
buttonText: {
fontWeight: '600',
fontSize: 15,
},
resultMessage: {
borderRadius: 12,
padding: 16,
marginTop: 16,
flexDirection: 'row',
alignItems: 'center',
},
resultIcon: {
marginRight: 12,
},
resultText: {
flex: 1,
fontSize: 14,
fontWeight: '500',
},
helpLink: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 16,
paddingVertical: 8,
},
helpIcon: {
marginRight: 8,
},
helpText: {
fontSize: 14,
fontWeight: '500',
},
infoCard: {
borderRadius: 16,
marginBottom: 16,
padding: 16,
flexDirection: 'row',
alignItems: 'flex-start',
shadowColor: '#000',
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.05,
shadowRadius: 2,
elevation: 1,
},
infoIcon: {
marginRight: 12,
marginTop: 2,
},
infoText: {
fontSize: 14,
flex: 1,
lineHeight: 20,
opacity: 0.8,
},
});
export default TMDBSettingsScreen; export default TMDBSettingsScreen;

View file

@ -11,6 +11,8 @@ import {
ScrollView, ScrollView,
StatusBar, StatusBar,
Platform, Platform,
Linking,
Switch,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session'; import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session';
@ -20,6 +22,9 @@ import { useSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import TraktIcon from '../../assets/rating-icons/trakt.svg'; import TraktIcon from '../../assets/rating-icons/trakt.svg';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useTraktIntegration } from '../hooks/useTraktIntegration';
import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings';
import { colors } from '../styles';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
@ -45,6 +50,21 @@ const TraktSettingsScreen: React.FC = () => {
const [userProfile, setUserProfile] = useState<TraktUser | null>(null); const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const {
settings: autosyncSettings,
isSyncing,
setAutosyncEnabled,
performManualSync
} = useTraktAutosyncSettings();
const {
isLoading: traktLoading,
refreshAuthStatus
} = useTraktIntegration();
const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false);
const [showThresholdModal, setShowThresholdModal] = useState(false);
const checkAuthStatus = useCallback(async () => { const checkAuthStatus = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
try { try {
@ -180,7 +200,7 @@ const TraktSettingsScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
<Text style={[ <Text style={[
styles.headerTitle, styles.headerTitle,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
]}> ]}>
Trakt Settings Trakt Settings
</Text> </Text>
@ -308,6 +328,8 @@ const TraktSettingsScreen: React.FC = () => {
Sync Settings Sync Settings
</Text> </Text>
<View style={styles.settingItem}> <View style={styles.settingItem}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={[ <Text style={[
styles.settingLabel, styles.settingLabel,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark } { color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
@ -318,9 +340,20 @@ const TraktSettingsScreen: React.FC = () => {
styles.settingDescription, styles.settingDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}> ]}>
Coming soon Automatically sync watch progress to Trakt
</Text> </Text>
</View> </View>
<Switch
value={autosyncSettings.enabled}
onValueChange={setAutosyncEnabled}
trackColor={{
false: isDarkMode ? 'rgba(120,120,128,0.3)' : 'rgba(120,120,128,0.2)',
true: currentTheme.colors.primary + '80'
}}
thumbColor={autosyncSettings.enabled ? currentTheme.colors.primary : (isDarkMode ? '#ffffff' : '#f4f3f4')}
/>
</View>
</View>
<View style={styles.settingItem}> <View style={styles.settingItem}>
<Text style={[ <Text style={[
styles.settingLabel, styles.settingLabel,
@ -332,23 +365,43 @@ const TraktSettingsScreen: React.FC = () => {
styles.settingDescription, styles.settingDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}> ]}>
Coming soon Use "Sync Now" to import your watch history and progress from Trakt
</Text> </Text>
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.button, styles.button,
{ backgroundColor: isDarkMode ? 'rgba(120,120,128,0.2)' : 'rgba(120,120,128,0.1)' } {
backgroundColor: isDarkMode ? currentTheme.colors.primary + '40' : currentTheme.colors.primary + '20',
opacity: isSyncing ? 0.6 : 1
}
]} ]}
disabled={true} disabled={isSyncing}
onPress={async () => {
const success = await performManualSync();
Alert.alert(
'Sync Complete',
success ? 'Successfully synced your watch progress with Trakt.' : 'Sync failed. Please try again.',
[{ text: 'OK' }]
);
}}
> >
{isSyncing ? (
<ActivityIndicator
size="small"
color={isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary}
/>
) : (
<Text style={[ <Text style={[
styles.buttonText, styles.buttonText,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark } { color: isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary }
]}> ]}>
Sync Now (Coming Soon) Sync Now
</Text> </Text>
)}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
)} )}

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,22 @@ export interface StreamingContent {
directors?: string[]; directors?: string[];
creators?: string[]; creators?: string[];
certification?: string; certification?: string;
// Enhanced metadata from addons
country?: string;
writer?: string[];
links?: Array<{
name: string;
category: string;
url: string;
}>;
behaviorHints?: {
defaultVideoId?: string;
hasScheduledVideos?: boolean;
[key: string]: any;
};
imdb_id?: string;
slug?: string;
releaseInfo?: string;
} }
export interface CatalogContent { export interface CatalogContent {
@ -442,7 +458,7 @@ class CatalogService {
} }
} }
async getContentDetails(type: string, id: string): Promise<StreamingContent | null> { async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
try { try {
// Try up to 3 times with increasing delays // Try up to 3 times with increasing delays
let meta = null; let meta = null;
@ -450,7 +466,7 @@ class CatalogService {
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
try { try {
meta = await stremioService.getMetaDetails(type, id); meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
if (meta) break; if (meta) break;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i))); await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
} catch (error) { } catch (error) {
@ -461,8 +477,8 @@ class CatalogService {
} }
if (meta) { if (meta) {
// Add to recent content // Add to recent content using enhanced conversion for full metadata
const content = this.convertMetaToStreamingContent(meta); const content = this.convertMetaToStreamingContentEnhanced(meta);
this.addToRecentContent(content); this.addToRecentContent(content);
// Check if it's in the library // Check if it's in the library
@ -482,7 +498,54 @@ class CatalogService {
} }
} }
// Public method for getting enhanced metadata details (used by MetadataScreen)
async getEnhancedContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`);
return this.getContentDetails(type, id, preferredAddonId);
}
// Public method for getting basic content details without enhanced processing (used by ContinueWatching, etc.)
async getBasicContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
try {
// Try up to 3 times with increasing delays
let meta = null;
let lastError = null;
for (let i = 0; i < 3; i++) {
try {
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
if (meta) break;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
} catch (error) {
lastError = error;
logger.error(`Attempt ${i + 1} failed to get basic content details for ${type}:${id}:`, error);
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
}
}
if (meta) {
// Use basic conversion without enhanced metadata processing
const content = this.convertMetaToStreamingContent(meta);
// Check if it's in the library
content.inLibrary = this.library[`${type}:${id}`] !== undefined;
return content;
}
if (lastError) {
throw lastError;
}
return null;
} catch (error) {
logger.error(`Failed to get basic content details for ${type}:${id}:`, error);
return null;
}
}
private convertMetaToStreamingContent(meta: Meta): StreamingContent { private convertMetaToStreamingContent(meta: Meta): StreamingContent {
// Basic conversion for catalog display - no enhanced metadata processing
return { return {
id: meta.id, id: meta.id,
type: meta.type, type: meta.type,
@ -490,17 +553,70 @@ class CatalogService {
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image', poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: 'poster', posterShape: 'poster',
banner: meta.background, banner: meta.background,
logo: `https://images.metahub.space/logo/medium/${meta.id}/img`, logo: `https://images.metahub.space/logo/medium/${meta.id}/img`, // Use metahub for catalog display
imdbRating: meta.imdbRating, imdbRating: meta.imdbRating,
year: meta.year, year: meta.year,
genres: meta.genres, genres: meta.genres,
description: meta.description, description: meta.description,
runtime: meta.runtime, runtime: meta.runtime,
inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined, inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
certification: meta.certification certification: meta.certification,
releaseInfo: meta.releaseInfo,
}; };
} }
// Enhanced conversion for detailed metadata (used only when fetching individual content details)
private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent {
// Enhanced conversion to utilize all available metadata from addons
const converted: StreamingContent = {
id: meta.id,
type: meta.type,
name: meta.name,
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: 'poster',
banner: meta.background,
// Use addon's logo if available, fallback to metahub
logo: (meta as any).logo || `https://images.metahub.space/logo/medium/${meta.id}/img`,
imdbRating: meta.imdbRating,
year: meta.year,
genres: meta.genres,
description: meta.description,
runtime: meta.runtime,
inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
certification: meta.certification,
// Enhanced fields from addon metadata
directors: (meta as any).director ?
(Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director])
: undefined,
writer: (meta as any).writer || undefined,
country: (meta as any).country || undefined,
imdb_id: (meta as any).imdb_id || undefined,
slug: (meta as any).slug || undefined,
releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
trailerStreams: (meta as any).trailerStreams || undefined,
links: (meta as any).links || undefined,
behaviorHints: (meta as any).behaviorHints || undefined,
};
// Cast is handled separately by the dedicated CastSection component via TMDB
// Log if rich metadata is found
if ((meta as any).trailerStreams?.length > 0) {
logger.log(`🎬 Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`);
}
if ((meta as any).links?.length > 0) {
logger.log(`🔗 Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`);
}
// Handle videos/episodes if available
if ((meta as any).videos) {
converted.videos = (meta as any).videos;
}
return converted;
}
private notifyLibrarySubscribers(): void { private notifyLibrarySubscribers(): void {
const items = Object.values(this.library); const items = Object.values(this.library);
this.librarySubscribers.forEach(callback => callback(items)); this.librarySubscribers.forEach(callback => callback(items));

View file

@ -0,0 +1,543 @@
import { logger } from '../utils/logger';
import { Stream } from '../types/metadata';
import { tmdbService } from './tmdbService';
import axios from 'axios';
import { settingsEmitter } from '../hooks/useSettings';
import AsyncStorage from '@react-native-async-storage/async-storage';
// Use node-fetch if available, otherwise fallback to global fetch
let fetchImpl: typeof fetch;
try {
// @ts-ignore
fetchImpl = require('node-fetch');
} catch {
fetchImpl = fetch;
}
// Constants
const REZKA_BASE = 'https://hdrezka.ag/';
const BASE_HEADERS = {
'X-Hdrezka-Android-App': '1',
'X-Hdrezka-Android-App-Version': '2.2.0',
};
class HDRezkaService {
private MAX_RETRIES = 3;
private RETRY_DELAY = 1000; // 1 second
// No cookies/session logic needed for Android app API
private getHeaders() {
return {
...BASE_HEADERS,
'User-Agent': 'okhttp/4.9.0',
};
}
private generateRandomFavs(): string {
const randomHex = () => Math.floor(Math.random() * 16).toString(16);
const generateSegment = (length: number) => Array.from({ length }, () => randomHex()).join('');
return `${generateSegment(8)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(4)}-${generateSegment(12)}`;
}
private extractTitleAndYear(input: string): { title: string; year: number | null } | null {
// Handle multiple formats
// Format 1: "Title, YEAR, Additional info"
const regex1 = /^(.*?),.*?(\d{4})/;
const match1 = input.match(regex1);
if (match1) {
const title = match1[1];
const year = match1[2];
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
}
// Format 2: "Title (YEAR)"
const regex2 = /^(.*?)\s*\((\d{4})\)/;
const match2 = input.match(regex2);
if (match2) {
const title = match2[1];
const year = match2[2];
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
}
// Format 3: Look for any 4-digit year in the string
const yearMatch = input.match(/(\d{4})/);
if (yearMatch) {
const year = yearMatch[1];
// Remove the year and any surrounding brackets/parentheses from the title
let title = input.replace(/\s*\(\d{4}\)|\s*\[\d{4}\]|\s*\d{4}/, '');
return { title: title.trim(), year: year ? parseInt(year, 10) : null };
}
// If no year found but we have a title
if (input.trim()) {
return { title: input.trim(), year: null };
}
return null;
}
private parseVideoLinks(inputString: string | undefined): Record<string, { type: string; url: string }> {
if (!inputString) {
logger.log('[HDRezka] No video links found');
return {};
}
logger.log(`[HDRezka] Parsing video links from stream URL data`);
const linksArray = inputString.split(',');
const result: Record<string, { type: string; url: string }> = {};
linksArray.forEach((link) => {
// Handle different quality formats:
// 1. Simple format: [360p]https://example.com/video.mp4
// 2. HTML format: [<span class="pjs-registered-quality">1080p<img...>]https://example.com/video.mp4
// Try simple format first (non-HTML)
let match = link.match(/\[([^<\]]+)\](https?:\/\/[^\s,]+\.mp4|null)/);
// If not found, try HTML format with more flexible pattern
if (!match) {
// Extract quality text from HTML span
const qualityMatch = link.match(/\[<span[^>]*>([^<]+)/);
// Extract URL separately
const urlMatch = link.match(/\][^[]*?(https?:\/\/[^\s,]+\.mp4|null)/);
if (qualityMatch && urlMatch) {
match = [link, qualityMatch[1].trim(), urlMatch[1]] as RegExpMatchArray;
}
}
if (match) {
const qualityText = match[1].trim();
const mp4Url = match[2];
// Skip null URLs (premium content that requires login)
if (mp4Url !== 'null') {
result[qualityText] = { type: 'mp4', url: mp4Url };
logger.log(`[HDRezka] Found ${qualityText}: ${mp4Url}`);
} else {
logger.log(`[HDRezka] Premium quality ${qualityText} requires login (null URL)`);
}
} else {
logger.log(`[HDRezka] Could not parse quality from: ${link}`);
}
});
logger.log(`[HDRezka] Found ${Object.keys(result).length} valid qualities: ${Object.keys(result).join(', ')}`);
return result;
}
private parseSubtitles(inputString: string | undefined): Array<{
id: string;
language: string;
hasCorsRestrictions: boolean;
type: string;
url: string;
}> {
if (!inputString) {
logger.log('[HDRezka] No subtitles found');
return [];
}
logger.log(`[HDRezka] Parsing subtitles data`);
const linksArray = inputString.split(',');
const captions: Array<{
id: string;
language: string;
hasCorsRestrictions: boolean;
type: string;
url: string;
}> = [];
linksArray.forEach((link) => {
const match = link.match(/\[([^\]]+)\](https?:\/\/\S+?)(?=,\[|$)/);
if (match) {
const language = match[1];
const url = match[2];
captions.push({
id: url,
language,
hasCorsRestrictions: false,
type: 'vtt',
url: url,
});
logger.log(`[HDRezka] Found subtitle ${language}: ${url}`);
}
});
logger.log(`[HDRezka] Found ${captions.length} subtitles`);
return captions;
}
async searchAndFindMediaId(media: { title: string; type: string; releaseYear?: number }): Promise<{
id: string;
year: number;
type: string;
url: string;
title: string;
} | null> {
logger.log(`[HDRezka] Searching for title: ${media.title}, type: ${media.type}, year: ${media.releaseYear || 'any'}`);
const itemRegexPattern = /<a href="([^"]+)"><span class="enty">([^<]+)<\/span> \(([^)]+)\)/g;
const idRegexPattern = /\/(\d+)-[^/]+\.html$/;
const fullUrl = new URL('/engine/ajax/search.php', REZKA_BASE);
fullUrl.searchParams.append('q', media.title);
logger.log(`[HDRezka] Making search request to: ${fullUrl.toString()}`);
try {
const response = await fetchImpl(fullUrl.toString(), {
method: 'GET',
headers: this.getHeaders()
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const searchData = await response.text();
logger.log(`[HDRezka] Search response length: ${searchData.length}`);
const movieData: Array<{
id: string;
year: number;
type: string;
url: string;
title: string;
}> = [];
let match;
while ((match = itemRegexPattern.exec(searchData)) !== null) {
const url = match[1];
const titleAndYear = match[3];
const result = this.extractTitleAndYear(titleAndYear);
if (result !== null) {
const id = url.match(idRegexPattern)?.[1] || null;
const isMovie = url.includes('/films/');
const isShow = url.includes('/series/');
const type = isMovie ? 'movie' : isShow ? 'show' : 'unknown';
movieData.push({
id: id ?? '',
year: result.year ?? 0,
type,
url,
title: match[2]
});
logger.log(`[HDRezka] Found: id=${id}, title=${match[2]}, type=${type}, year=${result.year}`);
}
}
// If year is provided, filter by year
let filteredItems = movieData;
if (media.releaseYear) {
filteredItems = movieData.filter(item => item.year === media.releaseYear);
logger.log(`[HDRezka] Items filtered by year ${media.releaseYear}: ${filteredItems.length}`);
}
// If type is provided, filter by type
if (media.type) {
filteredItems = filteredItems.filter(item => item.type === media.type);
logger.log(`[HDRezka] Items filtered by type ${media.type}: ${filteredItems.length}`);
}
if (filteredItems.length > 0) {
logger.log(`[HDRezka] Selected item: id=${filteredItems[0].id}, title=${filteredItems[0].title}`);
return filteredItems[0];
} else if (movieData.length > 0) {
logger.log(`[HDRezka] No exact match, using first result: id=${movieData[0].id}, title=${movieData[0].title}`);
return movieData[0];
} else {
logger.log(`[HDRezka] No matching items found`);
return null;
}
} catch (error) {
logger.error(`[HDRezka] Search request failed: ${error}`);
return null;
}
}
async getTranslatorId(url: string, id: string, mediaType: string): Promise<string | null> {
logger.log(`[HDRezka] Getting translator ID for url=${url}, id=${id}`);
// Make sure the URL is absolute
const fullUrl = url.startsWith('http') ? url : `${REZKA_BASE}${url.startsWith('/') ? url.substring(1) : url}`;
logger.log(`[HDRezka] Making request to: ${fullUrl}`);
try {
const response = await fetchImpl(fullUrl, {
method: 'GET',
headers: this.getHeaders(),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const responseText = await response.text();
logger.log(`[HDRezka] Translator page response length: ${responseText.length}`);
// 1. Check for "Original + Subtitles" specific ID (often ID 238)
if (responseText.includes(`data-translator_id="238"`)) {
logger.log(`[HDRezka] Found specific translator ID 238 (Original + subtitles)`);
return '238';
}
// 2. Try to extract from the main CDN init function (e.g., initCDNMoviesEvents, initCDNSeriesEvents)
const functionName = mediaType === 'movie' ? 'initCDNMoviesEvents' : 'initCDNSeriesEvents';
const cdnEventsRegex = new RegExp(`sof\.tv\.${functionName}\(${id}, ([^,]+)`, 'i');
const cdnEventsMatch = responseText.match(cdnEventsRegex);
if (cdnEventsMatch && cdnEventsMatch[1]) {
const translatorIdFromCdn = cdnEventsMatch[1].trim().replace(/['"]/g, ''); // Remove potential quotes
if (translatorIdFromCdn && translatorIdFromCdn !== 'false' && translatorIdFromCdn !== 'null') {
logger.log(`[HDRezka] Extracted translator ID from CDN init: ${translatorIdFromCdn}`);
return translatorIdFromCdn;
}
}
logger.log(`[HDRezka] CDN init function did not yield a valid translator ID.`);
// 3. Fallback: Try to find any other data-translator_id attribute in the HTML
// This regex looks for data-translator_id="<digits>"
const anyTranslatorRegex = /data-translator_id="(\d+)"/;
const anyTranslatorMatch = responseText.match(anyTranslatorRegex);
if (anyTranslatorMatch && anyTranslatorMatch[1]) {
const fallbackTranslatorId = anyTranslatorMatch[1].trim();
logger.log(`[HDRezka] Found fallback translator ID from data attribute: ${fallbackTranslatorId}`);
return fallbackTranslatorId;
}
logger.log(`[HDRezka] No fallback data-translator_id found.`);
// If all attempts fail
logger.log(`[HDRezka] Could not find any translator ID for id ${id} on page ${fullUrl}`);
return null;
} catch (error) {
logger.error(`[HDRezka] Failed to get translator ID: ${error}`);
return null;
}
}
async getStream(id: string, translatorId: string, media: {
type: string;
season?: { number: number };
episode?: { number: number };
}): Promise<any> {
logger.log(`[HDRezka] Getting stream for id=${id}, translatorId=${translatorId}`);
const searchParams = new URLSearchParams();
searchParams.append('id', id);
searchParams.append('translator_id', translatorId);
if (media.type === 'show' && media.season && media.episode) {
searchParams.append('season', media.season.number.toString());
searchParams.append('episode', media.episode.number.toString());
logger.log(`[HDRezka] Show params: season=${media.season.number}, episode=${media.episode.number}`);
}
const randomFavs = this.generateRandomFavs();
searchParams.append('favs', randomFavs);
searchParams.append('action', media.type === 'show' ? 'get_stream' : 'get_movie');
const fullUrl = `${REZKA_BASE}ajax/get_cdn_series/`;
logger.log(`[HDRezka] Making stream request to: ${fullUrl} with action=${media.type === 'show' ? 'get_stream' : 'get_movie'}`);
let attempts = 0;
const maxAttempts = 3;
while (attempts < maxAttempts) {
attempts++;
try {
// Log the request details
logger.log('[HDRezka][AXIOS DEBUG]', {
url: fullUrl,
method: 'POST',
headers: this.getHeaders(),
data: searchParams.toString()
});
const axiosResponse = await axios.post(fullUrl, searchParams.toString(), {
headers: {
...this.getHeaders(),
'Content-Type': 'application/x-www-form-urlencoded',
},
validateStatus: () => true,
});
logger.log('[HDRezka][AXIOS RESPONSE]', {
status: axiosResponse.status,
headers: axiosResponse.headers,
data: axiosResponse.data
});
if (axiosResponse.status !== 200) {
throw new Error(`HTTP error! status: ${axiosResponse.status}`);
}
const responseText = typeof axiosResponse.data === 'string' ? axiosResponse.data : JSON.stringify(axiosResponse.data);
logger.log(`[HDRezka] Stream response length: ${responseText.length}`);
try {
const parsedResponse = typeof axiosResponse.data === 'object' ? axiosResponse.data : JSON.parse(responseText);
logger.log(`[HDRezka] Parsed response successfully: ${JSON.stringify(parsedResponse)}`);
if (!parsedResponse.success && parsedResponse.message) {
logger.error(`[HDRezka] Server returned error: ${parsedResponse.message}`);
if (attempts < maxAttempts) {
logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`);
continue;
}
return null;
}
const qualities = this.parseVideoLinks(parsedResponse.url);
const captions = this.parseSubtitles(parsedResponse.subtitle);
return {
qualities,
captions
};
} catch (e: unknown) {
const error = e instanceof Error ? e.message : String(e);
logger.error(`[HDRezka] Failed to parse JSON response: ${error}`);
if (attempts < maxAttempts) {
logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`);
continue;
}
return null;
}
} catch (error) {
logger.error(`[HDRezka] Stream request failed: ${error}`);
if (attempts < maxAttempts) {
logger.log(`[HDRezka] Retrying stream request (attempt ${attempts + 1}/${maxAttempts})...`);
continue;
}
return null;
}
}
logger.error(`[HDRezka] All stream request attempts failed`);
return null;
}
async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> {
try {
logger.log(`[HDRezka] Getting streams for ${mediaType} with ID: ${mediaId}`);
// Check if internal providers are enabled globally
const appSettingsJson = await AsyncStorage.getItem('app_settings');
if (appSettingsJson) {
const appSettings = JSON.parse(appSettingsJson);
if (appSettings.enableInternalProviders === false) {
logger.log('[HDRezka] Internal providers are disabled in settings, skipping HDRezka');
return [];
}
}
// Check if HDRezka specifically is enabled
const hdrezkaSettingsJson = await AsyncStorage.getItem('hdrezka_settings');
if (hdrezkaSettingsJson) {
const hdrezkaSettings = JSON.parse(hdrezkaSettingsJson);
if (hdrezkaSettings.enabled === false) {
logger.log('[HDRezka] HDRezka provider is disabled in settings, skipping HDRezka');
return [];
}
}
// First, extract the actual title from TMDB if this is an ID
let title = mediaId;
let year: number | undefined = undefined;
if (mediaId.startsWith('tt') || mediaId.startsWith('tmdb:')) {
let tmdbId: number | null = null;
// Handle IMDB IDs
if (mediaId.startsWith('tt')) {
logger.log(`[HDRezka] Converting IMDB ID to TMDB ID: ${mediaId}`);
tmdbId = await tmdbService.findTMDBIdByIMDB(mediaId);
}
// Handle TMDB IDs
else if (mediaId.startsWith('tmdb:')) {
tmdbId = parseInt(mediaId.split(':')[1], 10);
}
if (tmdbId) {
// Fetch metadata from TMDB API
if (mediaType === 'movie') {
logger.log(`[HDRezka] Fetching movie details from TMDB for ID: ${tmdbId}`);
const movieDetails = await tmdbService.getMovieDetails(tmdbId.toString());
if (movieDetails) {
title = movieDetails.title;
year = movieDetails.release_date ? parseInt(movieDetails.release_date.substring(0, 4), 10) : undefined;
logger.log(`[HDRezka] Using movie title "${title}" (${year}) for search`);
}
} else {
logger.log(`[HDRezka] Fetching TV show details from TMDB for ID: ${tmdbId}`);
const showDetails = await tmdbService.getTVShowDetails(tmdbId);
if (showDetails) {
title = showDetails.name;
year = showDetails.first_air_date ? parseInt(showDetails.first_air_date.substring(0, 4), 10) : undefined;
logger.log(`[HDRezka] Using TV show title "${title}" (${year}) for search`);
}
}
}
}
const media = {
title,
type: mediaType === 'movie' ? 'movie' : 'show',
releaseYear: year
};
// Step 1: Search and find media ID
const searchResult = await this.searchAndFindMediaId(media);
if (!searchResult || !searchResult.id) {
logger.log('[HDRezka] No search results found');
return [];
}
// Step 2: Get translator ID
const translatorId = await this.getTranslatorId(
searchResult.url,
searchResult.id,
media.type
);
if (!translatorId) {
logger.log('[HDRezka] No translator ID found');
return [];
}
// Step 3: Get stream
const streamParams = {
type: media.type,
season: season ? { number: season } : undefined,
episode: episode ? { number: episode } : undefined
};
const streamData = await this.getStream(searchResult.id, translatorId, streamParams);
if (!streamData) {
logger.log('[HDRezka] No stream data found');
return [];
}
// Convert to Stream format
const streams: Stream[] = [];
Object.entries(streamData.qualities).forEach(([quality, data]: [string, any]) => {
streams.push({
name: 'HDRezka',
title: quality,
url: data.url,
behaviorHints: {
notWebReady: false
}
});
});
logger.log(`[HDRezka] Found ${streams.length} streams`);
return streams;
} catch (error) {
logger.error(`[HDRezka] Error getting streams: ${error}`);
return [];
}
}
}
export const hdrezkaService = new HDRezkaService();

View file

@ -0,0 +1,137 @@
import { logger } from '../utils/logger';
interface CachedImage {
url: string;
localPath: string;
timestamp: number;
expiresAt: number;
}
class ImageCacheService {
private cache = new Map<string, CachedImage>();
private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images
/**
* Get a cached image URL or cache the original if not present
*/
public async getCachedImageUrl(originalUrl: string): Promise<string> {
if (!originalUrl || originalUrl.includes('placeholder')) {
return originalUrl; // Don't cache placeholder images
}
// Check if we have a valid cached version
const cached = this.cache.get(originalUrl);
if (cached && cached.expiresAt > Date.now()) {
logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`);
return cached.localPath;
}
try {
// For now, return the original URL but mark it as cached
// In a production app, you would implement actual local caching here
const cachedImage: CachedImage = {
url: originalUrl,
localPath: originalUrl, // In production, this would be a local file path
timestamp: Date.now(),
expiresAt: Date.now() + this.CACHE_DURATION,
};
this.cache.set(originalUrl, cachedImage);
this.enforceMaxCacheSize();
logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Cache size: ${this.cache.size})`);
return cachedImage.localPath;
} catch (error) {
logger.error('[ImageCache] Failed to cache image:', error);
return originalUrl; // Fallback to original URL
}
}
/**
* Check if an image is cached
*/
public isCached(url: string): boolean {
const cached = this.cache.get(url);
return cached !== undefined && cached.expiresAt > Date.now();
}
/**
* Log cache status (for debugging)
*/
public logCacheStatus(): void {
const stats = this.getCacheStats();
logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`);
// Log first 5 cached URLs for debugging
const entries = Array.from(this.cache.entries()).slice(0, 5);
entries.forEach(([url, cached]) => {
const isExpired = cached.expiresAt <= Date.now();
const timeLeft = Math.max(0, cached.expiresAt - Date.now()) / 1000 / 60; // minutes
logger.log(`[ImageCache] - ${url.substring(0, 60)}... (${isExpired ? 'EXPIRED' : `${timeLeft.toFixed(1)}m left`})`);
});
}
/**
* Clear expired cache entries
*/
public clearExpiredCache(): void {
const now = Date.now();
for (const [url, cached] of this.cache.entries()) {
if (cached.expiresAt <= now) {
this.cache.delete(url);
}
}
}
/**
* Clear all cached images
*/
public clearAllCache(): void {
this.cache.clear();
logger.log('[ImageCache] Cleared all cached images');
}
/**
* Get cache statistics
*/
public getCacheStats(): { size: number; expired: number } {
const now = Date.now();
let expired = 0;
for (const cached of this.cache.values()) {
if (cached.expiresAt <= now) {
expired++;
}
}
return {
size: this.cache.size,
expired,
};
}
/**
* Enforce maximum cache size by removing oldest entries
*/
private enforceMaxCacheSize(): void {
if (this.cache.size <= this.MAX_CACHE_SIZE) {
return;
}
// Convert to array and sort by timestamp (oldest first)
const entries = Array.from(this.cache.entries()).sort(
(a, b) => a[1].timestamp - b[1].timestamp
);
// Remove oldest entries
const toRemove = this.cache.size - this.MAX_CACHE_SIZE;
for (let i = 0; i < toRemove; i++) {
this.cache.delete(entries[i][0]);
}
logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`);
}
}
export const imageCacheService = new ImageCacheService();

View file

@ -5,6 +5,9 @@ interface WatchProgress {
currentTime: number; currentTime: number;
duration: number; duration: number;
lastUpdated: number; lastUpdated: number;
traktSynced?: boolean;
traktLastSynced?: number;
traktProgress?: number;
} }
class StorageService { class StorageService {
@ -103,6 +106,142 @@ class StorageService {
return {}; return {};
} }
} }
/**
* Update Trakt sync status for a watch progress entry
*/
public async updateTraktSyncStatus(
id: string,
type: string,
traktSynced: boolean,
traktProgress?: number,
episodeId?: string
): Promise<void> {
try {
const existingProgress = await this.getWatchProgress(id, type, episodeId);
if (existingProgress) {
const updatedProgress: WatchProgress = {
...existingProgress,
traktSynced,
traktLastSynced: traktSynced ? Date.now() : existingProgress.traktLastSynced,
traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
}
} catch (error) {
logger.error('Error updating Trakt sync status:', error);
}
}
/**
* Get all watch progress entries that need Trakt sync
*/
public async getUnsyncedProgress(): Promise<Array<{
key: string;
id: string;
type: string;
episodeId?: string;
progress: WatchProgress;
}>> {
try {
const allProgress = await this.getAllWatchProgress();
const unsynced: Array<{
key: string;
id: string;
type: string;
episodeId?: string;
progress: WatchProgress;
}> = [];
for (const [key, progress] of Object.entries(allProgress)) {
// Check if needs sync (either never synced or local progress is newer)
const needsSync = !progress.traktSynced ||
(progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced);
if (needsSync) {
const parts = key.split(':');
const type = parts[0];
const id = parts[1];
const episodeId = parts[2] || undefined;
unsynced.push({
key,
id,
type,
episodeId,
progress
});
}
}
return unsynced;
} catch (error) {
logger.error('Error getting unsynced progress:', error);
return [];
}
}
/**
* Merge Trakt progress with local progress
*/
public async mergeWithTraktProgress(
id: string,
type: string,
traktProgress: number,
traktPausedAt: string,
episodeId?: string
): Promise<void> {
try {
const localProgress = await this.getWatchProgress(id, type, episodeId);
const traktTimestamp = new Date(traktPausedAt).getTime();
if (!localProgress) {
// No local progress, use Trakt data (estimate duration)
const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; // Default 1 hour
const newProgress: WatchProgress = {
currentTime: (traktProgress / 100) * estimatedDuration,
duration: estimatedDuration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, newProgress, episodeId);
} else {
// Always prioritize Trakt progress when merging
const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100;
if (localProgress.duration > 0) {
// Use Trakt progress, keeping the existing duration
const updatedProgress: WatchProgress = {
...localProgress,
currentTime: (traktProgress / 100) * localProgress.duration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%)`);
} else {
// If no duration, estimate it from Trakt progress
const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600;
const updatedProgress: WatchProgress = {
currentTime: (traktProgress / 100) * estimatedDuration,
duration: estimatedDuration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%) - estimated duration`);
}
}
} catch (error) {
logger.error('Error merging with Trakt progress:', error);
}
}
} }
export const storageService = StorageService.getInstance(); export const storageService = StorageService.getInstance();

View file

@ -26,9 +26,35 @@ export interface Meta {
genres?: string[]; genres?: string[];
runtime?: string; runtime?: string;
cast?: string[]; cast?: string[];
director?: string; director?: string | string[];
writer?: string; writer?: string | string[];
certification?: string; certification?: string;
// Extended fields available from some addons
country?: string;
imdb_id?: string;
slug?: string;
released?: string;
trailerStreams?: Array<{
title: string;
ytId: string;
}>;
links?: Array<{
name: string;
category: string;
url: string;
}>;
behaviorHints?: {
defaultVideoId?: string;
hasScheduledVideos?: boolean;
[key: string]: any;
};
app_extras?: {
cast?: Array<{
name: string;
character?: string;
photo?: string;
}>;
};
} }
export interface Subtitle { export interface Subtitle {
@ -379,17 +405,20 @@ class StremioService {
return result; return result;
} }
private getAddonBaseURL(url: string): string { private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
// Remove trailing manifest.json if present // Extract query parameters if they exist
let baseUrl = url.replace(/manifest\.json$/, '').replace(/\/$/, ''); const [baseUrl, queryString] = url.split('?');
// Remove trailing manifest.json and slashes
let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
// Ensure URL has protocol // Ensure URL has protocol
if (!baseUrl.startsWith('http')) { if (!cleanBaseUrl.startsWith('http')) {
baseUrl = `https://${baseUrl}`; cleanBaseUrl = `https://${cleanBaseUrl}`;
} }
logger.log('Addon base URL:', baseUrl); logger.log('Addon base URL:', cleanBaseUrl, queryString ? `with query: ${queryString}` : '');
return baseUrl; return { baseUrl: cleanBaseUrl, queryParams: queryString };
} }
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> { async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
@ -412,14 +441,11 @@ class StremioService {
}); });
} }
logger.log(`Cinemeta catalog request URL: ${url}`);
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(url); return await axios.get(url);
}); });
if (response.data && response.data.metas && Array.isArray(response.data.metas)) { if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
logger.log(`Cinemeta returned ${response.data.metas.length} items`);
return response.data.metas; return response.data.metas;
} }
return []; return [];
@ -431,7 +457,7 @@ class StremioService {
} }
try { try {
const baseUrl = this.getAddonBaseURL(manifest.url); const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
// Build the catalog URL // Build the catalog URL
let url = `${baseUrl}/catalog/${type}/${id}.json`; let url = `${baseUrl}/catalog/${type}/${id}.json`;
@ -450,14 +476,11 @@ class StremioService {
}); });
} }
logger.log(`${manifest.name} catalog request URL: ${url}`);
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(url); return await axios.get(url);
}); });
if (response.data && response.data.metas && Array.isArray(response.data.metas)) { if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
logger.log(`${manifest.name} returned ${response.data.metas.length} items`);
return response.data.metas; return response.data.metas;
} }
return []; return [];
@ -467,8 +490,71 @@ class StremioService {
} }
} }
async getMetaDetails(type: string, id: string): Promise<MetaDetails | null> { async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null> {
try { try {
const addons = this.getInstalledAddons();
// If a preferred addon is specified, try it first
if (preferredAddonId) {
logger.log(`🎯 Trying preferred addon first: ${preferredAddonId}`);
const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
if (preferredAddon && preferredAddon.resources) {
// Log what URL would be used for debugging
const { baseUrl, queryParams } = this.getAddonBaseURL(preferredAddon.url || '');
const wouldBeUrl = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`;
logger.log(`🔍 Would check URL: ${wouldBeUrl} (addon: ${preferredAddon.name})`);
// Log addon resources for debugging
logger.log(`🔍 Addon resources:`, JSON.stringify(preferredAddon.resources, null, 2));
// Check if addon supports meta resource for this type
let hasMetaSupport = false;
for (const resource of preferredAddon.resources) {
// Check if the current element is a ResourceObject
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject;
if (typedResource.name === 'meta' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
hasMetaSupport = true;
break;
}
}
// Check if the element is the simple string "meta" AND the addon has a top-level types array
else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) {
if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) {
hasMetaSupport = true;
break;
}
}
}
logger.log(`🔍 Meta support check: ${hasMetaSupport} (addon types: ${JSON.stringify(preferredAddon.types)})`);
if (hasMetaSupport) {
try {
logger.log(`HTTP GET: ${wouldBeUrl} (preferred addon: ${preferredAddon.name})`);
const response = await this.retryRequest(async () => {
return await axios.get(wouldBeUrl, { timeout: 10000 });
});
if (response.data && response.data.meta) {
logger.log(`✅ Metadata fetched successfully from preferred addon: ${wouldBeUrl}`);
return response.data.meta;
}
} catch (error) {
logger.warn(`❌ Failed to fetch meta from preferred addon ${preferredAddon.name}:`, error);
}
} else {
logger.warn(`⚠️ Preferred addon ${preferredAddonId} does not support meta for type ${type}`);
}
} else {
logger.warn(`⚠️ Preferred addon ${preferredAddonId} not found or has no resources`);
}
}
// Try Cinemeta with different base URLs // Try Cinemeta with different base URLs
const cinemetaUrls = [ const cinemetaUrls = [
'https://v3-cinemeta.strem.io', 'https://v3-cinemeta.strem.io',
@ -478,44 +564,66 @@ class StremioService {
for (const baseUrl of cinemetaUrls) { for (const baseUrl of cinemetaUrls) {
try { try {
const url = `${baseUrl}/meta/${type}/${id}.json`; const url = `${baseUrl}/meta/${type}/${id}.json`;
logger.log(`HTTP GET: ${url}`);
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 }); return await axios.get(url, { timeout: 10000 });
}); });
if (response.data && response.data.meta) { if (response.data && response.data.meta) {
logger.log(`✅ Metadata fetched successfully from: ${url}`);
return response.data.meta; return response.data.meta;
} }
} catch (error) { } catch (error) {
logger.warn(`Failed to fetch meta from ${baseUrl}:`, error); logger.warn(`Failed to fetch meta from ${baseUrl}:`, error);
continue; // Try next URL continue; // Try next URL
} }
} }
// If Cinemeta fails, try other addons // If Cinemeta fails, try other addons (excluding the preferred one already tried)
const addons = this.getInstalledAddons();
for (const addon of addons) { for (const addon of addons) {
if (!addon.resources || addon.id === 'com.linvo.cinemeta') continue; if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue;
const metaResource = addon.resources.find( // Check if addon supports meta resource for this type (handles both string and object formats)
resource => resource.name === 'meta' && resource.types.includes(type) let hasMetaSupport = false;
);
if (!metaResource) continue; for (const resource of addon.resources) {
// Check if the current element is a ResourceObject
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
const typedResource = resource as ResourceObject;
if (typedResource.name === 'meta' &&
Array.isArray(typedResource.types) &&
typedResource.types.includes(type)) {
hasMetaSupport = true;
break;
}
}
// Check if the element is the simple string "meta" AND the addon has a top-level types array
else if (typeof resource === 'string' && resource === 'meta' && addon.types) {
if (Array.isArray(addon.types) && addon.types.includes(type)) {
hasMetaSupport = true;
break;
}
}
}
if (!hasMetaSupport) continue;
try { try {
const baseUrl = this.getAddonBaseURL(addon.url || ''); const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
const url = `${baseUrl}/meta/${type}/${id}.json`; const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`;
logger.log(`HTTP GET: ${url}`);
const response = await this.retryRequest(async () => { const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 }); return await axios.get(url, { timeout: 10000 });
}); });
if (response.data && response.data.meta) { if (response.data && response.data.meta) {
logger.log(`✅ Metadata fetched successfully from: ${url}`);
return response.data.meta; return response.data.meta;
} }
} catch (error) { } catch (error) {
logger.warn(`Failed to fetch meta from ${addon.name}:`, error); logger.warn(`Failed to fetch meta from ${addon.name} (${addon.id}):`, error);
continue; // Try next addon continue; // Try next addon
} }
} }
@ -612,8 +720,8 @@ class StremioService {
return; return;
} }
const baseUrl = this.getAddonBaseURL(addon.url); const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
const url = `${baseUrl}/stream/${type}/${id}.json`; const url = queryParams ? `${baseUrl}/stream/${type}/${id}.json?${queryParams}` : `${baseUrl}/stream/${type}/${id}.json`;
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`); logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
@ -656,8 +764,9 @@ class StremioService {
return null; return null;
} }
const baseUrl = this.getAddonBaseURL(addon.url); const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
const url = `${baseUrl}/stream/${type}/${id}.json`; const streamPath = `/stream/${type}/${id}.json`;
const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`;
logger.log(`Fetching streams from URL: ${url}`); logger.log(`Fetching streams from URL: ${url}`);
@ -671,7 +780,7 @@ class StremioService {
timeout, timeout,
headers: { headers: {
'Accept': 'application/json', 'Accept': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' 'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
} }
}); });
}, 5); // Increase retries for stream fetching }, 5); // Increase retries for stream fetching
@ -868,7 +977,7 @@ class StremioService {
} }
try { try {
const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || ''); const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || '').baseUrl;
// Construct the query URL with the correct format // Construct the query URL with the correct format
// For series episodes, use the videoId directly which includes series ID + episode info // For series episodes, use the videoId directly which includes series ID + episode info
@ -930,6 +1039,29 @@ class StremioService {
} }
return false; return false;
} }
// Check if any installed addons can provide streams
async hasStreamProviders(): Promise<boolean> {
await this.ensureInitialized();
const addons = Array.from(this.installedAddons.values());
for (const addon of addons) {
if (addon.resources && Array.isArray(addon.resources)) {
// Check for 'stream' resource in the modern format
const hasStreamResource = addon.resources.some(resource =>
typeof resource === 'string'
? resource === 'stream'
: resource.name === 'stream'
);
if (hasStreamResource) {
return true;
}
}
}
return false;
}
} }
export const stremioService = StremioService.getInstance(); export const stremioService = StremioService.getInstance();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,298 @@
import { logger } from '../utils/logger';
import { Stream } from '../types/metadata';
import { tmdbService } from './tmdbService';
import axios from 'axios';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as FileSystem from 'expo-file-system';
import * as Crypto from 'expo-crypto';
// Use node-fetch if available, otherwise fallback to global fetch
let fetchImpl: typeof fetch;
try {
// @ts-ignore
fetchImpl = require('node-fetch');
} catch {
fetchImpl = fetch;
}
// Constants
const MAX_RETRIES_XPRIME = 3;
const RETRY_DELAY_MS_XPRIME = 1000;
// Use app's cache directory for React Native
const CACHE_DIR = `${FileSystem.cacheDirectory}xprime/`;
const BROWSER_HEADERS_XPRIME = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36',
'Accept': '*/*',
'Accept-Language': 'en-GB,en-US;q=0.9,en;q=0.8',
'Cache-Control': 'no-cache',
'Pragma': 'no-cache',
'Sec-Ch-Ua': '"Chromium";v="136", "Google Chrome";v="136", "Not.A/Brand";v="99"',
'Sec-Ch-Ua-Mobile': '?0',
'Sec-Ch-Ua-Platform': '"Windows"',
'Connection': 'keep-alive'
};
interface XprimeStream {
url: string;
quality: string;
title: string;
provider: string;
codecs: string[];
size: string;
}
class XprimeService {
private MAX_RETRIES = 3;
private RETRY_DELAY = 1000; // 1 second
// Ensure cache directories exist
private async ensureCacheDir(dirPath: string) {
try {
const dirInfo = await FileSystem.getInfoAsync(dirPath);
if (!dirInfo.exists) {
await FileSystem.makeDirectoryAsync(dirPath, { intermediates: true });
}
} catch (error) {
logger.error(`[XPRIME] Warning: Could not create cache directory ${dirPath}:`, error);
}
}
// Cache helpers
private async getFromCache(cacheKey: string, subDir: string = ''): Promise<any> {
try {
const fullPath = `${CACHE_DIR}${subDir}/${cacheKey}`;
const fileInfo = await FileSystem.getInfoAsync(fullPath);
if (fileInfo.exists) {
const data = await FileSystem.readAsStringAsync(fullPath);
logger.log(`[XPRIME] CACHE HIT for: ${subDir}/${cacheKey}`);
try {
return JSON.parse(data);
} catch (e) {
return data;
}
}
return null;
} catch (error) {
logger.error(`[XPRIME] CACHE READ ERROR for ${cacheKey}:`, error);
return null;
}
}
private async saveToCache(cacheKey: string, content: any, subDir: string = '') {
try {
const fullSubDir = `${CACHE_DIR}${subDir}/`;
await this.ensureCacheDir(fullSubDir);
const fullPath = `${fullSubDir}${cacheKey}`;
const dataToSave = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
await FileSystem.writeAsStringAsync(fullPath, dataToSave);
logger.log(`[XPRIME] SAVED TO CACHE: ${subDir}/${cacheKey}`);
} catch (error) {
logger.error(`[XPRIME] CACHE WRITE ERROR for ${cacheKey}:`, error);
}
}
// Helper function to fetch stream size using a HEAD request
private async fetchStreamSize(url: string): Promise<string> {
const cacheSubDir = 'xprime_stream_sizes';
// Create a hash of the URL to use as the cache key
const urlHash = await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.MD5,
url,
{ encoding: Crypto.CryptoEncoding.HEX }
);
const urlCacheKey = `${urlHash}.txt`;
const cachedSize = await this.getFromCache(urlCacheKey, cacheSubDir);
if (cachedSize !== null) {
return cachedSize;
}
try {
// For m3u8, Content-Length is for the playlist file, not the stream segments
if (url.toLowerCase().includes('.m3u8')) {
await this.saveToCache(urlCacheKey, 'Playlist (size N/A)', cacheSubDir);
return 'Playlist (size N/A)';
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5-second timeout
try {
const response = await fetchImpl(url, {
method: 'HEAD',
signal: controller.signal
});
clearTimeout(timeoutId);
const contentLength = response.headers.get('content-length');
if (contentLength) {
const sizeInBytes = parseInt(contentLength, 10);
if (!isNaN(sizeInBytes)) {
let formattedSize;
if (sizeInBytes < 1024) formattedSize = `${sizeInBytes} B`;
else if (sizeInBytes < 1024 * 1024) formattedSize = `${(sizeInBytes / 1024).toFixed(2)} KB`;
else if (sizeInBytes < 1024 * 1024 * 1024) formattedSize = `${(sizeInBytes / (1024 * 1024)).toFixed(2)} MB`;
else formattedSize = `${(sizeInBytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
await this.saveToCache(urlCacheKey, formattedSize, cacheSubDir);
return formattedSize;
}
}
await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir);
return 'Unknown size';
} finally {
clearTimeout(timeoutId);
}
} catch (error) {
logger.error(`[XPRIME] Could not fetch size for ${url.substring(0, 50)}...`, error);
await this.saveToCache(urlCacheKey, 'Unknown size', cacheSubDir);
return 'Unknown size';
}
}
private async fetchWithRetry(url: string, options: any, maxRetries: number = MAX_RETRIES_XPRIME) {
let lastError;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetchImpl(url, options);
if (!response.ok) {
let errorBody = '';
try {
errorBody = await response.text();
} catch (e) {
// ignore
}
const httpError = new Error(`HTTP error! Status: ${response.status} ${response.statusText}. Body: ${errorBody.substring(0, 200)}`);
(httpError as any).status = response.status;
throw httpError;
}
return response;
} catch (error: any) {
lastError = error;
logger.error(`[XPRIME] Fetch attempt ${attempt}/${maxRetries} failed for ${url}:`, error);
// If it's a 403 error, stop retrying immediately
if (error.status === 403) {
logger.log(`[XPRIME] Encountered 403 Forbidden for ${url}. Halting retries.`);
throw lastError;
}
if (attempt < maxRetries) {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS_XPRIME * Math.pow(2, attempt - 1)));
}
}
}
logger.error(`[XPRIME] All fetch attempts failed for ${url}. Last error:`, lastError);
if (lastError) throw lastError;
else throw new Error(`[XPRIME] All fetch attempts failed for ${url} without a specific error captured.`);
}
async getStreams(mediaId: string, mediaType: string, season?: number, episode?: number): Promise<Stream[]> {
// XPRIME service has been removed from internal providers
logger.log('[XPRIME] Service has been removed from internal providers');
return [];
}
private async getXprimeStreams(title: string, year: number, type: string, seasonNum?: number, episodeNum?: number): Promise<XprimeStream[]> {
let rawXprimeStreams: XprimeStream[] = [];
try {
logger.log(`[XPRIME] Fetch attempt for '${title}' (${year}). Type: ${type}, S: ${seasonNum}, E: ${episodeNum}`);
const xprimeName = encodeURIComponent(title);
let xprimeApiUrl: string;
// type here is tmdbTypeFromId which is 'movie' or 'tv'/'series'
if (type === 'movie') {
xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}`;
} else if (type === 'tv' || type === 'series') { // Accept both 'tv' and 'series' for compatibility
if (seasonNum !== null && seasonNum !== undefined && episodeNum !== null && episodeNum !== undefined) {
xprimeApiUrl = `https://backend.xprime.tv/primebox?name=${xprimeName}&year=${year}&fallback_year=${year}&season=${seasonNum}&episode=${episodeNum}`;
} else {
logger.log('[XPRIME] Skipping series request: missing season/episode numbers.');
return [];
}
} else {
logger.log(`[XPRIME] Skipping request: unknown type '${type}'.`);
return [];
}
let xprimeResult: any;
// Direct fetch only
logger.log(`[XPRIME] Fetching directly: ${xprimeApiUrl}`);
const xprimeResponse = await this.fetchWithRetry(xprimeApiUrl, {
headers: {
...BROWSER_HEADERS_XPRIME,
'Origin': 'https://pstream.org',
'Referer': 'https://pstream.org/',
'Sec-Fetch-Mode': 'cors',
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Dest': 'empty'
}
});
xprimeResult = await xprimeResponse.json();
// Process the result
this.processXprimeResult(xprimeResult, rawXprimeStreams, title, type, seasonNum, episodeNum);
// Fetch stream sizes concurrently for all Xprime streams
if (rawXprimeStreams.length > 0) {
logger.log('[XPRIME] Fetching stream sizes...');
const sizePromises = rawXprimeStreams.map(async (stream) => {
stream.size = await this.fetchStreamSize(stream.url);
return stream;
});
await Promise.all(sizePromises);
logger.log(`[XPRIME] Found ${rawXprimeStreams.length} streams with sizes.`);
}
return rawXprimeStreams;
} catch (xprimeError) {
logger.error('[XPRIME] Error fetching or processing streams:', xprimeError);
return [];
}
}
// Helper function to process Xprime API response
private processXprimeResult(xprimeResult: any, rawXprimeStreams: XprimeStream[], title: string, type: string, seasonNum?: number, episodeNum?: number) {
const processXprimeItem = (item: any) => {
if (item && typeof item === 'object' && !item.error && item.streams && typeof item.streams === 'object') {
Object.entries(item.streams).forEach(([quality, fileUrl]) => {
if (fileUrl && typeof fileUrl === 'string') {
rawXprimeStreams.push({
url: fileUrl,
quality: quality || 'Unknown',
title: `${title} - ${(type === 'tv' || type === 'series') ? `S${String(seasonNum).padStart(2,'0')}E${String(episodeNum).padStart(2,'0')} ` : ''}${quality}`,
provider: 'XPRIME',
codecs: [],
size: 'Unknown size'
});
}
});
} else {
logger.log('[XPRIME] Skipping item due to missing/invalid streams or an error was reported by Xprime API:', item && item.error);
}
};
if (Array.isArray(xprimeResult)) {
xprimeResult.forEach(processXprimeItem);
} else if (xprimeResult) {
processXprimeItem(xprimeResult);
} else {
logger.log('[XPRIME] No result from Xprime API to process.');
}
}
}
export const xprimeService = new XprimeService();

View file

@ -1,7 +1,32 @@
import { StyleSheet, Dimensions, Platform } from 'react-native'; import { StyleSheet, Dimensions, Platform } from 'react-native';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
export const POSTER_WIDTH = (width - 50) / 3;
// Dynamic poster calculation based on screen width
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability
const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters
const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins
// Calculate how many posters can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
// Limit to reasonable number of columns (3-6)
const numColumns = Math.min(Math.max(maxColumns, 3), 6);
// 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);
export const POSTER_WIDTH = posterLayout.posterWidth;
export const POSTER_HEIGHT = POSTER_WIDTH * 1.5; export const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
export const HORIZONTAL_PADDING = 16; export const HORIZONTAL_PADDING = 16;

View file

@ -55,6 +55,7 @@ const useDiscoverStyles = () => {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingTop: 80, paddingTop: 80,
paddingBottom: 90,
}, },
emptyText: { emptyText: {
color: currentTheme.colors.mediumGray, color: currentTheme.colors.mediumGray,

61
src/testHDRezka.js Normal file
View file

@ -0,0 +1,61 @@
// Test script for HDRezka service
const { hdrezkaService } = require('./services/hdrezkaService');
// Enable more detailed console logging
const originalConsoleLog = console.log;
console.log = function(...args) {
const timestamp = new Date().toISOString();
originalConsoleLog(`[${timestamp}]`, ...args);
};
// Test function to get streams from HDRezka
async function testHDRezka() {
console.log('Testing HDRezka service...');
// Test a popular movie - "Deadpool & Wolverine" (2024)
const movieId = 'tt6263850';
console.log(`Testing movie ID: ${movieId}`);
try {
const streams = await hdrezkaService.getStreams(movieId, 'movie');
console.log('Streams found:', streams.length);
if (streams.length > 0) {
console.log('First stream:', {
name: streams[0].name,
title: streams[0].title,
url: streams[0].url.substring(0, 100) + '...' // Only show part of the URL
});
} else {
console.log('No streams found.');
}
} catch (error) {
console.error('Error testing HDRezka:', error);
}
// Test a TV show - "House of the Dragon" with a specific episode
const showId = 'tt11198330';
console.log(`\nTesting TV show ID: ${showId}, Season 2 Episode 1`);
try {
const streams = await hdrezkaService.getStreams(showId, 'series', 2, 1);
console.log('Streams found:', streams.length);
if (streams.length > 0) {
console.log('First stream:', {
name: streams[0].name,
title: streams[0].title,
url: streams[0].url.substring(0, 100) + '...' // Only show part of the URL
});
} else {
console.log('No streams found.');
}
} catch (error) {
console.error('Error testing HDRezka TV show:', error);
}
}
// Run the test
testHDRezka().then(() => {
console.log('Test completed.');
}).catch(error => {
console.error('Test failed:', error);
});

View file

@ -81,6 +81,7 @@ export interface StreamingContent {
name: string; name: string;
description?: string; description?: string;
poster?: string; poster?: string;
posterShape?: string;
banner?: string; banner?: string;
logo?: string; logo?: string;
year?: string | number; year?: string | number;
@ -88,12 +89,30 @@ export interface StreamingContent {
imdbRating?: string; imdbRating?: string;
genres?: string[]; genres?: string[];
director?: string; director?: string;
writer?: string; writer?: string[];
cast?: string[]; cast?: string[];
releaseInfo?: string; releaseInfo?: string;
directors?: string[]; directors?: string[];
creators?: string[]; creators?: string[];
certification?: string; certification?: string;
released?: string;
trailerStreams?: any[];
videos?: any[];
inLibrary?: boolean;
// Enhanced metadata from addons
country?: string;
links?: Array<{
name: string;
category: string;
url: string;
}>;
behaviorHints?: {
defaultVideoId?: string;
hasScheduledVideos?: boolean;
[key: string]: any;
};
imdb_id?: string;
slug?: string;
} }
// Navigation types // Navigation types

View file

@ -6,6 +6,7 @@ export type RootStackParamList = {
Metadata: { Metadata: {
id: string; id: string;
type: string; type: string;
addonId?: string;
}; };
Streams: { Streams: {
id: string; id: string;
@ -27,6 +28,7 @@ export type RootStackParamList = {
url: string; url: string;
lang: string; lang: string;
}>; }>;
imdbId?: string;
}; };
Catalog: { Catalog: {
addonId?: string; addonId?: string;

82
src/utils/posterUtils.ts Normal file
View file

@ -0,0 +1,82 @@
import { Dimensions } from 'react-native';
export interface PosterLayoutConfig {
minPosterWidth: number;
maxPosterWidth: number;
horizontalPadding: number;
minColumns: number;
maxColumns: number;
spacing: number;
}
export interface PosterLayout {
numColumns: number;
posterWidth: number;
spacing: number;
}
// Default configuration for main home sections
export const DEFAULT_POSTER_CONFIG: PosterLayoutConfig = {
minPosterWidth: 110,
maxPosterWidth: 140,
horizontalPadding: 50,
minColumns: 3,
maxColumns: 6,
spacing: 12
};
// Configuration for More Like This section (smaller posters, more items)
export const MORE_LIKE_THIS_CONFIG: PosterLayoutConfig = {
minPosterWidth: 100,
maxPosterWidth: 130,
horizontalPadding: 48,
minColumns: 3,
maxColumns: 7,
spacing: 12
};
// Configuration for Continue Watching section (larger posters, fewer items)
export const CONTINUE_WATCHING_CONFIG: PosterLayoutConfig = {
minPosterWidth: 120,
maxPosterWidth: 160,
horizontalPadding: 40,
minColumns: 2,
maxColumns: 5,
spacing: 12
};
export const calculatePosterLayout = (
screenWidth: number,
config: PosterLayoutConfig = DEFAULT_POSTER_CONFIG
): PosterLayout => {
const {
minPosterWidth,
maxPosterWidth,
horizontalPadding,
minColumns,
maxColumns,
spacing
} = config;
// Calculate how many posters can fit
const availableWidth = screenWidth - horizontalPadding;
const maxColumnsBasedOnWidth = Math.floor(availableWidth / minPosterWidth);
// Limit to reasonable number of columns
const numColumns = Math.min(Math.max(maxColumnsBasedOnWidth, minColumns), maxColumns);
// Calculate actual poster width
const posterWidth = Math.min(availableWidth / numColumns, maxPosterWidth);
return {
numColumns,
posterWidth,
spacing
};
};
// Helper function to get current screen dimensions
export const getCurrentPosterLayout = (config?: PosterLayoutConfig): PosterLayout => {
const { width } = Dimensions.get('window');
return calculatePosterLayout(width, config);
};

View file

@ -1,6 +1,8 @@
{ {
"extends": "expo/tsconfig.base", "extends": "expo/tsconfig.base",
"compilerOptions": { "compilerOptions": {
"strict": true "strict": true,
"jsx": "react-jsx",
"esModuleInterop": true
} }
} }