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
*.tsbuildinfo
plan.md
release_announcement.md

12
App.tsx
View file

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

View file

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

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"
},
"production": {
"autoIncrement": true
"autoIncrement": true,
"extends": "apk",
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease",
"image": "latest"
}
},
"release": {
"distribution": "store",
@ -22,7 +28,8 @@
},
"apk": {
"android": {
"buildType": "apk"
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
}
},

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');
module.exports = (() => {
const config = getDefaultConfig(__dirname);
const config = getDefaultConfig(__dirname);
const { transformer, resolver } = config;
config.transformer = {
...transformer,
babelTransformerPath: require.resolve('react-native-svg-transformer'),
minifierConfig: {
compress: {
// Remove console.* statements in release builds
drop_console: true,
// Keep error logging for critical issues
pure_funcs: ['console.info', 'console.log', 'console.debug', 'console.warn'],
},
// Enable tree shaking and better minification
config.transformer = {
...config.transformer,
babelTransformerPath: require.resolve('react-native-svg-transformer'),
minifierConfig: {
ecma: 8,
keep_fnames: true,
mangle: {
keep_fnames: true,
},
};
compress: {
drop_console: true,
drop_debugger: true,
pure_funcs: ['console.log', 'console.info', 'console.debug'],
},
},
};
config.resolver = {
...resolver,
assetExts: resolver.assetExts.filter((ext) => ext !== 'svg'),
sourceExts: [...resolver.sourceExts, 'svg'],
};
// Optimize resolver for better tree shaking and SVG support
config.resolver = {
...config.resolver,
assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
sourceExts: [...config.resolver.sourceExts, 'svg'],
resolverMainFields: ['react-native', 'browser', 'main'],
};
return config;
})();
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",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"postinstall": "node patch-package.js"
"web": "expo start --web"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
"@expo/vector-icons": "^14.1.0",
"@gorhom/bottom-sheet": "^5.1.2",
"@movie-web/providers": "^2.4.13",
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6",
@ -25,8 +25,10 @@
"@shopify/flash-list": "1.7.3",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.8.4",
"axios": "^1.10.0",
"base64-js": "^1.5.1",
"cheerio": "^1.1.0",
"cors": "^2.8.5",
"date-fns": "^4.1.0",
"eventemitter3": "^5.0.1",
"expo": "~52.0.43",
@ -44,7 +46,10 @@
"expo-status-bar": "~2.0.1",
"expo-system-ui": "^4.0.9",
"expo-web-browser": "~14.0.2",
"express": "^5.1.0",
"lodash": "^4.17.21",
"node-fetch": "^2.6.7",
"puppeteer": "^24.10.1",
"react": "18.3.1",
"react-native": "0.76.9",
"react-native-awesome-slider": "^2.9.0",
@ -61,6 +66,7 @@
"react-native-tab-view": "^4.0.10",
"react-native-url-polyfill": "^2.0.0",
"react-native-video": "^6.12.0",
"react-native-vlc-media-player": "^1.0.87",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1",
"subsrt": "^1.1.1"

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: {
height: Platform.OS === 'ios' ? 100 : 90,
paddingTop: Platform.OS === 'ios' ? 35 : 20,
paddingTop: Platform.OS === 'ios' ? 35 : 35,
backgroundColor: 'rgba(0,0,0,0.3)',
},
blurOverlay: {

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({
container: {
paddingVertical: 8,
paddingBottom: 90,
},
});

View file

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

View file

@ -14,14 +14,53 @@ interface CatalogSectionProps {
}
const { width } = Dimensions.get('window');
const POSTER_WIDTH = (width - 50) / 3;
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
// Calculate available width for posters (reserve space for left padding)
const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
// We'll use minimal right padding (8px) to maximize space
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
console.log(`[CatalogSection] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
console.log(`[CatalogSection] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
return {
numFullPosters: bestLayout.numFullPosters,
posterWidth: bestLayout.posterWidth,
spacing: SPACING,
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
};
};
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const handleContentPress = (id: string, type: string) => {
navigation.navigate('Metadata', { id, type });
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
};
const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => {
@ -73,18 +112,18 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
keyExtractor={(item) => `${item.id}-${item.type}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.catalogList}
snapToInterval={POSTER_WIDTH + 12}
contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]}
snapToInterval={POSTER_WIDTH + 8}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 12 }} />}
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
initialNumToRender={4}
maxToRenderPerBatch={4}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
getItemLayout={(data, index) => ({
length: POSTER_WIDTH + 12,
offset: (POSTER_WIDTH + 12) * index,
length: POSTER_WIDTH + 8,
offset: (POSTER_WIDTH + 8) * index,
index,
})}
/>
@ -107,19 +146,19 @@ const styles = StyleSheet.create({
position: 'relative',
},
catalogTitle: {
fontSize: 18,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
fontSize: 19,
fontWeight: '700',
letterSpacing: 0.2,
marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
bottom: -4,
bottom: -2,
left: 0,
width: 60,
height: 3,
borderRadius: 1.5,
width: 35,
height: 2,
borderRadius: 1,
opacity: 0.8,
},
seeAllButton: {
flexDirection: 'row',

View file

@ -12,7 +12,46 @@ interface ContentItemProps {
}
const { width } = Dimensions.get('window');
const POSTER_WIDTH = (width - 50) / 3;
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
// Calculate available width for posters (reserve space for left padding)
const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
// We'll use minimal right padding (8px) to maximize space
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
console.log(`[ContentItem] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
console.log(`[ContentItem] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
return {
numFullPosters: bestLayout.numFullPosters,
posterWidth: bestLayout.posterWidth,
spacing: SPACING,
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
};
};
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
@ -132,28 +171,28 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
borderRadius: 16,
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
elevation: 8,
elevation: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.25,
shadowRadius: 6,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.12)',
},
contentItemContainer: {
width: '100%',
height: '100%',
borderRadius: 16,
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 16,
borderRadius: 4,
},
loadingOverlay: {
position: 'absolute',
@ -163,7 +202,7 @@ const styles = StyleSheet.create({
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
borderRadius: 16,
borderRadius: 8,
},
watchedIndicator: {
position: 'absolute',

View file

@ -9,6 +9,7 @@ import {
AppState,
AppStateStatus
} from 'react-native';
import Animated, { FadeIn } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -33,8 +34,39 @@ interface ContinueWatchingRef {
refresh: () => Promise<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 POSTER_WIDTH = (width - 40) / 2.7;
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
// Function to validate IMDB ID format
const isValidImdbId = (id: string): boolean => {
// IMDB IDs should start with 'tt' followed by 7-10 digits
const imdbPattern = /^tt\d{7,10}$/;
return imdbPattern.test(id);
};
// Create a proper imperative handle with React.forwardRef and updated type
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
@ -50,6 +82,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
try {
setLoading(true);
const allProgress = await storageService.getAllWatchProgress();
if (Object.keys(allProgress).length === 0) {
setContinueWatchingItems([]);
return;
@ -62,19 +95,29 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// Process each saved progress
for (const key in allProgress) {
// Parse the key to get type and id
const [type, id, episodeId] = key.split(':');
const keyParts = key.split(':');
const [type, id, ...episodeIdParts] = keyParts;
const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined;
const progress = allProgress[key];
// Skip items that are more than 95% complete (effectively finished)
// Skip items that are more than 85% complete (effectively finished)
const progressPercent = (progress.currentTime / progress.duration) * 100;
if (progressPercent >= 95) continue;
if (progressPercent >= 85) {
continue;
}
const contentPromise = (async () => {
try {
// Validate IMDB ID format before attempting to fetch
if (!isValidImdbId(id)) {
return;
}
let content: StreamingContent | null = null;
// Get content details using catalogService
content = await catalogService.getContentDetails(type, id);
// Get basic content details using catalogService (no enhanced metadata needed for continue watching)
content = await catalogService.getBasicContentDetails(type, id);
if (content) {
// Extract season and episode info from episodeId if available
@ -83,11 +126,28 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
let episodeTitle: string | undefined;
if (episodeId && type === 'series') {
const match = episodeId.match(/s(\d+)e(\d+)/i);
// Try different episode ID formats
let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1
if (match) {
season = parseInt(match[1], 10);
episode = parseInt(match[2], 10);
episodeTitle = `Episode ${episode}`;
} else {
// Try format: seriesId:season:episode (e.g., tt0108778:4:6)
const parts = episodeId.split(':');
if (parts.length >= 3) {
const seasonPart = parts[parts.length - 2]; // Second to last part
const episodePart = parts[parts.length - 1]; // Last part
const seasonNum = parseInt(seasonPart, 10);
const episodeNum = parseInt(episodePart, 10);
if (!isNaN(seasonNum) && !isNaN(episodeNum)) {
season = seasonNum;
episode = episodeNum;
episodeTitle = `Episode ${episode}`;
}
}
}
}
@ -128,7 +188,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated);
// Limit to 10 items
setContinueWatchingItems(progressItems.slice(0, 10));
const finalItems = progressItems.slice(0, 10);
setContinueWatchingItems(finalItems);
} catch (error) {
logger.error('Failed to load continue watching items:', error);
} finally {
@ -197,7 +259,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
refresh: async () => {
await loadContinueWatching();
// Return whether there are items to help parent determine visibility
return continueWatchingItems.length > 0;
const hasItems = continueWatchingItems.length > 0;
return hasItems;
}
}));
@ -206,12 +269,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}, [navigation]);
// If no continue watching items, don't render anything
if (continueWatchingItems.length === 0 && !loading) {
if (continueWatchingItems.length === 0) {
return null;
}
return (
<View style={styles.container}>
<Animated.View entering={FadeIn.duration(400).delay(250)} style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text>
@ -228,41 +291,82 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
data={continueWatchingItems}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.contentItem, {
style={[styles.wideContentItem, {
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black
}]}
activeOpacity={0.7}
activeOpacity={0.8}
onPress={() => handleContentPress(item.id, item.type)}
>
<View style={styles.contentItemContainer}>
{/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage
source={{ uri: item.poster }}
style={styles.poster}
style={styles.widePoster}
contentFit="cover"
transition={200}
cachePolicy="memory-disk"
/>
{item.type === 'series' && item.season && item.episode && (
<View style={[styles.episodeInfoContainer, { backgroundColor: 'rgba(0, 0, 0, 0.7)' }]}>
<Text style={[styles.episodeInfo, { color: currentTheme.colors.white }]}>
S{item.season.toString().padStart(2, '0')}E{item.episode.toString().padStart(2, '0')}
</Text>
{item.episodeTitle && (
<Text style={[styles.episodeTitle, { color: currentTheme.colors.white, opacity: 0.9 }]} numberOfLines={1}>
{item.episodeTitle}
</Text>
)}
</View>
{/* Content Details */}
<View style={styles.contentDetails}>
<View style={styles.titleRow}>
<Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{item.name}
</Text>
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.progressText}>{Math.round(item.progress)}%</Text>
</View>
)}
{/* Progress bar indicator */}
<View style={styles.progressBarContainer}>
</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>
{item.episodeTitle && (
<Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={1}
>
{item.episodeTitle}
</Text>
)}
</View>
);
} else {
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
style={[
styles.progressBar,
{ width: `${item.progress}%`, backgroundColor: currentTheme.colors.primary }
styles.wideProgressBar,
{
width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}>
{Math.round(item.progress)}% watched
</Text>
</View>
</View>
</TouchableOpacity>
@ -270,13 +374,13 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.list}
snapToInterval={POSTER_WIDTH + 10}
contentContainerStyle={styles.wideList}
snapToInterval={280 + 16} // Card width + margin
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
/>
</View>
</Animated.View>
);
});
@ -291,26 +395,116 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
marginBottom: 8,
marginBottom: 12,
},
titleContainer: {
position: 'relative',
},
title: {
fontSize: 18,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
fontSize: 20,
fontWeight: '700',
letterSpacing: 0.3,
marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
bottom: -4,
bottom: -2,
left: 0,
width: 60,
height: 3,
borderRadius: 1.5,
width: 40,
height: 2,
borderRadius: 1,
opacity: 0.8,
},
wideList: {
paddingHorizontal: 16,
paddingBottom: 8,
paddingTop: 4,
},
wideContentItem: {
width: 280,
height: 120,
flexDirection: 'row',
borderRadius: 12,
overflow: 'hidden',
elevation: 6,
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.2,
shadowRadius: 6,
borderWidth: 1,
},
posterContainer: {
width: 80,
height: '100%',
},
widePoster: {
width: '100%',
height: '100%',
borderTopLeftRadius: 12,
borderBottomLeftRadius: 12,
},
contentDetails: {
flex: 1,
padding: 12,
justifyContent: 'space-between',
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
marginBottom: 4,
},
contentTitle: {
fontSize: 16,
fontWeight: '700',
flex: 1,
marginRight: 8,
},
progressBadge: {
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 12,
minWidth: 44,
alignItems: 'center',
},
progressText: {
fontSize: 12,
fontWeight: '700',
color: '#FFFFFF',
},
episodeRow: {
marginBottom: 8,
},
episodeText: {
fontSize: 13,
fontWeight: '600',
marginBottom: 2,
},
episodeTitle: {
fontSize: 12,
},
yearText: {
fontSize: 13,
fontWeight: '500',
marginBottom: 8,
},
wideProgressContainer: {
marginTop: 'auto',
},
wideProgressTrack: {
height: 4,
backgroundColor: 'rgba(255,255,255,0.1)',
borderRadius: 2,
marginBottom: 4,
},
wideProgressBar: {
height: '100%',
borderRadius: 2,
},
progressLabel: {
fontSize: 11,
fontWeight: '500',
},
// Keep old styles for backward compatibility
list: {
paddingHorizontal: 16,
paddingBottom: 8,
@ -320,7 +514,7 @@ const styles = StyleSheet.create({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
borderRadius: 12,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
elevation: 8,
@ -332,14 +526,14 @@ const styles = StyleSheet.create({
contentItemContainer: {
width: '100%',
height: '100%',
borderRadius: 12,
borderRadius: 8,
overflow: 'hidden',
position: 'relative',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 12,
borderRadius: 8,
},
episodeInfoContainer: {
position: 'absolute',
@ -353,9 +547,6 @@ const styles = StyleSheet.create({
fontSize: 12,
fontWeight: 'bold',
},
episodeTitle: {
fontSize: 10,
},
progressBarContainer: {
position: 'absolute',
bottom: 0,

View file

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

View file

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

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';
const { width } = Dimensions.get('window');
const POSTER_WIDTH = (width - 48) / 3.5; // Adjust number for desired items visible
// Dynamic poster calculation based on screen width for More Like This section
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section
const MAX_POSTER_WIDTH = 130; // Maximum poster width
const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins
// Calculate how many posters can fit (aim for slightly more items than main sections)
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
// Limit to reasonable number of columns (3-7 for this section)
const numColumns = Math.min(Math.max(maxColumns, 3), 7);
// Calculate actual poster width
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
return {
numColumns,
posterWidth,
spacing: 12 // Space between posters
};
};
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
interface MoreLikeThisSectionProps {

View file

@ -10,7 +10,7 @@ interface MovieContentProps {
export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
const { currentTheme } = useTheme();
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0;
const castDisplay = hasCast ? (metadata.cast as string[]).slice(0, 5).join(', ') : '';
const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : '';
return (
<View style={styles.container}>
@ -23,12 +23,6 @@ export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
</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 && (
<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 { Image } from 'expo-image';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { LinearGradient } from 'expo-linear-gradient';
import { useTheme } from '../../contexts/ThemeContext';
import { useSettings } from '../../hooks/useSettings';
import { Episode } from '../../types/metadata';
import { tmdbService } from '../../services/tmdbService';
import { storageService } from '../../services/storageService';
@ -34,19 +36,21 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
metadata
}) => {
const { currentTheme } = useTheme();
const { settings } = useSettings();
const { width } = useWindowDimensions();
const isTablet = width > 768;
const isDarkMode = useColorScheme() === 'dark';
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number } }>({});
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({});
// Add ref for the season selector ScrollView
// Add refs for the scroll views
const seasonScrollViewRef = useRef<ScrollView | null>(null);
const episodeScrollViewRef = useRef<ScrollView | null>(null);
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
const allProgress = await storageService.getAllWatchProgress();
const progress: { [key: string]: { currentTime: number; duration: number } } = {};
const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {};
episodes.forEach(episode => {
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
@ -54,7 +58,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
if (allProgress[key]) {
progress[episodeId] = {
currentTime: allProgress[key].currentTime,
duration: allProgress[key].duration
duration: allProgress[key].duration,
lastUpdated: allProgress[key].lastUpdated
};
}
});
@ -62,6 +67,67 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
setEpisodeProgress(progress);
};
// Function to find and scroll to the most recently watched episode
const scrollToMostRecentEpisode = () => {
if (!metadata?.id || !episodeScrollViewRef.current || settings.episodeLayoutStyle !== 'horizontal') {
console.log('[SeriesContent] Scroll conditions not met:', {
hasMetadataId: !!metadata?.id,
hasScrollRef: !!episodeScrollViewRef.current,
isHorizontal: settings.episodeLayoutStyle === 'horizontal'
});
return;
}
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
if (currentSeasonEpisodes.length === 0) {
console.log('[SeriesContent] No episodes in current season:', selectedSeason);
return;
}
// Find the most recently watched episode in the current season
let mostRecentEpisodeIndex = -1;
let mostRecentTimestamp = 0;
let mostRecentEpisodeName = '';
currentSeasonEpisodes.forEach((episode, index) => {
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
const progress = episodeProgress[episodeId];
if (progress && progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
mostRecentTimestamp = progress.lastUpdated;
mostRecentEpisodeIndex = index;
mostRecentEpisodeName = episode.name;
}
});
console.log('[SeriesContent] Episode scroll analysis:', {
totalEpisodes: currentSeasonEpisodes.length,
mostRecentIndex: mostRecentEpisodeIndex,
mostRecentEpisode: mostRecentEpisodeName,
selectedSeason
});
// Scroll to the most recently watched episode if found
if (mostRecentEpisodeIndex >= 0) {
const cardWidth = isTablet ? width * 0.4 + 16 : width * 0.85 + 16;
const scrollPosition = mostRecentEpisodeIndex * cardWidth;
console.log('[SeriesContent] Scrolling to episode:', {
index: mostRecentEpisodeIndex,
cardWidth,
scrollPosition,
episodeName: mostRecentEpisodeName
});
setTimeout(() => {
episodeScrollViewRef.current?.scrollTo({
x: scrollPosition,
animated: true
});
}, 500); // Delay to ensure the season has loaded
}
};
// Initial load of watch progress
useEffect(() => {
loadEpisodesProgress();
@ -93,6 +159,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
}
}, [selectedSeason, groupedEpisodes]);
// Add effect to scroll to most recently watched episode when season changes or progress loads
useEffect(() => {
if (Object.keys(episodeProgress).length > 0 && selectedSeason) {
scrollToMostRecentEpisode();
}
}, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]);
if (loadingSeasons) {
return (
<View style={styles.centeredContainer}>
@ -159,6 +232,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</View>
<Text
style={[
styles.seasonButtonText,
{ color: currentTheme.colors.mediumEmphasis },
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;
if (episode.still_path) {
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
@ -210,15 +285,15 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
const progress = episodeProgress[episodeId];
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
// Don't show progress bar if episode is complete (>= 95%)
const showProgress = progress && progressPercent < 95;
// Don't show progress bar if episode is complete (>= 85%)
const showProgress = progress && progressPercent < 85;
return (
<TouchableOpacity
key={episode.id}
style={[
styles.episodeCard,
isTablet && styles.episodeCardTablet,
styles.episodeCardVertical,
isTablet && styles.episodeCardVerticalTablet,
{ backgroundColor: currentTheme.colors.elevation2 }
]}
onPress={() => onSelectEpisode(episode)}
@ -243,7 +318,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
/>
</View>
)}
{progressPercent >= 95 && (
{progressPercent >= 85 && (
<View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}>
<MaterialIcons name="check" size={12} color={currentTheme.colors.white} />
</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] || [];
return (
@ -308,35 +547,63 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
</Text>
<ScrollView
style={styles.episodeList}
contentContainerStyle={[
styles.episodeListContent,
isTablet && styles.episodeListContentTablet
]}
>
{isTablet ? (
<View style={styles.episodeGrid}>
{currentSeasonEpisodes.map((episode, index) => (
{settings.episodeLayoutStyle === 'horizontal' ? (
// Horizontal Layout (Netflix-style)
<ScrollView
ref={episodeScrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.episodeList}
contentContainerStyle={styles.episodeListContentHorizontal}
decelerationRate="fast"
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
snapToAlignment="start"
>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
style={[
styles.episodeCardWrapperHorizontal,
isTablet && styles.episodeCardWrapperHorizontalTablet
]}
>
{renderHorizontalEpisodeCard(episode)}
</Animated.View>
))}
</ScrollView>
) : (
// Vertical Layout (Traditional)
<ScrollView
style={styles.episodeList}
contentContainerStyle={[
styles.episodeListContentVertical,
isTablet && styles.episodeListContentVerticalTablet
]}
>
{isTablet ? (
<View style={styles.episodeGridVertical}>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderEpisodeCard(episode)}
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderEpisodeCard(episode)}
</Animated.View>
))
)}
</ScrollView>
))
)}
</ScrollView>
)}
</Animated.View>
</View>
);
@ -345,7 +612,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 16,
paddingVertical: 16,
},
centeredContainer: {
flex: 1,
@ -362,22 +629,26 @@ const styles = StyleSheet.create({
fontSize: 20,
fontWeight: '700',
marginBottom: 16,
paddingHorizontal: 16,
},
episodeList: {
flex: 1,
},
episodeListContent: {
// Vertical Layout Styles
episodeListContentVertical: {
paddingBottom: 20,
paddingHorizontal: 16,
},
episodeListContentTablet: {
episodeListContentVerticalTablet: {
paddingHorizontal: 8,
},
episodeGrid: {
episodeGridVertical: {
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'space-between',
},
episodeCard: {
episodeCardVertical: {
flexDirection: 'row',
borderRadius: 16,
marginBottom: 16,
@ -391,7 +662,7 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255,255,255,0.1)',
height: 120,
},
episodeCardTablet: {
episodeCardVerticalTablet: {
width: '48%',
flexDirection: 'column',
height: 120,
@ -461,6 +732,19 @@ const styles = StyleSheet.create({
fontWeight: '700',
marginLeft: 4,
},
runtimeContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 4,
},
runtimeText: {
fontSize: 13,
fontWeight: '600',
marginLeft: 4,
},
airDateText: {
fontSize: 12,
opacity: 0.8,
@ -469,8 +753,170 @@ const styles = StyleSheet.create({
fontSize: 13,
lineHeight: 18,
},
progressBarContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(0,0,0,0.5)',
},
progressBar: {
height: '100%',
},
completedBadge: {
position: 'absolute',
bottom: 8,
right: 8,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
},
// Horizontal Layout Styles
episodeListContentHorizontal: {
paddingLeft: 16,
paddingRight: 16,
},
episodeCardWrapperHorizontal: {
width: Dimensions.get('window').width * 0.85,
marginRight: 16,
},
episodeCardWrapperHorizontalTablet: {
width: Dimensions.get('window').width * 0.4,
},
episodeCardHorizontal: {
borderRadius: 16,
overflow: 'hidden',
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.35,
shadowRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
height: 200,
position: 'relative',
width: '100%',
backgroundColor: 'transparent',
},
episodeCardHorizontalTablet: {
height: 180,
},
episodeBackgroundImage: {
width: '100%',
height: '100%',
borderRadius: 16,
},
episodeGradient: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 16,
justifyContent: 'flex-end',
},
episodeContent: {
padding: 12,
paddingBottom: 16,
},
episodeNumberBadgeHorizontal: {
backgroundColor: 'rgba(0,0,0,0.4)',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
marginBottom: 6,
alignSelf: 'flex-start',
},
episodeNumberHorizontal: {
color: 'rgba(255,255,255,0.8)',
fontSize: 10,
fontWeight: '600',
letterSpacing: 0.8,
textTransform: 'uppercase',
marginBottom: 2,
},
episodeTitleHorizontal: {
color: '#fff',
fontSize: 15,
fontWeight: '700',
letterSpacing: -0.3,
marginBottom: 4,
lineHeight: 18,
},
episodeDescriptionHorizontal: {
color: 'rgba(255,255,255,0.85)',
fontSize: 12,
lineHeight: 16,
marginBottom: 8,
opacity: 0.9,
},
episodeMetadataRowHorizontal: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
runtimeContainerHorizontal: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.4)',
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: 3,
},
runtimeTextHorizontal: {
color: 'rgba(255,255,255,0.8)',
fontSize: 11,
fontWeight: '500',
},
ratingContainerHorizontal: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.4)',
paddingHorizontal: 5,
paddingVertical: 2,
borderRadius: 3,
gap: 2,
},
ratingTextHorizontal: {
color: '#FFD700',
fontSize: 11,
fontWeight: '600',
},
progressBarContainerHorizontal: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(255,255,255,0.2)',
},
progressBarHorizontal: {
height: '100%',
borderRadius: 2,
},
completedBadgeHorizontal: {
position: 'absolute',
bottom: 12,
right: 12,
width: 24,
height: 24,
borderRadius: 12,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: '#fff',
},
// Season Selector Styles
seasonSelectorWrapper: {
marginBottom: 20,
paddingHorizontal: 16,
},
seasonSelectorTitle: {
fontSize: 18,
@ -517,54 +963,4 @@ const styles = StyleSheet.create({
selectedSeasonButtonText: {
fontWeight: '700',
},
progressBarContainer: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 3,
backgroundColor: 'rgba(0,0,0,0.5)',
},
progressBar: {
height: '100%',
},
progressTextContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 6,
paddingVertical: 3,
borderRadius: 4,
marginRight: 8,
},
progressText: {
fontSize: 12,
fontWeight: '600',
marginLeft: 4,
},
completedBadge: {
position: 'absolute',
bottom: 8,
right: 8,
width: 20,
height: 20,
borderRadius: 10,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.3)',
},
runtimeContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 4,
},
runtimeText: {
fontSize: 13,
fontWeight: '600',
marginLeft: 4,
},
});

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',
colors: {
...defaultColors,
primary: '#a786df',
secondary: '#5e72e4',
darkBackground: '#0f0f1a',
primary: '#c084fc',
secondary: '#60a5fa',
darkBackground: '#060609',
},
isEditable: false,
},

View file

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

View file

@ -3,11 +3,15 @@ import { StreamingContent } from '../services/catalogService';
import { catalogService } from '../services/catalogService';
import { stremioService } from '../services/stremioService';
import { tmdbService } from '../services/tmdbService';
import { hdrezkaService } from '../services/hdrezkaService';
import { cacheService } from '../services/cacheService';
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
import { TMDBService } from '../services/tmdbService';
import { logger } from '../utils/logger';
import { usePersistentSeasons } from './usePersistentSeasons';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Stream } from '../types/metadata';
import { storageService } from '../services/storageService';
// Constants for timeouts and retries
const API_TIMEOUT = 10000; // 10 seconds
@ -56,6 +60,7 @@ const withRetry = async <T>(
interface UseMetadataProps {
id: string;
type: string;
addonId?: string;
}
interface UseMetadataReturn {
@ -90,7 +95,7 @@ interface UseMetadataReturn {
imdbId: string | null;
}
export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn => {
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@ -113,6 +118,8 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
const [imdbId, setImdbId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
// Add hook for persistent seasons
const { getSeason, saveSeason } = usePersistentSeasons();
@ -150,8 +157,12 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
if (isEpisode) {
setEpisodeStreams(updateState);
// Turn off loading when we get streams
setLoadingEpisodeStreams(false);
} else {
setGroupedStreams(updateState);
// Turn off loading when we get streams
setLoadingStreams(false);
}
} else {
logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
@ -173,35 +184,80 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Loading indicators should probably be managed based on callbacks completing.
};
const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
const processHDRezkaSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => {
const sourceStartTime = Date.now();
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
const sourceName = 'hdrezka';
try {
logger.log(`🔍 [${logPrefix}:${sourceType}] Starting fetch`);
const result = await promise;
logger.log(`✅ [${logPrefix}:${sourceType}] Completed in ${Date.now() - sourceStartTime}ms`);
if (Object.keys(result).length > 0) {
const totalStreams = Object.values(result).reduce((acc, group: any) => acc + (group.streams?.length || 0), 0);
logger.log(`📦 [${logPrefix}:${sourceType}] Found ${totalStreams} streams`);
const updateState = (prevState: GroupedStreams) => {
logger.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`);
return { ...prevState, ...result };
};
logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`);
try {
const streams = await hdrezkaService.getStreams(
id,
type,
season,
episode
);
const processTime = Date.now() - sourceStartTime;
if (streams && streams.length > 0) {
logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`);
// Format response similar to Stremio format for the UI
return {
'hdrezka': {
addonName: 'HDRezka',
streams
}
};
} else {
logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`);
return {};
}
} catch (error) {
logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error);
return {};
}
};
const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
try {
const startTime = Date.now();
const result = await promise;
const processingTime = Date.now() - startTime;
if (result && Object.keys(result).length > 0) {
// Update the appropriate state based on whether this is for an episode or not
const updateState = (prevState: GroupedStreams) => {
const newState = { ...prevState };
// Merge in the new streams
Object.entries(result).forEach(([provider, data]: [string, any]) => {
newState[provider] = data;
});
return newState;
};
if (isEpisode) {
setEpisodeStreams(updateState);
} else {
setGroupedStreams(updateState);
}
console.log(`✅ [processExternalSource:${sourceType}] Processed in ${processingTime}ms, found streams:`,
Object.values(result).reduce((acc: number, curr: any) => acc + (curr.streams?.length || 0), 0)
);
// Return the result for the promise chain
return result;
} else {
logger.log(`⚠️ [${logPrefix}:${sourceType}] No streams found`);
console.log(`⚠️ [processExternalSource:${sourceType}] No streams found after ${processingTime}ms`);
return {};
}
return result;
} catch (error) {
logger.error(`❌ [${logPrefix}:${sourceType}] Error:`, error);
console.error(`❌ [processExternalSource:${sourceType}] Error:`, error);
return {};
}
};
@ -356,7 +412,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
if (writers.length > 0) {
(formattedMovie as any).creators = writers;
(formattedMovie as StreamingContent & { writer: string }).writer = writers.join(', ');
(formattedMovie as any).writer = writers;
}
}
} catch (error) {
@ -459,7 +515,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Load content with timeout and retry
withRetry(async () => {
const result = await withTimeout(
catalogService.getContentDetails(type, actualId),
catalogService.getEnhancedContentDetails(type, actualId, addonId),
API_TIMEOUT
);
// Store the actual ID used (could be IMDB)
@ -485,8 +541,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
cacheService.setMetadata(id, type, content.value);
if (type === 'series') {
// Load series data in parallel with other data
// Load series data after the enhanced metadata is processed
setTimeout(() => {
loadSeriesData().catch(console.error);
}, 100);
}
} else {
throw new Error('Content not found');
@ -509,6 +567,67 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
const loadSeriesData = async () => {
setLoadingSeasons(true);
try {
// First check if we have episode data from the addon
const addonVideos = metadata?.videos;
if (addonVideos && Array.isArray(addonVideos) && addonVideos.length > 0) {
logger.log(`🎬 Found ${addonVideos.length} episodes from addon metadata for ${metadata?.name || id}`);
// Group addon episodes by season
const groupedAddonEpisodes: GroupedEpisodes = {};
addonVideos.forEach((video: any) => {
const seasonNumber = video.season || 1;
const episodeNumber = video.episode || video.number || 1;
if (!groupedAddonEpisodes[seasonNumber]) {
groupedAddonEpisodes[seasonNumber] = [];
}
// Convert addon episode format to our Episode interface
const episode: Episode = {
id: video.id,
name: video.name || video.title || `Episode ${episodeNumber}`,
overview: video.overview || video.description || '',
season_number: seasonNumber,
episode_number: episodeNumber,
air_date: video.released ? video.released.split('T')[0] : video.firstAired ? video.firstAired.split('T')[0] : '',
still_path: video.thumbnail ? video.thumbnail.replace('https://image.tmdb.org/t/p/w500', '') : null,
vote_average: parseFloat(video.rating) || 0,
runtime: undefined,
episodeString: `S${seasonNumber.toString().padStart(2, '0')}E${episodeNumber.toString().padStart(2, '0')}`,
stremioId: video.id,
season_poster_path: null
};
groupedAddonEpisodes[seasonNumber].push(episode);
});
// Sort episodes within each season
Object.keys(groupedAddonEpisodes).forEach(season => {
groupedAddonEpisodes[parseInt(season)].sort((a, b) => a.episode_number - b.episode_number);
});
logger.log(`📺 Processed addon episodes into ${Object.keys(groupedAddonEpisodes).length} seasons`);
setGroupedEpisodes(groupedAddonEpisodes);
// Set the first available season
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
const firstSeason = Math.min(...seasons);
logger.log(`📺 Setting season ${firstSeason} as selected (${groupedAddonEpisodes[firstSeason]?.length || 0} episodes)`);
setSelectedSeason(firstSeason);
setEpisodes(groupedAddonEpisodes[firstSeason] || []);
// Try to get TMDB ID for additional metadata (cast, etc.) but don't override episodes
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
if (tmdbIdResult) {
setTmdbId(tmdbIdResult);
}
return; // Use addon episodes, skip TMDB loading
}
// Fallback to TMDB if no addon episodes
logger.log('📺 No addon episodes found, falling back to TMDB');
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
if (tmdbIdResult) {
setTmdbId(tmdbIdResult);
@ -535,14 +654,61 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
// Get the first available season as fallback
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
// Get saved season from persistence, fallback to first season if not found
const persistedSeason = getSeason(id, firstSeason);
// Check for watch progress to auto-select season
let selectedSeasonNumber = firstSeason;
// Set the selected season from persistence
setSelectedSeason(persistedSeason);
try {
// Check watch progress for auto-season selection
const allProgress = await storageService.getAllWatchProgress();
// Find the most recently watched episode for this series
let mostRecentEpisodeId = '';
let mostRecentTimestamp = 0;
Object.entries(allProgress).forEach(([key, progress]) => {
if (key.includes(`series:${id}:`)) {
const episodeId = key.split(`series:${id}:`)[1];
if (progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
mostRecentTimestamp = progress.lastUpdated;
mostRecentEpisodeId = episodeId;
}
}
});
if (mostRecentEpisodeId) {
// Parse season number from episode ID
const parts = mostRecentEpisodeId.split(':');
if (parts.length === 3) {
const watchProgressSeason = parseInt(parts[1], 10);
if (transformedEpisodes[watchProgressSeason]) {
selectedSeasonNumber = watchProgressSeason;
logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for ${mostRecentEpisodeId}`);
}
} else {
// Try to find episode by stremioId to get season
const allEpisodesList = Object.values(transformedEpisodes).flat();
const episode = allEpisodesList.find(ep => ep.stremioId === mostRecentEpisodeId);
if (episode) {
selectedSeasonNumber = episode.season_number;
logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for episode with stremioId ${mostRecentEpisodeId}`);
}
}
} else {
// No watch progress found, use persistent storage as fallback
selectedSeasonNumber = getSeason(id, firstSeason);
logger.log(`[useMetadata] No watch progress found, using persistent season ${selectedSeasonNumber}`);
}
} catch (error) {
logger.error('[useMetadata] Error checking watch progress for season selection:', error);
// Fall back to persistent storage
selectedSeasonNumber = getSeason(id, firstSeason);
}
// Set the selected season
setSelectedSeason(selectedSeasonNumber);
// Set episodes for the selected season
setEpisodes(transformedEpisodes[persistedSeason] || []);
setEpisodes(transformedEpisodes[selectedSeasonNumber] || []);
}
} catch (error) {
console.error('Failed to load episodes:', error);
@ -575,39 +741,71 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log('🚀 [loadStreams] START - Loading streams for:', id);
updateLoadingState();
// Always clear streams first to ensure we don't show stale data
setGroupedStreams({});
// Get TMDB ID for external sources first before starting parallel requests
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
let tmdbId;
let stremioId = id; // Default to original ID
if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1];
console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId);
// Try to get IMDb ID from metadata first, then convert if needed
if (metadata?.imdb_id) {
stremioId = metadata.imdb_id;
console.log('✅ [loadStreams] Using IMDb ID from metadata for Stremio:', stremioId);
} else if (imdbId) {
stremioId = imdbId;
console.log('✅ [loadStreams] Using stored IMDb ID for Stremio:', stremioId);
} else {
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
try {
let externalIds = null;
if (type === 'movie') {
const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
externalIds = movieDetails?.external_ids;
} else if (type === 'series') {
externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
}
if (externalIds?.imdb_id) {
stremioId = externalIds.imdb_id;
console.log('✅ [loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId);
} else {
console.log('⚠️ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId);
}
} catch (error) {
console.log('⚠️ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error);
}
}
} else if (id.startsWith('tt')) {
// This is an IMDB ID
// This is already an IMDB ID, perfect for Stremio
stremioId = id;
console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId);
} else {
tmdbId = id;
console.log(' [loadStreams] Using ID as TMDB ID:', tmdbId);
stremioId = id;
console.log(' [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
}
console.log('🔄 [loadStreams] Starting stream requests');
// Start Stremio request using the callback method
processStremioSource(type, id, false);
// Start Stremio request using the converted ID format
console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
processStremioSource(type, stremioId, false);
// Add HDRezka source
const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false);
// Include HDRezka in fetchPromises array
const fetchPromises: Promise<any>[] = [hdrezkaPromise];
// No external sources are used anymore
const fetchPromises: Promise<any>[] = [];
// Wait only for external promises now (none in this case)
// Wait only for external promises now
const results = await Promise.allSettled(fetchPromises);
const totalTime = Date.now() - startTime;
console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
const sourceTypes: string[] = []; // No external sources
const sourceTypes: string[] = ['hdrezka'];
results.forEach((result, index) => {
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
console.log(`📊 [loadStreams:${source}] Status: ${result.status}`);
@ -634,15 +832,15 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
return prev;
});
// Add a delay before marking loading as complete to give Stremio addons more time
setTimeout(() => {
setLoadingStreams(false);
}, 10000); // 10 second delay to allow streams to load
} catch (error) {
console.error('❌ [loadStreams] Failed to load streams:', error);
setError('Failed to load streams');
} finally {
// Loading is now complete when external sources finish, Stremio updates happen independently.
// We need a better way to track overall completion if we want a final 'FINISHED' log.
const endTime = Date.now() - startTime;
console.log(`🏁 [loadStreams] External sources FINISHED in ${endTime}ms`);
setLoadingStreams(false); // Mark loading=false, but Stremio might still be working
setLoadingStreams(false);
}
};
@ -652,42 +850,76 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
updateEpisodeLoadingState();
// Get TMDB ID for external sources first before starting parallel requests
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
let tmdbId;
let stremioEpisodeId = episodeId; // Default to original episode ID
if (id.startsWith('tmdb:')) {
tmdbId = id.split(':')[1];
console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
// Try to get IMDb ID from metadata first, then convert if needed
if (metadata?.imdb_id) {
// Replace the series ID in episodeId with the IMDb ID
const [, season, episode] = episodeId.split(':');
stremioEpisodeId = `series:${metadata.imdb_id}:${season}:${episode}`;
console.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId);
} else if (imdbId) {
const [, season, episode] = episodeId.split(':');
stremioEpisodeId = `series:${imdbId}:${season}:${episode}`;
console.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId);
} else {
// Convert TMDB ID to IMDb ID for Stremio addons
try {
const externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
if (externalIds?.imdb_id) {
const [, season, episode] = episodeId.split(':');
stremioEpisodeId = `series:${externalIds.imdb_id}:${season}:${episode}`;
console.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId);
} else {
console.log('⚠️ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using original episode ID:', stremioEpisodeId);
}
} catch (error) {
console.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using original episode ID:', error);
}
}
} else if (id.startsWith('tt')) {
// This is an IMDB ID
// This is already an IMDB ID, perfect for Stremio
console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
} else {
tmdbId = id;
console.log(' [loadEpisodeStreams] Using ID as TMDB ID:', tmdbId);
console.log(' [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
}
// Extract episode info from the episodeId
// Extract episode info from the episodeId for logging
const [, season, episode] = episodeId.split(':');
const episodeQuery = `?s=${season}&e=${episode}`;
console.log(` [loadEpisodeStreams] Episode query: ${episodeQuery}`);
console.log('🔄 [loadEpisodeStreams] Starting stream requests');
const fetchPromises: Promise<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
processStremioSource('series', episodeId, true);
// Add HDRezka source for episodes
const hdrezkaEpisodePromise = processExternalSource('hdrezka',
processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true),
true
);
const fetchPromises: Promise<any>[] = [hdrezkaEpisodePromise];
// No external sources are used anymore
// Wait only for external promises now (none in this case)
// Wait only for external promises now
const results = await Promise.allSettled(fetchPromises);
const totalTime = Date.now() - startTime;
console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
const sourceTypes: string[] = []; // No external sources
const sourceTypes: string[] = ['hdrezka'];
results.forEach((result, index) => {
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`);
@ -699,31 +931,23 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
console.log('🧮 [loadEpisodeStreams] Summary:');
console.log(' Total time for external sources:', totalTime + 'ms');
// Log the final states - might not include all Stremio addons yet
console.log('📦 [loadEpisodeStreams] Current combined streams count:',
Object.keys(episodeStreams).length > 0 ?
Object.values(episodeStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
0
);
// Cache the final streams state - Might be incomplete
setEpisodeStreams(prev => {
// Cache episode streams - maybe incrementally?
setPreloadedEpisodeStreams(currentPreloaded => ({
...currentPreloaded,
[episodeId]: prev
// Update preloaded episode streams for future use
if (Object.keys(episodeStreams).length > 0) {
setPreloadedEpisodeStreams(prev => ({
...prev,
[episodeId]: { ...episodeStreams }
}));
return prev;
});
}
// Add a delay before marking loading as complete to give addons more time
setTimeout(() => {
setLoadingEpisodeStreams(false);
}, 10000); // 10 second delay to allow streams to load
} catch (error) {
console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
setError('Failed to load episode streams');
} finally {
// Loading is now complete when external sources finish
const endTime = Date.now() - startTime;
console.log(`🏁 [loadEpisodeStreams] External sources FINISHED in ${endTime}ms`);
setLoadingEpisodeStreams(false); // Mark loading=false, but Stremio might still be working
setLoadingEpisodeStreams(false);
}
};
@ -770,6 +994,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
loadMetadata();
}, [id, type]);
// Re-run series data loading when metadata updates with videos
useEffect(() => {
if (metadata && type === 'series' && metadata.videos && metadata.videos.length > 0) {
logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
loadSeriesData().catch(console.error);
}
}, [metadata?.videos, type]);
const loadRecommendations = useCallback(async () => {
if (!tmdbId) return;

View file

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

View file

@ -196,7 +196,15 @@ export const useMetadataAssets = (
else if (shouldFetchLogo && logoFetchInProgress.current) {
logger.log(`[useMetadataAssets:Logo] Skipping logo fetch because logoFetchInProgress is true.`);
}
}, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference]); // Added tmdbLanguagePreference dependency
}, [
id,
type,
imdbId,
metadata?.logo, // Depend on the logo value itself, not the whole object
settings.logoSourcePreference,
settings.tmdbLanguagePreference,
setMetadata // Keep setMetadata, but ensure it's memoized in parent
]);
// Fetch banner image based on logo source preference - optimized version
useEffect(() => {
@ -217,9 +225,15 @@ export const useMetadataAssets = (
const fetchBanner = async () => {
logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`);
setLoadingBanner(true);
setBannerImage(null); // Clear existing banner to prevent mixed sources
setBannerSource(null); // Clear source tracking
setLoadingBanner(true);
// Show fallback banner immediately to prevent blank state
const fallbackBanner = metadata?.banner || metadata?.poster || null;
if (fallbackBanner && !bannerImage) {
setBannerImage(fallbackBanner);
setBannerSource('default');
logger.log(`[useMetadataAssets:Banner] Setting immediate fallback banner: ${fallbackBanner}`);
}
let finalBanner: string | null = null;
let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default';
@ -411,17 +425,31 @@ export const useMetadataAssets = (
// Set the final state
logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`);
setBannerImage(finalBanner);
setBannerSource(bannerSourceType); // Track the source of the final image
// Only update if the banner actually changed to avoid unnecessary re-renders
if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) {
setBannerImage(finalBanner);
setBannerSource(bannerSourceType); // Track the source of the final image
logger.log(`[useMetadataAssets:Banner] Banner updated from ${bannerImage} to ${finalBanner}`);
} else {
logger.log(`[useMetadataAssets:Banner] Banner unchanged, skipping update`);
}
forcedBannerRefreshDone.current = true; // Mark this cycle as complete
} catch (error) {
logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error);
// Ensure fallback to default even on outer error
const defaultBanner = metadata?.banner || metadata?.poster || null;
setBannerImage(defaultBanner);
setBannerSource('default');
logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`);
// Only set if it's different from current banner
if (defaultBanner !== bannerImage) {
setBannerImage(defaultBanner);
setBannerSource('default');
logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`);
} else {
logger.log(`[useMetadataAssets:Banner] Default banner already set, skipping update`);
}
} finally {
logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`);
setLoadingBanner(false);

View file

@ -34,6 +34,9 @@ export interface AppSettings {
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
enableInternalProviders: boolean; // Toggle for internal providers like HDRezka
episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards
autoplayBestStream: boolean; // Automatically play the best available stream
}
export const DEFAULT_SETTINGS: AppSettings = {
@ -50,6 +53,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
logoSourcePreference: 'metahub', // Default to Metahub as first source
tmdbLanguagePreference: 'en', // Default to English
enableInternalProviders: true, // Enable internal providers by default
episodeLayoutStyle: 'horizontal', // Default to the new horizontal layout
autoplayBestStream: false, // Disabled by default for user choice
};
const SETTINGS_STORAGE_KEY = 'app_settings';

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 { traktService, TraktUser, TraktWatchedItem } from '../services/traktService';
import { AppState, AppStateStatus } from 'react-native';
import {
traktService,
TraktUser,
TraktWatchedItem,
TraktWatchlistItem,
TraktCollectionItem,
TraktRatingItem,
TraktContentData,
TraktPlaybackItem
} from '../services/traktService';
import { storageService } from '../services/storageService';
import { logger } from '../utils/logger';
export function useTraktIntegration() {
@ -8,19 +19,30 @@ export function useTraktIntegration() {
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
const [watchedMovies, setWatchedMovies] = 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());
// Check authentication status
const checkAuthStatus = useCallback(async () => {
logger.log('[useTraktIntegration] checkAuthStatus called');
setIsLoading(true);
try {
const authenticated = await traktService.isAuthenticated();
logger.log(`[useTraktIntegration] Authentication check result: ${authenticated}`);
setIsAuthenticated(authenticated);
if (authenticated) {
logger.log('[useTraktIntegration] User is authenticated, fetching profile...');
const profile = await traktService.getUserProfile();
logger.log(`[useTraktIntegration] User profile: ${profile.username}`);
setUserProfile(profile);
} else {
logger.log('[useTraktIntegration] User is not authenticated');
setUserProfile(null);
}
@ -46,8 +68,8 @@ export function useTraktIntegration() {
setIsLoading(true);
try {
const [movies, shows] = await Promise.all([
traktService.getWatchedMovies(),
traktService.getWatchedShows()
traktService.getWatchedMoviesWithImages(),
traktService.getWatchedShowsWithImages()
]);
setWatchedMovies(movies);
setWatchedShows(shows);
@ -58,6 +80,41 @@ export function useTraktIntegration() {
}
}, [isAuthenticated]);
// Load all collections (watchlist, collection, continue watching, ratings)
const loadAllCollections = useCallback(async () => {
if (!isAuthenticated) return;
setIsLoading(true);
try {
const [
watchlistMovies,
watchlistShows,
collectionMovies,
collectionShows,
continueWatching,
ratings
] = await Promise.all([
traktService.getWatchlistMoviesWithImages(),
traktService.getWatchlistShowsWithImages(),
traktService.getCollectionMoviesWithImages(),
traktService.getCollectionShowsWithImages(),
traktService.getPlaybackProgressWithImages(),
traktService.getRatingsWithImages()
]);
setWatchlistMovies(watchlistMovies);
setWatchlistShows(watchlistShows);
setCollectionMovies(collectionMovies);
setCollectionShows(collectionShows);
setContinueWatching(continueWatching);
setRatedContent(ratings);
} catch (error) {
logger.error('[useTraktIntegration] Error loading all collections:', error);
} finally {
setIsLoading(false);
}
}, [isAuthenticated]);
// Check if a movie is watched
const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => {
if (!isAuthenticated) return false;
@ -128,6 +185,224 @@ export function useTraktIntegration() {
}
}, [isAuthenticated, loadWatchedItems]);
// Start watching content (scrobble start)
const startWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<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
useEffect(() => {
checkAuthStatus();
@ -140,18 +415,98 @@ export function useTraktIntegration() {
}
}, [isAuthenticated, loadWatchedItems]);
// Auto-sync when authenticated changes OR when auth status is refreshed
useEffect(() => {
if (isAuthenticated) {
// Fetch Trakt progress and merge with local
logger.log('[useTraktIntegration] User authenticated, fetching Trakt progress to replace local data');
fetchAndMergeTraktProgress().then((success) => {
if (success) {
logger.log('[useTraktIntegration] Trakt progress merged successfully - local data replaced with Trakt data');
} else {
logger.warn('[useTraktIntegration] Failed to merge Trakt progress');
}
// Small delay to ensure storage subscribers are notified
setTimeout(() => {
logger.log('[useTraktIntegration] Trakt progress merge completed, UI should refresh');
}, 100);
});
}
}, [isAuthenticated, fetchAndMergeTraktProgress]);
// App focus sync - sync when app comes back into focus (much smarter than periodic)
useEffect(() => {
if (!isAuthenticated) return;
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
logger.log('[useTraktIntegration] App became active, syncing Trakt data');
fetchAndMergeTraktProgress().then((success) => {
if (success) {
logger.log('[useTraktIntegration] App focus sync completed successfully');
}
}).catch(error => {
logger.error('[useTraktIntegration] App focus sync failed:', error);
});
}
};
const subscription = AppState.addEventListener('change', handleAppStateChange);
return () => {
subscription?.remove();
};
}, [isAuthenticated, fetchAndMergeTraktProgress]);
// Trigger sync when auth status is manually refreshed (for login scenarios)
useEffect(() => {
if (isAuthenticated) {
logger.log('[useTraktIntegration] Auth status refresh detected, triggering Trakt progress merge');
fetchAndMergeTraktProgress().then((success) => {
if (success) {
logger.log('[useTraktIntegration] Trakt progress merged after manual auth refresh');
}
});
}
}, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]);
// Manual force sync function for testing/troubleshooting
const forceSyncTraktProgress = useCallback(async (): Promise<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 {
isAuthenticated,
isLoading,
userProfile,
watchedMovies,
watchedShows,
watchlistMovies,
watchlistShows,
collectionMovies,
collectionShows,
continueWatching,
ratedContent,
checkAuthStatus,
loadWatchedItems,
loadAllCollections,
isMovieWatched,
isEpisodeWatched,
markMovieAsWatched,
markEpisodeAsWatched,
refreshAuthStatus
refreshAuthStatus,
startWatching,
updateProgress,
stopWatching,
syncProgress, // legacy
getTraktPlaybackProgress,
syncAllProgress,
fetchAndMergeTraktProgress,
forceSyncTraktProgress // For manual testing
};
}

View file

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

View file

@ -21,7 +21,7 @@ import DiscoverScreen from '../screens/DiscoverScreen';
import LibraryScreen from '../screens/LibraryScreen';
import SettingsScreen from '../screens/SettingsScreen';
import MetadataScreen from '../screens/MetadataScreen';
import VideoPlayer from '../screens/VideoPlayer';
import VideoPlayer from '../components/player/VideoPlayer';
import CatalogScreen from '../screens/CatalogScreen';
import AddonsScreen from '../screens/AddonsScreen';
import SearchScreen from '../screens/SearchScreen';
@ -39,6 +39,7 @@ import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
import LogoSourceSettings from '../screens/LogoSourceSettings';
import ThemeScreen from '../screens/ThemeScreen';
import ProfilesScreen from '../screens/ProfilesScreen';
import InternalProvidersSettings from '../screens/InternalProvidersSettings';
// Stack navigator types
export type RootStackParamList = {
@ -53,6 +54,7 @@ export type RootStackParamList = {
id: string;
type: string;
episodeId?: string;
addonId?: string;
};
Streams: {
id: string;
@ -74,9 +76,12 @@ export type RootStackParamList = {
quality?: string;
year?: number;
streamProvider?: string;
streamName?: string;
id?: string;
type?: string;
episodeId?: string;
imdbId?: string;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
};
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
Credits: { mediaId: string; mediaType: string };
@ -97,6 +102,7 @@ export type RootStackParamList = {
LogoSourceSettings: undefined;
ThemeSettings: undefined;
ProfilesSettings: undefined;
InternalProvidersSettings: undefined;
};
export type RootStackNavigationProp = NativeStackNavigationProp<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
const AppNavigator = () => {
const { currentTheme } = useTheme();
// Handle Android-specific optimizations
useEffect(() => {
if (Platform.OS === 'android') {
// Ensure consistent background color for Android
StatusBar.setBackgroundColor('transparent', true);
StatusBar.setTranslucent(true);
}
}, []);
return (
<SafeAreaProvider>
<StatusBar
@ -669,213 +710,352 @@ const AppNavigator = () => {
barStyle="light-content"
/>
<PaperProvider theme={CustomDarkTheme}>
<Stack.Navigator
screenOptions={{
headerShown: false,
// Disable animations for smoother transitions
animation: 'none',
// Ensure content is not popping in and out
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
}
}}
>
<Stack.Screen
name="MainTabs"
component={MainTabs as any}
/>
<Stack.Screen
name="Metadata"
component={MetadataScreen as any}
/>
<Stack.Screen
name="Streams"
component={StreamsScreen as any}
options={{
headerShown: false,
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade_from_bottom',
...(Platform.OS === 'ios' && { presentation: 'modal' }),
}}
/>
<Stack.Screen
name="Player"
component={VideoPlayer as any}
/>
<Stack.Screen
name="Catalog"
component={CatalogScreen as any}
/>
<Stack.Screen
name="Addons"
component={AddonsScreen as any}
/>
<Stack.Screen
name="Search"
component={SearchScreen as any}
/>
<Stack.Screen
name="CatalogSettings"
component={CatalogSettingsScreen as any}
/>
<Stack.Screen
name="HomeScreenSettings"
component={HomeScreenSettings}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
<View style={{
flex: 1,
backgroundColor: currentTheme.colors.darkBackground,
...(Platform.OS === 'android' && {
// Prevent white flashes on Android
opacity: 1,
})
}}>
<Stack.Navigator
screenOptions={{
headerShown: false,
// Use slide_from_right for consistency and smooth transitions
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
// Ensure consistent background during transitions
contentStyle: {
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
name="HeroCatalogs"
component={HeroCatalogsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ShowRatings"
component={ShowRatingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
...(Platform.OS === 'ios' && { presentation: 'modal' }),
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: 'transparent',
},
}}
/>
<Stack.Screen
name="Calendar"
component={CalendarScreen as any}
/>
<Stack.Screen
name="NotificationSettings"
component={NotificationSettingsScreen as any}
/>
<Stack.Screen
name="MDBListSettings"
component={MDBListSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TMDBSettings"
component={TMDBSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TraktSettings"
component={TraktSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="PlayerSettings"
component={PlayerSettingsScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="LogoSourceSettings"
component={LogoSourceSettings}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ThemeSettings"
component={ThemeScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ProfilesSettings"
component={ProfilesScreen}
options={{
animation: 'fade',
animationDuration: 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
>
<Stack.Screen
name="MainTabs"
component={MainTabs as any}
options={{
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Metadata"
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
name="Streams"
component={StreamsScreen as any}
options={{
headerShown: false,
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' }),
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Player"
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
name="Catalog"
component={CatalogScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Addons"
component={AddonsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="Search"
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
name="CatalogSettings"
component={CatalogSettingsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="HomeScreenSettings"
component={HomeScreenSettings}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="HeroCatalogs"
component={HeroCatalogsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ShowRatings"
component={ShowRatingsScreen}
options={{
animation: Platform.OS === 'android' ? 'fade_from_bottom' : 'fade',
animationDuration: Platform.OS === 'android' ? 200 : 200,
...(Platform.OS === 'ios' && { presentation: 'modal' }),
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: 'transparent',
},
}}
/>
<Stack.Screen
name="Calendar"
component={CalendarScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="NotificationSettings"
component={NotificationSettingsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="MDBListSettings"
component={MDBListSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TMDBSettings"
component={TMDBSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="TraktSettings"
component={TraktSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="PlayerSettings"
component={PlayerSettingsScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="LogoSourceSettings"
component={LogoSourceSettings}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ThemeSettings"
component={ThemeScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
animationDuration: Platform.OS === 'android' ? 250 : 200,
presentation: 'card',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="ProfilesSettings"
component={ProfilesScreen}
options={{
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
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',
gestureEnabled: true,
gestureDirection: 'horizontal',
headerShown: false,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
</Stack.Navigator>
</View>
</PaperProvider>
</SafeAreaProvider>
);

View file

@ -29,7 +29,9 @@ import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { BlurView } from 'expo-blur';
import { BlurView as ExpoBlurView } from 'expo-blur';
import { BlurView as CommunityBlurView } from '@react-native-community/blur';
import Constants, { ExecutionEnvironment } from 'expo-constants';
import axios from 'axios';
import { useTheme } from '../contexts/ThemeContext';
@ -552,6 +554,36 @@ const createStyles = (colors: any) => StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
},
blurOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0,0,0,0.4)',
},
androidBlurContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
androidBlur: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
},
androidFallbackBlur: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'black',
},
});
const AddonsScreen = () => {
@ -1233,7 +1265,24 @@ const AddonsScreen = () => {
setAddonDetails(null);
}}
>
<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}>
{addonDetails && (
<>
@ -1332,7 +1381,7 @@ const AddonsScreen = () => {
</>
)}
</View>
</BlurView>
</View>
</Modal>
</SafeAreaView>
);

View file

@ -41,9 +41,38 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
// Screen dimensions and grid layout
const { width } = Dimensions.get('window');
const NUM_COLUMNS = 3;
// Dynamic column calculation based on screen width
const calculateCatalogLayout = (screenWidth: number) => {
const MIN_ITEM_WIDTH = 120; // Increased minimum for better readability
const MAX_ITEM_WIDTH = 160; // Adjusted maximum
const HORIZONTAL_PADDING = SPACING.lg * 2; // Total horizontal padding
const ITEM_SPACING = SPACING.sm; // Space between items
// Calculate how many columns can fit
const availableWidth = screenWidth - HORIZONTAL_PADDING;
const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING));
// Limit to reasonable number of columns (2-4 for better UX)
const numColumns = Math.min(Math.max(maxColumns, 2), 4);
// Calculate actual item width with proper spacing
const totalSpacing = ITEM_SPACING * (numColumns - 1);
const itemWidth = (availableWidth - totalSpacing) / numColumns;
// For 2 columns, ensure we use the full available width
const finalItemWidth = numColumns === 2 ? itemWidth : Math.min(itemWidth, MAX_ITEM_WIDTH);
return {
numColumns,
itemWidth: finalItemWidth
};
};
const catalogLayout = calculateCatalogLayout(width);
const NUM_COLUMNS = catalogLayout.numColumns;
const ITEM_MARGIN = SPACING.sm;
const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS;
const ITEM_WIDTH = catalogLayout.itemWidth;
// Create a styles creator function that accepts the theme colors
const createStyles = (colors: any) => StyleSheet.create({
@ -79,13 +108,9 @@ const createStyles = (colors: any) => StyleSheet.create({
padding: SPACING.lg,
paddingTop: SPACING.sm,
},
columnWrapper: {
justifyContent: 'space-between',
},
item: {
width: ITEM_WIDTH,
marginBottom: SPACING.lg,
borderRadius: 12,
borderRadius: 8,
overflow: 'hidden',
backgroundColor: colors.elevation2,
shadowColor: '#000',
@ -97,8 +122,8 @@ const createStyles = (colors: any) => StyleSheet.create({
poster: {
width: '100%',
aspectRatio: 2/3,
borderTopLeftRadius: 12,
borderTopRightRadius: 12,
borderTopLeftRadius: 8,
borderTopRightRadius: 8,
backgroundColor: colors.elevation3,
},
itemContent: {
@ -168,13 +193,60 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
const [actualCatalogName, setActualCatalogName] = useState<string | null>(null);
const { currentTheme } = useTheme();
const colors = currentTheme.colors;
const styles = createStyles(colors);
const isDarkMode = true;
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
const displayName = getCustomName(addonId || '', type || '', id || '', originalName || '');
// Create display name with proper type suffix
const createDisplayName = (catalogName: string) => {
if (!catalogName) return '';
// Check if the name already includes content type indicators
const lowerName = catalogName.toLowerCase();
const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
// If the name already contains type information, return as is
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
return catalogName;
}
// Otherwise append the content type
return `${catalogName} ${contentType}`;
};
// Use actual catalog name if available, otherwise fallback to custom name or original name
const displayName = actualCatalogName
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
// Add effect to get the actual catalog name from addon manifest
useEffect(() => {
const getActualCatalogName = async () => {
if (addonId && type && id) {
try {
const manifests = await stremioService.getInstalledAddonsAsync();
const addon = manifests.find(a => a.id === addonId);
if (addon && addon.catalogs) {
const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
if (catalog && catalog.name) {
setActualCatalogName(catalog.name);
}
}
} catch (error) {
logger.error('Failed to get actual catalog name:', error);
}
}
};
getActualCatalogName();
}, [addonId, type, id]);
// Add effect to get data source preference when component mounts
useEffect(() => {
@ -415,11 +487,23 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
}
}, [loading, hasMore, page, loadItems]);
const renderItem = useCallback(({ item }: { item: Meta }) => {
const renderItem = useCallback(({ item, index }: { item: Meta; index: number }) => {
// Calculate if this is the last item in a row
const isLastInRow = (index + 1) % NUM_COLUMNS === 0;
// For 2-column layout, ensure proper spacing
const rightMargin = isLastInRow ? 0 : SPACING.sm;
return (
<TouchableOpacity
style={styles.item}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
style={[
styles.item,
{
marginRight: rightMargin,
// For 2 columns, ensure items fill the available space properly
width: NUM_COLUMNS === 2 ? ITEM_WIDTH : ITEM_WIDTH
}
]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })}
activeOpacity={0.7}
>
<Image
@ -443,7 +527,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
</View>
</TouchableOpacity>
);
}, [navigation, styles]);
}, [navigation, styles, NUM_COLUMNS, ITEM_WIDTH]);
const renderEmptyState = () => (
<View style={styles.centered}>
@ -542,6 +626,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
renderItem={renderItem}
keyExtractor={(item) => `${item.id}-${item.type}`}
numColumns={NUM_COLUMNS}
key={NUM_COLUMNS}
refreshControl={
<RefreshControl
refreshing={refreshing}
@ -560,7 +645,6 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
) : null
}
contentContainerStyle={styles.list}
columnWrapperStyle={styles.columnWrapper}
showsVerticalScrollIndicator={false}
/>
) : 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 {
View,
Text,
@ -6,7 +6,6 @@ import {
FlatList,
TouchableOpacity,
ActivityIndicator,
RefreshControl,
SafeAreaView,
StatusBar,
useColorScheme,
@ -16,12 +15,14 @@ import {
Platform,
Image,
Modal,
Pressable
Pressable,
Alert
} from 'react-native';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService';
import { stremioService } from '../services/stremioService';
import { Stream } from '../types/metadata';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@ -60,6 +61,7 @@ import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
import homeStyles, { sharedStyles } from '../styles/homeStyles';
import { useTheme } from '../contexts/ThemeContext';
import type { Theme } from '../contexts/ThemeContext';
import * as ScreenOrientation from 'expo-screen-orientation';
// Define interfaces for our data
interface Category {
@ -83,7 +85,7 @@ interface ContinueWatchingRef {
refresh: () => Promise<boolean>;
}
const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
const DropUpMenu = React.memo(({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
const translateY = useSharedValue(300);
const opacity = useSharedValue(0);
const isDarkMode = useColorScheme() === 'dark';
@ -98,9 +100,15 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
opacity.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(300, { duration: 300 });
}
// Cleanup animations when component unmounts
return () => {
opacity.value = 0;
translateY.value = 300;
};
}, [visible]);
const gesture = Gesture.Pan()
const gesture = useMemo(() => Gesture.Pan()
.onStart(() => {
// Store initial position if needed
})
@ -124,7 +132,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
translateY.value = withTiming(0, { duration: 300 });
opacity.value = withTiming(1, { duration: 200 });
}
});
}), [onClose]);
const overlayStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
@ -138,7 +146,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white,
}));
const menuOptions = [
const menuOptions = useMemo(() => [
{
icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
@ -159,7 +167,12 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
label: 'Share',
action: 'share'
}
];
], [item.inLibrary]);
const handleOptionSelect = useCallback((action: string) => {
onOptionSelect(action);
onClose();
}, [onOptionSelect, onClose]);
return (
<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)' },
index === menuOptions.length - 1 && styles.lastMenuOption
]}
onPress={() => {
onOptionSelect(option.action);
onClose();
}}
onPress={() => handleOptionSelect(option.action)}
>
<MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
@ -225,9 +235,9 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
</GestureHandlerRootView>
</Modal>
);
};
});
const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
const ContentItem = React.memo(({ item: initialItem, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
const [localItem, setLocalItem] = useState(initialItem);
const [isWatched, setIsWatched] = useState(false);
@ -256,8 +266,8 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
setIsWatched(prev => !prev);
break;
case 'playlist':
break;
case 'share':
// These options don't have implementations yet
break;
}
}, [localItem]);
@ -266,16 +276,20 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
setMenuVisible(false);
}, []);
// Only update localItem when initialItem changes
useEffect(() => {
setLocalItem(initialItem);
}, [initialItem]);
// Subscribe to library updates
useEffect(() => {
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
const isInLibrary = libraryItems.some(
libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type
);
setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
if (isInLibrary !== localItem.inLibrary) {
setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
}
});
return () => unsubscribe();
@ -330,15 +344,24 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
</View>
</TouchableOpacity>
<DropUpMenu
visible={menuVisible}
onClose={handleMenuClose}
item={localItem}
onOptionSelect={handleOptionSelect}
/>
{menuVisible && (
<DropUpMenu
visible={menuVisible}
onClose={handleMenuClose}
item={localItem}
onOptionSelect={handleOptionSelect}
/>
)}
</>
);
};
}, (prevProps, nextProps) => {
// Custom comparison function to prevent unnecessary re-renders
return (
prevProps.item.id === nextProps.item.id &&
prevProps.item.inLibrary === nextProps.item.inLibrary &&
prevProps.onPress === nextProps.onPress
);
});
// Sample categories (real app would get these from API)
const SAMPLE_CATEGORIES: Category[] = [
@ -347,7 +370,7 @@ const SAMPLE_CATEGORIES: Category[] = [
{ id: 'channel', name: 'Channels' },
];
const SkeletonCatalog = () => {
const SkeletonCatalog = React.memo(() => {
const { currentTheme } = useTheme();
return (
<View style={styles.catalogContainer}>
@ -356,7 +379,7 @@ const SkeletonCatalog = () => {
</View>
</View>
);
};
});
const HomeScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -364,17 +387,16 @@ const HomeScreen = () => {
const { currentTheme } = useTheme();
const continueWatchingRef = useRef<ContinueWatchingRef>(null);
const { settings } = useSettings();
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [hasContinueWatching, setHasContinueWatching] = useState(false);
const {
catalogs,
loading: catalogsLoading,
refreshing: catalogsRefreshing,
refreshCatalogs
} = useHomeCatalogs();
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
const [catalogsLoading, setCatalogsLoading] = useState(true);
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
const totalCatalogsRef = useRef(0);
const {
featuredContent,
@ -384,9 +406,119 @@ const HomeScreen = () => {
refreshFeatured
} = useFeaturedContent();
// Progressive catalog loading function
const loadCatalogsProgressively = useCallback(async () => {
setCatalogsLoading(true);
setCatalogs([]);
setLoadedCatalogCount(0);
try {
const addons = await catalogService.getAllAddons();
// Create placeholder array with proper order and track indices
const catalogPlaceholders: (CatalogContent | null)[] = [];
const catalogPromises: Promise<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
const isLoading = (showHeroSection ? featuredLoading : false) || catalogsLoading;
const isRefreshing = catalogsRefreshing;
// For catalogs, we show them progressively, so only show loading if no catalogs are loaded yet
const isLoading = useMemo(() =>
(showHeroSection ? featuredLoading : false) || (catalogsLoading && catalogs.length === 0),
[showHeroSection, featuredLoading, catalogsLoading, catalogs.length]
);
// React to settings changes
useEffect(() => {
@ -394,14 +526,26 @@ const HomeScreen = () => {
setFeaturedContentSource(settings.featuredContentSource);
}, [settings]);
// Load catalogs progressively on mount and when settings change
useEffect(() => {
loadCatalogsProgressively();
}, [loadCatalogsProgressively]);
// Listen for catalog changes (addon additions/removals) and reload catalogs
useEffect(() => {
loadCatalogsProgressively();
}, [lastUpdate, loadCatalogsProgressively]);
// Create a refresh function for catalogs
const refreshCatalogs = useCallback(() => {
return loadCatalogsProgressively();
}, [loadCatalogsProgressively]);
// Subscribe directly to settings emitter for immediate updates
useEffect(() => {
const handleSettingsChange = () => {
setShowHeroSection(settings.showHeroSection);
setFeaturedContentSource(settings.featuredContentSource);
// The featured content refresh is now handled by the useFeaturedContent hook
// No need to call refreshFeatured() here to avoid duplicate refreshes
};
// Subscribe to settings changes
@ -410,18 +554,6 @@ const HomeScreen = () => {
return unsubscribe;
}, [settings]);
// Update the featured content refresh logic to handle persistence
useEffect(() => {
// This effect was causing duplicate refreshes - it's now handled in useFeaturedContent
// We'll keep it just to sync the local state with settings
if (showHeroSection && featuredContentSource !== settings.featuredContentSource) {
// Just update the local state
setFeaturedContentSource(settings.featuredContentSource);
}
// No timeout needed since we're not refreshing here
}, [settings.featuredContentSource, showHeroSection]);
useFocusEffect(
useCallback(() => {
const statusBarConfig = () => {
@ -451,16 +583,15 @@ const HomeScreen = () => {
StatusBar.setTranslucent(false);
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
}
// Clean up any lingering timeouts
if (refreshTimeoutRef.current) {
clearTimeout(refreshTimeoutRef.current);
}
};
}, [currentTheme.colors.darkBackground]);
useEffect(() => {
navigation.addListener('beforeRemove', () => {});
return () => {
navigation.removeListener('beforeRemove', () => {});
};
}, [navigation]);
// Preload images function - memoized to avoid recreating on every render
const preloadImages = useCallback(async (content: StreamingContent[]) => {
if (!content.length) return;
@ -481,69 +612,120 @@ const HomeScreen = () => {
await Promise.all(imagePromises);
} catch (error) {
console.error('Error preloading images:', error);
// Silently handle preload errors
}
}, []);
const handleRefresh = useCallback(async () => {
try {
const refreshTasks = [
refreshCatalogs(),
continueWatchingRef.current?.refresh(),
];
// Only refresh featured content if hero section is enabled,
// and force refresh to bypass the cache
if (showHeroSection) {
refreshTasks.push(refreshFeatured());
}
await Promise.all(refreshTasks);
} catch (error) {
logger.error('Error during refresh:', error);
}
}, [refreshFeatured, refreshCatalogs, showHeroSection]);
const handleContentPress = useCallback((id: string, type: string) => {
navigation.navigate('Metadata', { id, type });
}, [navigation]);
const handlePlayStream = useCallback((stream: Stream) => {
const handlePlayStream = useCallback(async (stream: Stream) => {
if (!featuredContent) return;
navigation.navigate('Player', {
uri: stream.url,
title: featuredContent.name,
year: featuredContent.year,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
streamProvider: stream.name,
id: featuredContent.id,
type: featuredContent.type
});
try {
// Lock orientation to landscape before navigation to prevent glitches
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
// Small delay to ensure orientation is set before navigation
await new Promise(resolve => setTimeout(resolve, 100));
navigation.navigate('Player', {
uri: stream.url,
title: featuredContent.name,
year: featuredContent.year,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
streamProvider: stream.name,
id: featuredContent.id,
type: featuredContent.type
});
} catch (error) {
// Fallback: navigate anyway
navigation.navigate('Player', {
uri: stream.url,
title: featuredContent.name,
year: featuredContent.year,
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
streamProvider: stream.name,
id: featuredContent.id,
type: featuredContent.type
});
}
}, [featuredContent, navigation]);
const refreshContinueWatching = useCallback(async () => {
console.log('[HomeScreen] Refreshing continue watching...');
if (continueWatchingRef.current) {
try {
const hasContent = await continueWatchingRef.current.refresh();
console.log(`[HomeScreen] Continue watching has content: ${hasContent}`);
setHasContinueWatching(hasContent);
// Debug: Let's check what's in storage
const allProgress = await storageService.getAllWatchProgress();
console.log('[HomeScreen] All watch progress in storage:', Object.keys(allProgress).length, 'items');
console.log('[HomeScreen] Watch progress items:', allProgress);
// Check if any items are being filtered out due to >85% progress
let filteredCount = 0;
for (const [key, progress] of Object.entries(allProgress)) {
const progressPercent = (progress.currentTime / progress.duration) * 100;
if (progressPercent >= 85) {
filteredCount++;
console.log(`[HomeScreen] Filtered out ${key}: ${progressPercent.toFixed(1)}% complete`);
} else {
console.log(`[HomeScreen] Valid progress ${key}: ${progressPercent.toFixed(1)}% complete`);
}
}
console.log(`[HomeScreen] Filtered out ${filteredCount} completed items`);
} catch (error) {
console.error('[HomeScreen] Error refreshing continue watching:', error);
setHasContinueWatching(false);
}
} else {
console.log('[HomeScreen] Continue watching ref is null');
}
}, []);
useEffect(() => {
const handlePlaybackComplete = () => {
refreshContinueWatching();
};
const unsubscribe = navigation.addListener('focus', () => {
// Only refresh continue watching section on focus
refreshContinueWatching();
// Don't reload catalogs unless they haven't been loaded yet
// Catalogs will be refreshed through context updates when addons change
if (catalogs.length === 0 && !catalogsLoading) {
loadCatalogsProgressively();
}
});
return () => {
unsubscribe();
};
}, [navigation, refreshContinueWatching]);
return unsubscribe;
}, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
if (isLoading && !isRefreshing) {
// Memoize the loading screen to prevent unnecessary re-renders
const renderLoadingScreen = useMemo(() => {
if (isLoading) {
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<View style={styles.loadingMainContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>Loading your content...</Text>
</View>
</View>
);
}
return null;
}, [isLoading, currentTheme.colors]);
// Memoize the main content section
const renderMainContent = useMemo(() => {
if (isLoading) return null;
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
@ -551,63 +733,73 @@ const HomeScreen = () => {
backgroundColor="transparent"
translucent
/>
<View style={styles.loadingMainContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>Loading your content...</Text>
</View>
</View>
);
}
<ScrollView
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
]}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
>
{showHeroSection && (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
)}
return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar
barStyle="light-content"
backgroundColor="transparent"
translucent
/>
<ScrollView
refreshControl={
<RefreshControl
refreshing={isRefreshing}
onRefresh={handleRefresh}
tintColor={currentTheme.colors.primary}
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
/>
}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
]}
showsVerticalScrollIndicator={false}
>
{showHeroSection && (
<FeaturedContent
key={`featured-${showHeroSection}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
)}
<Animated.View entering={FadeIn.duration(400).delay(150)}>
<ThisWeekSection />
</Animated.View>
<Animated.View entering={FadeIn.duration(400).delay(150)}>
<ThisWeekSection />
</Animated.View>
<ContinueWatchingSection ref={continueWatchingRef} />
{hasContinueWatching && (
<Animated.View entering={FadeIn.duration(400).delay(250)}>
<ContinueWatchingSection ref={continueWatchingRef} />
</Animated.View>
)}
{/* 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>
);
})}
{catalogs.length > 0 ? (
catalogs.map((catalog, index) => (
<View key={`${catalog.addon}-${catalog.id}-${index}`}>
<CatalogSection catalog={catalog} />
{/* 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>
))
) : (
!catalogsLoading && (
)}
{/* Show empty state only if all catalogs are loaded and none are available */}
{!catalogsLoading && catalogs.length === 0 && (
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
@ -621,33 +813,122 @@ const HomeScreen = () => {
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)
)}
</ScrollView>
</View>
);
)}
</ScrollView>
</View>
);
}, [
isLoading,
currentTheme.colors,
showHeroSection,
featuredContent,
isSaved,
handleSaveToLibrary,
hasContinueWatching,
catalogs,
catalogsLoading,
navigation,
featuredContentSource
]);
return isLoading ? renderLoadingScreen : renderMainContent;
};
const { width, height } = Dimensions.get('window');
const POSTER_WIDTH = (width - 50) / 3;
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
// Calculate available width for posters (reserve space for left padding)
const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
// We'll use minimal right padding (8px) to maximize space
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
console.log(`[HomeScreen] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
console.log(`[HomeScreen] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
return {
numFullPosters: bestLayout.numFullPosters,
posterWidth: bestLayout.posterWidth,
spacing: SPACING,
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
};
};
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const styles = StyleSheet.create<any>({
container: {
flex: 1,
},
scrollContent: {
paddingBottom: 40,
paddingBottom: 90,
},
loadingMainContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingBottom: 40,
paddingBottom: 90,
},
loadingText: {
marginTop: 12,
fontSize: 14,
},
loadingMoreCatalogs: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
marginHorizontal: 16,
marginBottom: 16,
},
loadingMoreText: {
marginLeft: 12,
fontSize: 14,
},
catalogPlaceholder: {
marginBottom: 24,
paddingHorizontal: 16,
},
placeholderHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
placeholderTitle: {
width: 150,
height: 20,
borderRadius: 4,
},
placeholderPosters: {
flexDirection: 'row',
gap: 8,
},
placeholderPoster: {
width: POSTER_WIDTH,
aspectRatio: 2/3,
borderRadius: 4,
},
emptyCatalog: {
padding: 32,
alignItems: 'center',
@ -810,19 +1091,19 @@ const styles = StyleSheet.create<any>({
position: 'relative',
},
catalogTitle: {
fontSize: 18,
fontWeight: '800',
textTransform: 'uppercase',
letterSpacing: 0.5,
marginBottom: 6,
fontSize: 19,
fontWeight: '700',
letterSpacing: 0.2,
marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
bottom: -4,
bottom: -2,
left: 0,
width: 60,
height: 3,
borderRadius: 1.5,
width: 35,
height: 2,
borderRadius: 1,
opacity: 0.8,
},
seeAllButton: {
flexDirection: 'row',
@ -837,7 +1118,8 @@ const styles = StyleSheet.create<any>({
marginRight: 4,
},
catalogList: {
paddingHorizontal: 16,
paddingLeft: 16,
paddingRight: 16 - posterLayout.partialPosterWidth,
paddingBottom: 12,
paddingTop: 6,
},
@ -845,21 +1127,21 @@ const styles = StyleSheet.create<any>({
width: POSTER_WIDTH,
aspectRatio: 2/3,
margin: 0,
borderRadius: 16,
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
elevation: 8,
elevation: 6,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.08)',
shadowOffset: { width: 0, height: 3 },
shadowOpacity: 0.25,
shadowRadius: 6,
borderWidth: 0.5,
borderColor: 'rgba(255,255,255,0.12)',
},
poster: {
width: '100%',
height: '100%',
borderRadius: 16,
borderRadius: 4,
},
imdbLogo: {
width: 35,
@ -898,7 +1180,7 @@ const styles = StyleSheet.create<any>({
contentItemContainer: {
width: '100%',
height: '100%',
borderRadius: 16,
borderRadius: 4,
overflow: 'hidden',
position: 'relative',
},
@ -1009,7 +1291,7 @@ const styles = StyleSheet.create<any>({
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 16,
borderRadius: 8,
},
featuredImage: {
width: '100%',
@ -1045,4 +1327,4 @@ const styles = StyleSheet.create<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'
);
// TMDB Language Preference
const [selectedTmdbLanguage, setSelectedTmdbLanguage] = useState<string>(
settings.tmdbLanguagePreference || 'en'
);
// Make sure logoSource stays in sync with settings
useEffect(() => {
setLogoSource(settings.logoSourcePreference || 'metahub');
}, [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
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}`);
// Get preferred language directly from settings
const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en';
// Get TMDB logo and banner
try {
const apiKey = TMDB_API_KEY;
@ -451,15 +417,15 @@ const LogoSourceSettings = () => {
// Find initial logo (prefer selectedTmdbLanguage, then 'en')
let initialLogoPath: string | null = null;
let initialLanguage = selectedTmdbLanguage;
let initialLanguage = preferredTmdbLanguage;
// 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) {
initialLogoPath = preferredLogo.file_path;
initialLanguage = selectedTmdbLanguage;
logger.log(`[LogoSourceSettings] Found initial ${selectedTmdbLanguage} TMDB logo for ${show.name}`);
initialLanguage = preferredTmdbLanguage;
logger.log(`[LogoSourceSettings] Found initial ${preferredTmdbLanguage} TMDB logo for ${show.name}`);
} else {
// Fallback to English logo
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) {
setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`);
setSelectedTmdbLanguage(initialLanguage); // Set selected language based on found logo
} else {
logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`);
}
@ -588,9 +553,6 @@ const LogoSourceSettings = () => {
// Handle TMDB language selection
const handleTmdbLanguageSelect = (languageCode: string) => {
// First set local state for immediate UI updates
setSelectedTmdbLanguage(languageCode);
// Update the preview logo if possible
if (tmdbLogosData) {
const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode);
@ -606,6 +568,9 @@ const LogoSourceSettings = () => {
saveLanguagePreference(languageCode);
};
// Get preferred language directly from settings for UI rendering
const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en';
// Save language preference with proper persistence
const saveLanguagePreference = async (languageCode: string) => {
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
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
await AsyncStorage.removeItem('_last_logos_');
@ -875,7 +812,7 @@ const LogoSourceSettings = () => {
key={langCode} // Use the unique code as key
style={[
styles.languageItem,
selectedTmdbLanguage === langCode && styles.selectedLanguageItem
preferredTmdbLanguage === langCode && styles.selectedLanguageItem
]}
onPress={() => handleTmdbLanguageSelect(langCode)}
activeOpacity={0.7}
@ -884,7 +821,7 @@ const LogoSourceSettings = () => {
<Text
style={[
styles.languageItemText,
selectedTmdbLanguage === langCode && styles.selectedLanguageItemText
preferredTmdbLanguage === langCode && styles.selectedLanguageItemText
]}
>
{(langCode || '').toUpperCase() || '??'}

View file

@ -540,7 +540,7 @@ const MDBListSettingsScreen = () => {
const openMDBListWebsite = () => {
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);
});
};

View file

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import {
View,
Text,
@ -24,36 +24,42 @@ import Animated, {
useAnimatedStyle,
interpolate,
Extrapolate,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
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 FloatingHeader from '../components/metadata/FloatingHeader';
import MetadataDetails from '../components/metadata/MetadataDetails';
import { useMetadataAnimations } from '../hooks/useMetadataAnimations';
import { useMetadataAssets } from '../hooks/useMetadataAssets';
import { useWatchProgress } from '../hooks/useWatchProgress';
import { TraktService, TraktPlaybackItem } from '../services/traktService';
const { height } = Dimensions.get('window');
const MetadataScreen = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
const MetadataScreen: React.FC = () => {
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
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();
// Get theme context
const { currentTheme } = useTheme();
// Get safe area insets
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 {
metadata,
loading,
@ -72,331 +78,357 @@ const MetadataScreen = () => {
loadingRecommendations,
setMetadata,
imdbId,
} = useMetadata({ id, type });
} = useMetadata({ id, type, addonId });
// Use our new hooks
const {
watchProgress,
getEpisodeDetails,
getPlayButtonText,
} = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
// Optimized hooks with memoization
const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress);
const {
bannerImage,
loadingBanner,
logoLoadError,
setLogoLoadError,
setBannerImage,
} = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
// Fetch and log Trakt progress data when entering the screen
useEffect(() => {
const fetchTraktProgress = async () => {
try {
const traktService = TraktService.getInstance();
const isAuthenticated = await traktService.isAuthenticated();
console.log(`[MetadataScreen] === TRAKT PROGRESS DATA FOR ${type.toUpperCase()}: ${metadata?.name || id} ===`);
console.log(`[MetadataScreen] IMDB ID: ${id}`);
console.log(`[MetadataScreen] Trakt authenticated: ${isAuthenticated}`);
if (!isAuthenticated) {
console.log(`[MetadataScreen] Not authenticated with Trakt, no progress data available`);
return;
}
const animations = useMetadataAnimations(safeAreaTop, watchProgress);
// 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;
}
// Add wrapper for toggleLibrary that includes haptic feedback
const handleToggleLibrary = useCallback(() => {
// Trigger appropriate haptic feedback based on action
if (inLibrary) {
// Removed from library - light impact
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
} else {
// Added to library - success feedback
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// 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();
}
// Call the original toggleLibrary function
}, [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();
}, [inLibrary, toggleLibrary]);
// Add wrapper for season change with distinctive haptic feedback
const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => {
// Change to Light impact for a more subtle feedback
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
// Wait a tiny bit before changing season, making the feedback more noticeable
setTimeout(() => {
handleSeasonChange(seasonNumber);
}, 10);
handleSeasonChange(seasonNumber);
}, [handleSeasonChange]);
// Handler functions
const handleShowStreams = useCallback(() => {
const { watchProgress } = watchProgressData;
if (type === 'series') {
// If we have watch progress with an episodeId, use that
if (watchProgress?.episodeId) {
navigation.navigate('Streams', {
id,
type,
episodeId: watchProgress.episodeId
});
return;
}
const targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ?
(episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined);
// If we have a specific episodeId from route params, use that
if (episodeId) {
navigation.navigate('Streams', { id, type, episodeId });
return;
}
// 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 });
if (targetEpisodeId) {
navigation.navigate('Streams', { id, type, episodeId: targetEpisodeId });
return;
}
}
navigation.navigate('Streams', { id, type, episodeId });
}, [navigation, id, type, episodes, episodeId, watchProgress]);
const handleSelectCastMember = useCallback((castMember: any) => {
// Future implementation
}, []);
}, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]);
const handleEpisodeSelect = useCallback((episode: Episode) => {
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
navigation.navigate('Streams', {
id,
type,
episodeId
});
navigation.navigate('Streams', { id, type, episodeId });
}, [navigation, id, type]);
const handleBack = useCallback(() => {
navigation.goBack();
}, [navigation]);
const handleBack = useCallback(() => navigation.goBack(), [navigation]);
const handleSelectCastMember = useCallback(() => {}, []); // Simplified for performance
// Animated styles
const containerAnimatedStyle = useAnimatedStyle(() => ({
flex: 1,
transform: [{ scale: animations.screenScale.value }],
opacity: animations.screenOpacity.value
}));
// Ultra-optimized animated styles - minimal calculations
const containerStyle = useAnimatedStyle(() => ({
opacity: animations.screenOpacity.value,
}), []);
const contentAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ translateY: animations.contentTranslateY.value }],
opacity: interpolate(
animations.contentTranslateY.value,
[60, 0],
[0, 1],
Extrapolate.CLAMP
)
}));
const contentStyle = useAnimatedStyle(() => ({
opacity: animations.contentOpacity.value,
transform: [{ translateY: animations.uiElementsTranslateY.value }]
}), []);
if (loading) {
const transitionStyle = useAnimatedStyle(() => ({
opacity: transitionOpacity.value,
}), []);
const skeletonStyle = useAnimatedStyle(() => ({
opacity: skeletonOpacity.value,
}), []);
// Memoized error component for performance
const ErrorComponent = useMemo(() => {
if (!metadataError) return null;
return (
<SafeAreaView
style={[styles.container, {
backgroundColor: currentTheme.colors.darkBackground
}]}
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
edges={['bottom']}
>
<StatusBar
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"
/>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
<View style={styles.errorContainer}>
<MaterialIcons
name="error-outline"
size={64}
color={currentTheme.colors.textMuted}
/>
<Text style={[styles.errorText, {
color: currentTheme.colors.highEmphasis
}]}>
<MaterialIcons name="error-outline" size={64} color={currentTheme.colors.textMuted} />
<Text style={[styles.errorText, { color: currentTheme.colors.highEmphasis }]}>
{metadataError || 'Content not found'}
</Text>
<TouchableOpacity
style={[
styles.retryButton,
{ backgroundColor: currentTheme.colors.primary }
]}
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={loadMetadata}
>
<MaterialIcons
name="refresh"
size={20}
color={currentTheme.colors.white}
style={{ marginRight: 8 }}
/>
<MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} />
<Text style={styles.retryButtonText}>Try Again</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.backButton,
{ borderColor: currentTheme.colors.primary }
]}
style={[styles.backButton, { borderColor: currentTheme.colors.primary }]}
onPress={handleBack}
>
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>
Go Back
</Text>
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>Go Back</Text>
</TouchableOpacity>
</View>
</SafeAreaView>
);
}, [metadataError, currentTheme, loadMetadata, handleBack]);
// Show error if exists
if (metadataError || (!loading && !metadata)) {
return ErrorComponent;
}
return (
<SafeAreaView
style={[containerAnimatedStyle, styles.container, {
backgroundColor: currentTheme.colors.darkBackground
}]}
edges={['bottom']}
>
<StatusBar
translucent={true}
backgroundColor="transparent"
barStyle="light-content"
animated={true}
/>
<Animated.View style={containerAnimatedStyle}>
{/* Floating Header */}
<FloatingHeader
metadata={metadata}
logoLoadError={logoLoadError}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsY={animations.headerElementsY}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={setLogoLoadError}
/>
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
<View style={StyleSheet.absoluteFill}>
{/* Skeleton Loading Screen - with fade out transition */}
{showSkeleton && (
<Animated.View
style={[StyleSheet.absoluteFill, skeletonStyle]}
pointerEvents={metadata ? 'none' : 'auto'}
>
{/* Hero Section */}
<HeroSection
metadata={metadata}
bannerImage={bannerImage}
loadingBanner={loadingBanner}
logoLoadError={logoLoadError}
scrollY={animations.scrollY}
dampedScrollY={animations.dampedScrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
heroScale={animations.heroScale}
logoOpacity={animations.logoOpacity}
logoScale={animations.logoScale}
genresOpacity={animations.genresOpacity}
genresTranslateY={animations.genresTranslateY}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressScaleY={animations.watchProgressScaleY}
watchProgress={watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
id={id}
navigation={navigation}
getPlayButtonText={getPlayButtonText}
setBannerImage={setBannerImage}
setLogoLoadError={setLogoLoadError}
/>
<MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} />
</Animated.View>
)}
{/* Main Content */}
<Animated.View style={contentAnimatedStyle}>
{/* Metadata Details */}
<MetadataDetails
{/* Main Content - with fade in transition */}
{metadata && (
<Animated.View
style={[StyleSheet.absoluteFill, transitionStyle]}
pointerEvents={metadata ? 'auto' : 'none'}
>
<SafeAreaView
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
edges={['bottom']}
>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
{/* Floating Header - Optimized */}
<FloatingHeader
metadata={metadata}
imdbId={imdbId}
type={type as 'movie' | 'series'}
renderRatings={() => imdbId ? (
<RatingsSection
imdbId={imdbId}
type={type === 'series' ? 'show' : 'movie'}
/>
) : null}
logoLoadError={assetData.logoLoadError}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={assetData.setLogoLoadError}
/>
{/* Cast Section */}
<CastSection
cast={cast}
loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember}
/>
{/* More Like This Section - Only for movies */}
{type === 'movie' && (
<MoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
/>
)}
{/* Type-specific content */}
{type === 'series' ? (
<SeriesContent
episodes={episodes}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
bounces={false}
overScrollMode="never"
contentContainerStyle={styles.scrollContent}
>
{/* Hero Section - Optimized */}
<HeroSection
metadata={metadata}
bannerImage={assetData.bannerImage}
loadingBanner={assetData.loadingBanner}
logoLoadError={assetData.logoLoadError}
scrollY={animations.scrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
logoOpacity={animations.logoOpacity}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgressData.watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={watchProgressData.getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
id={id}
navigation={navigation}
getPlayButtonText={watchProgressData.getPlayButtonText}
setBannerImage={assetData.setBannerImage}
setLogoLoadError={assetData.setLogoLoadError}
/>
) : (
<MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</Animated.View>
</SafeAreaView>
{/* Main Content - Optimized */}
<Animated.View style={contentStyle}>
<MetadataDetails
metadata={metadata}
imdbId={imdbId}
type={type as 'movie' | 'series'}
renderRatings={() => imdbId ? (
<RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} />
) : null}
/>
<CastSection
cast={cast}
loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember}
/>
{type === 'movie' && (
<MoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
/>
)}
{type === 'series' ? (
<SeriesContent
episodes={episodes}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}
onSelectEpisode={handleEpisodeSelect}
groupedEpisodes={groupedEpisodes}
metadata={metadata || undefined}
/>
) : (
metadata && <MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</SafeAreaView>
</Animated.View>
)}
</View>
);
};
// Optimized styles with minimal properties
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'transparent',
paddingTop: 0,
},
scrollView: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: 16,
},
loadingText: {
marginTop: 16,
fontSize: 16,
scrollContent: {
flexGrow: 1,
},
errorContainer: {
flex: 1,
@ -422,13 +454,13 @@ const styles = StyleSheet.create({
retryButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
backButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 20,
paddingHorizontal: 24,
paddingVertical: 12,
borderRadius: 24,
borderWidth: 2,
},
backButtonText: {
fontSize: 16,

View file

@ -8,6 +8,7 @@ import {
Platform,
TouchableOpacity,
StatusBar,
Switch,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { useSettings, AppSettings } from '../hooks/useSettings';
@ -219,6 +220,68 @@ const PlayerSettingsScreen: React.FC = () => {
))}
</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>
</SafeAreaView>
);

View file

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

View file

@ -367,6 +367,51 @@ const SettingsScreen: React.FC = () => {
icon="palette"
renderControl={ChevronRight}
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}
/>
</SettingsCard>
@ -406,6 +451,13 @@ const SettingsScreen: React.FC = () => {
onPress={() => navigation.navigate('CatalogSettings')}
badge={catalogCount}
/>
<SettingItem
title="Internal Providers"
description="Enable or disable built-in providers like HDRezka"
icon="source"
renderControl={ChevronRight}
onPress={() => navigation.navigate('InternalProvidersSettings')}
/>
<SettingItem
title="Home Screen"
description="Customize layout and content"
@ -545,7 +597,7 @@ const styles = StyleSheet.create({
scrollContent: {
flexGrow: 1,
width: '100%',
paddingBottom: 32,
paddingBottom: 90,
},
cardContainer: {
width: '100%',
@ -645,19 +697,20 @@ const styles = StyleSheet.create({
borderRadius: 8,
overflow: 'hidden',
height: 36,
width: 160,
width: 180,
marginRight: 8,
},
selectorButton: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: 12,
paddingHorizontal: 8,
backgroundColor: 'rgba(255,255,255,0.08)',
},
selectorText: {
fontSize: 14,
fontSize: 13,
fontWeight: '500',
textAlign: 'center',
},
profileLockContainer: {
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 { logger } from '../utils/logger';
import { useTheme } from '../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const TMDBSettingsScreen = () => {
const navigation = useNavigation();
@ -41,6 +41,7 @@ const TMDBSettingsScreen = () => {
const [isInputFocused, setIsInputFocused] = useState(false);
const apiKeyInputRef = useRef<TextInput>(null);
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
useEffect(() => {
logger.log('[TMDBSettingsScreen] Component mounted');
@ -115,13 +116,12 @@ const TMDBSettingsScreen = () => {
const testApiKey = async (key: string): Promise<boolean> => {
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(
'https://api.themoviedb.org/3/configuration',
`https://api.themoviedb.org/3/configuration?api_key=${key}`,
{
method: 'GET',
headers: {
'Authorization': `Bearer ${key}`,
'Content-Type': 'application/json',
}
}
@ -223,277 +223,66 @@ const TMDBSettingsScreen = () => {
});
};
const styles = StyleSheet.create({
container: {
flex: 1,
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,
},
});
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
const headerHeight = headerBaseHeight + topSpacing;
if (isLoading) {
return (
<SafeAreaView style={styles.container}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.loadingContainer}>
<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>
</SafeAreaView>
</View>
);
}
return (
<SafeAreaView style={styles.container}>
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={styles.backText}>Settings</Text>
</TouchableOpacity>
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
</TouchableOpacity>
</View>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
TMDb Settings
</Text>
</View>
<Text style={styles.title}>TMDb Settings</Text>
<ScrollView
style={styles.scrollView}
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<View style={styles.switchCard}>
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<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>
<Switch
value={useCustomKey}
onValueChange={toggleUseCustomKey}
trackColor={{ false: currentTheme.colors.lightGray, true: currentTheme.colors.accentLight }}
thumbColor={Platform.OS === 'android' ? currentTheme.colors.primary : ''}
ios_backgroundColor={currentTheme.colors.lightGray}
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
ios_backgroundColor={'rgba(255,255,255,0.1)'}
/>
</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 && (
<>
<View style={styles.statusCard}>
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
<MaterialIcons
name={isKeySet ? "check-circle" : "error-outline"}
size={28}
@ -501,10 +290,10 @@ const TMDBSettingsScreen = () => {
style={styles.statusIconContainer}
/>
<View style={styles.statusTextContainer}>
<Text style={styles.statusTitle}>
<Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}>
{isKeySet ? "API Key Active" : "API Key Required"}
</Text>
<Text style={styles.statusDescription}>
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
{isKeySet
? "Your custom TMDb API key is set and active."
: "Add your TMDb API key below."}
@ -512,19 +301,26 @@ const TMDBSettingsScreen = () => {
</View>
</View>
<View style={styles.card}>
<Text style={styles.cardTitle}>API Key</Text>
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text>
<View style={styles.inputContainer}>
<TextInput
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}
onChangeText={(text) => {
setApiKey(text);
if (testResult) setTestResult(null);
}}
placeholder="Paste your TMDb API key (v4 auth)"
placeholderTextColor={currentTheme.colors.mediumGray}
placeholder="Paste your TMDb API key (v3)"
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
spellCheck={false}
@ -541,18 +337,18 @@ const TMDBSettingsScreen = () => {
<View style={styles.buttonRow}>
<TouchableOpacity
style={styles.button}
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
onPress={saveApiKey}
>
<Text style={styles.buttonText}>Save API Key</Text>
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
</TouchableOpacity>
{isKeySet && (
<TouchableOpacity
style={[styles.button, styles.clearButton]}
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
onPress={clearApiKey}
>
<Text style={[styles.buttonText, styles.clearButtonText]}>Clear</Text>
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
</TouchableOpacity>
)}
</View>
@ -560,7 +356,7 @@ const TMDBSettingsScreen = () => {
{testResult && (
<View style={[
styles.resultMessage,
testResult.success ? styles.successMessage : styles.errorMessage
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
]}>
<MaterialIcons
name={testResult.success ? "check-circle" : "error"}
@ -570,7 +366,7 @@ const TMDBSettingsScreen = () => {
/>
<Text style={[
styles.resultText,
testResult.success ? styles.successText : styles.errorText
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
]}>
{testResult.message}
</Text>
@ -582,16 +378,16 @@ const TMDBSettingsScreen = () => {
onPress={openTMDBWebsite}
>
<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?
</Text>
</TouchableOpacity>
</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} />
<Text style={styles.infoText}>
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.
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
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.
</Text>
</View>
@ -599,17 +395,226 @@ const TMDBSettingsScreen = () => {
)}
{!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} />
<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.
For better performance and reliability, consider using your own API key.
</Text>
</View>
)}
</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;

View file

@ -11,6 +11,8 @@ import {
ScrollView,
StatusBar,
Platform,
Linking,
Switch,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
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 TraktIcon from '../../assets/rating-icons/trakt.svg';
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;
@ -44,6 +49,21 @@ const TraktSettingsScreen: React.FC = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
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 () => {
setIsLoading(true);
@ -180,7 +200,7 @@ const TraktSettingsScreen: React.FC = () => {
</TouchableOpacity>
<Text style={[
styles.headerTitle,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark}
]}>
Trakt Settings
</Text>
@ -308,6 +328,8 @@ const TraktSettingsScreen: React.FC = () => {
Sync Settings
</Text>
<View style={styles.settingItem}>
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={[
styles.settingLabel,
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
@ -318,8 +340,19 @@ const TraktSettingsScreen: React.FC = () => {
styles.settingDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Coming soon
Automatically sync watch progress to Trakt
</Text>
</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}>
<Text style={[
@ -332,23 +365,43 @@ const TraktSettingsScreen: React.FC = () => {
styles.settingDescription,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Coming soon
Use "Sync Now" to import your watch history and progress from Trakt
</Text>
</View>
<TouchableOpacity
style={[
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' }]
);
}}
>
<Text style={[
styles.buttonText,
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
]}>
Sync Now (Coming Soon)
</Text>
{isSyncing ? (
<ActivityIndicator
size="small"
color={isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary}
/>
) : (
<Text style={[
styles.buttonText,
{ color: isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary }
]}>
Sync Now
</Text>
)}
</TouchableOpacity>
</View>
</View>
)}

File diff suppressed because it is too large Load diff

View file

@ -54,6 +54,22 @@ export interface StreamingContent {
directors?: string[];
creators?: 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 {
@ -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 up to 3 times with increasing delays
let meta = null;
@ -450,7 +466,7 @@ class CatalogService {
for (let i = 0; i < 3; i++) {
try {
meta = await stremioService.getMetaDetails(type, id);
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
if (meta) break;
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
} catch (error) {
@ -461,8 +477,8 @@ class CatalogService {
}
if (meta) {
// Add to recent content
const content = this.convertMetaToStreamingContent(meta);
// Add to recent content using enhanced conversion for full metadata
const content = this.convertMetaToStreamingContentEnhanced(meta);
this.addToRecentContent(content);
// 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 {
// Basic conversion for catalog display - no enhanced metadata processing
return {
id: meta.id,
type: meta.type,
@ -490,17 +553,70 @@ class CatalogService {
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
posterShape: 'poster',
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,
year: meta.year,
genres: meta.genres,
description: meta.description,
runtime: meta.runtime,
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 {
const items = Object.values(this.library);
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;
duration: number;
lastUpdated: number;
traktSynced?: boolean;
traktLastSynced?: number;
traktProgress?: number;
}
class StorageService {
@ -103,6 +106,142 @@ class StorageService {
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();

View file

@ -26,9 +26,35 @@ export interface Meta {
genres?: string[];
runtime?: string;
cast?: string[];
director?: string;
writer?: string;
director?: string | string[];
writer?: string | 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 {
@ -379,17 +405,20 @@ class StremioService {
return result;
}
private getAddonBaseURL(url: string): string {
// Remove trailing manifest.json if present
let baseUrl = url.replace(/manifest\.json$/, '').replace(/\/$/, '');
private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
// Extract query parameters if they exist
const [baseUrl, queryString] = url.split('?');
// Remove trailing manifest.json and slashes
let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
// Ensure URL has protocol
if (!baseUrl.startsWith('http')) {
baseUrl = `https://${baseUrl}`;
if (!cleanBaseUrl.startsWith('http')) {
cleanBaseUrl = `https://${cleanBaseUrl}`;
}
logger.log('Addon base URL:', baseUrl);
return baseUrl;
logger.log('Addon base URL:', cleanBaseUrl, queryString ? `with query: ${queryString}` : '');
return { baseUrl: cleanBaseUrl, queryParams: queryString };
}
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 () => {
return await axios.get(url);
});
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 [];
@ -431,7 +457,7 @@ class StremioService {
}
try {
const baseUrl = this.getAddonBaseURL(manifest.url);
const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
// Build the catalog URL
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 () => {
return await axios.get(url);
});
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 [];
@ -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 {
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
const cinemetaUrls = [
'https://v3-cinemeta.strem.io',
@ -478,44 +564,66 @@ class StremioService {
for (const baseUrl of cinemetaUrls) {
try {
const url = `${baseUrl}/meta/${type}/${id}.json`;
logger.log(`HTTP GET: ${url}`);
const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 });
});
if (response.data && response.data.meta) {
logger.log(`✅ Metadata fetched successfully from: ${url}`);
return response.data.meta;
}
} catch (error) {
logger.warn(`Failed to fetch meta from ${baseUrl}:`, error);
logger.warn(`Failed to fetch meta from ${baseUrl}:`, error);
continue; // Try next URL
}
}
// If Cinemeta fails, try other addons
const addons = this.getInstalledAddons();
// If Cinemeta fails, try other addons (excluding the preferred one already tried)
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(
resource => resource.name === 'meta' && resource.types.includes(type)
);
// Check if addon supports meta resource for this type (handles both string and object formats)
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 {
const baseUrl = this.getAddonBaseURL(addon.url || '');
const url = `${baseUrl}/meta/${type}/${id}.json`;
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
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 () => {
return await axios.get(url, { timeout: 10000 });
});
if (response.data && response.data.meta) {
logger.log(`✅ Metadata fetched successfully from: ${url}`);
return response.data.meta;
}
} catch (error) {
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
}
}
@ -612,8 +720,8 @@ class StremioService {
return;
}
const baseUrl = this.getAddonBaseURL(addon.url);
const url = `${baseUrl}/stream/${type}/${id}.json`;
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
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}`);
@ -656,8 +764,9 @@ class StremioService {
return null;
}
const baseUrl = this.getAddonBaseURL(addon.url);
const url = `${baseUrl}/stream/${type}/${id}.json`;
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
const streamPath = `/stream/${type}/${id}.json`;
const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`;
logger.log(`Fetching streams from URL: ${url}`);
@ -671,7 +780,7 @@ class StremioService {
timeout,
headers: {
'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
@ -868,7 +977,7 @@ class StremioService {
}
try {
const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || '');
const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || '').baseUrl;
// Construct the query URL with the correct format
// For series episodes, use the videoId directly which includes series ID + episode info
@ -930,6 +1039,29 @@ class StremioService {
}
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();

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';
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 HORIZONTAL_PADDING = 16;

View file

@ -55,6 +55,7 @@ const useDiscoverStyles = () => {
justifyContent: 'center',
alignItems: 'center',
paddingTop: 80,
paddingBottom: 90,
},
emptyText: {
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;
description?: string;
poster?: string;
posterShape?: string;
banner?: string;
logo?: string;
year?: string | number;
@ -88,12 +89,30 @@ export interface StreamingContent {
imdbRating?: string;
genres?: string[];
director?: string;
writer?: string;
writer?: string[];
cast?: string[];
releaseInfo?: string;
directors?: string[];
creators?: string[];
certification?: string;
released?: string;
trailerStreams?: any[];
videos?: any[];
inLibrary?: boolean;
// Enhanced metadata from addons
country?: string;
links?: Array<{
name: string;
category: string;
url: string;
}>;
behaviorHints?: {
defaultVideoId?: string;
hasScheduledVideos?: boolean;
[key: string]: any;
};
imdb_id?: string;
slug?: string;
}
// Navigation types

View file

@ -6,6 +6,7 @@ export type RootStackParamList = {
Metadata: {
id: string;
type: string;
addonId?: string;
};
Streams: {
id: string;
@ -27,6 +28,7 @@ export type RootStackParamList = {
url: string;
lang: string;
}>;
imdbId?: string;
};
Catalog: {
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",
"compilerOptions": {
"strict": true
"strict": true,
"jsx": "react-jsx",
"esModuleInterop": true
}
}