mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
commit
68a347d808
76 changed files with 19595 additions and 4029 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -36,3 +36,5 @@ yarn-error.*
|
|||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
plan.md
|
||||
release_announcement.md
|
||||
12
App.tsx
12
App.tsx
|
|
@ -5,7 +5,7 @@
|
|||
* @format
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet
|
||||
|
|
@ -24,6 +24,7 @@ import { CatalogProvider } from './src/contexts/CatalogContext';
|
|||
import { GenreProvider } from './src/contexts/GenreContext';
|
||||
import { TraktProvider } from './src/contexts/TraktContext';
|
||||
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
|
||||
import SplashScreen from './src/components/SplashScreen';
|
||||
|
||||
// This fixes many navigation layout issues by using native screen containers
|
||||
enableScreens(true);
|
||||
|
|
@ -31,6 +32,7 @@ enableScreens(true);
|
|||
// Inner app component that uses the theme context
|
||||
const ThemedApp = () => {
|
||||
const { currentTheme } = useTheme();
|
||||
const [isAppReady, setIsAppReady] = useState(false);
|
||||
|
||||
// Create custom themes based on current theme
|
||||
const customDarkTheme = {
|
||||
|
|
@ -50,6 +52,11 @@ const ThemedApp = () => {
|
|||
background: currentTheme.colors.darkBackground,
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for splash screen completion
|
||||
const handleSplashComplete = () => {
|
||||
setIsAppReady(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<PaperProvider theme={customDarkTheme}>
|
||||
|
|
@ -62,7 +69,8 @@ const ThemedApp = () => {
|
|||
<StatusBar
|
||||
style="light"
|
||||
/>
|
||||
<AppNavigator />
|
||||
{!isAppReady && <SplashScreen onFinish={handleSplashComplete} />}
|
||||
{isAppReady && <AppNavigator />}
|
||||
</View>
|
||||
</NavigationContainer>
|
||||
</PaperProvider>
|
||||
|
|
|
|||
16
app.json
16
app.json
|
|
@ -5,13 +5,13 @@
|
|||
"version": "1.0.0",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"userInterfaceStyle": "dark",
|
||||
"scheme": "stremioexpo",
|
||||
"newArchEnabled": true,
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
"backgroundColor": "#020404"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
|
|
@ -41,15 +41,21 @@
|
|||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#ffffff"
|
||||
"foregroundImage": "./assets/icon.png",
|
||||
"backgroundColor": "#020404",
|
||||
"monochromeImage": "./assets/icon.png"
|
||||
},
|
||||
"permissions": [
|
||||
"INTERNET",
|
||||
"WAKE_LOCK"
|
||||
],
|
||||
"package": "com.nuvio.app",
|
||||
"enableSplitAPKs": true
|
||||
"enableSplitAPKs": true,
|
||||
"versionCode": 1,
|
||||
"enableProguardInReleaseBuilds": true,
|
||||
"enableHermes": true,
|
||||
"enableSeparateBuildPerCPUArchitecture": true,
|
||||
"enableVectorDrawables": true
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
|
|
|
|||
119
components/AndroidVideoPlayer.tsx
Normal file
119
components/AndroidVideoPlayer.tsx
Normal 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;
|
||||
11
eas.json
11
eas.json
|
|
@ -12,7 +12,13 @@
|
|||
"distribution": "internal"
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true
|
||||
"autoIncrement": true,
|
||||
"extends": "apk",
|
||||
"android": {
|
||||
"buildType": "apk",
|
||||
"gradleCommand": ":app:assembleRelease",
|
||||
"image": "latest"
|
||||
}
|
||||
},
|
||||
"release": {
|
||||
"distribution": "store",
|
||||
|
|
@ -22,7 +28,8 @@
|
|||
},
|
||||
"apk": {
|
||||
"android": {
|
||||
"buildType": "apk"
|
||||
"buildType": "apk",
|
||||
"gradleCommand": ":app:assembleRelease"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
516
hdrezkas.js
Normal file
516
hdrezkas.js
Normal 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();
|
||||
|
|
@ -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
1634
package-lock.json
generated
File diff suppressed because it is too large
Load diff
12
package.json
12
package.json
|
|
@ -6,13 +6,13 @@
|
|||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"postinstall": "node patch-package.js"
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/metro-runtime": "~4.0.1",
|
||||
"@expo/vector-icons": "^14.1.0",
|
||||
"@gorhom/bottom-sheet": "^5.1.2",
|
||||
"@movie-web/providers": "^2.4.13",
|
||||
"@react-native-async-storage/async-storage": "1.23.1",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
"@react-native-community/slider": "^4.5.6",
|
||||
|
|
@ -25,8 +25,10 @@
|
|||
"@shopify/flash-list": "1.7.3",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
"axios": "^1.8.4",
|
||||
"axios": "^1.10.0",
|
||||
"base64-js": "^1.5.1",
|
||||
"cheerio": "^1.1.0",
|
||||
"cors": "^2.8.5",
|
||||
"date-fns": "^4.1.0",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"expo": "~52.0.43",
|
||||
|
|
@ -44,7 +46,10 @@
|
|||
"expo-status-bar": "~2.0.1",
|
||||
"expo-system-ui": "^4.0.9",
|
||||
"expo-web-browser": "~14.0.2",
|
||||
"express": "^5.1.0",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.6.7",
|
||||
"puppeteer": "^24.10.1",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.9",
|
||||
"react-native-awesome-slider": "^2.9.0",
|
||||
|
|
@ -61,6 +66,7 @@
|
|||
"react-native-tab-view": "^4.0.10",
|
||||
"react-native-url-polyfill": "^2.0.0",
|
||||
"react-native-video": "^6.12.0",
|
||||
"react-native-vlc-media-player": "^1.0.87",
|
||||
"react-native-web": "~0.19.13",
|
||||
"react-native-wheel-color-picker": "^1.3.1",
|
||||
"subsrt": "^1.1.1"
|
||||
|
|
|
|||
|
|
@ -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
434
scripts/test-hdrezka.js
Normal 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();
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
59
src/components/SplashScreen.tsx
Normal file
59
src/components/SplashScreen.tsx
Normal 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;
|
||||
|
|
@ -37,6 +37,7 @@ const CatalogsList = ({ catalogs, selectedCategory }: CatalogsListProps) => {
|
|||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
paddingVertical: 8,
|
||||
paddingBottom: 90,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
294
src/components/loading/MetadataLoadingScreen.tsx
Normal file
294
src/components/loading/MetadataLoadingScreen.tsx
Normal 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
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
1131
src/components/player/AndroidVideoPlayer.tsx
Normal file
1131
src/components/player/AndroidVideoPlayer.tsx
Normal file
File diff suppressed because it is too large
Load diff
1148
src/components/player/VideoPlayer.tsx
Normal file
1148
src/components/player/VideoPlayer.tsx
Normal file
File diff suppressed because it is too large
Load diff
228
src/components/player/controls/PlayerControls.tsx
Normal file
228
src/components/player/controls/PlayerControls.tsx
Normal 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;
|
||||
444
src/components/player/modals/AudioTrackModal.tsx
Normal file
444
src/components/player/modals/AudioTrackModal.tsx
Normal 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;
|
||||
125
src/components/player/modals/ResumeOverlay.tsx
Normal file
125
src/components/player/modals/ResumeOverlay.tsx
Normal 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;
|
||||
657
src/components/player/modals/SourcesModal.tsx
Normal file
657
src/components/player/modals/SourcesModal.tsx
Normal 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;
|
||||
1148
src/components/player/modals/SubtitleModals.tsx
Normal file
1148
src/components/player/modals/SubtitleModals.tsx
Normal file
File diff suppressed because it is too large
Load diff
29
src/components/player/subtitles/CustomSubtitles.tsx
Normal file
29
src/components/player/subtitles/CustomSubtitles.tsx
Normal 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;
|
||||
987
src/components/player/utils/playerStyles.ts
Normal file
987
src/components/player/utils/playerStyles.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
88
src/components/player/utils/playerTypes.ts
Normal file
88
src/components/player/utils/playerTypes.ts
Normal 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;
|
||||
}
|
||||
219
src/components/player/utils/playerUtils.ts
Normal file
219
src/components/player/utils/playerUtils.ts
Normal 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;
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
369
src/hooks/useTraktAutosync.ts
Normal file
369
src/hooks/useTraktAutosync.ts
Normal 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
|
||||
};
|
||||
}
|
||||
165
src/hooks/useTraktAutosyncSettings.ts
Normal file
165
src/hooks/useTraktAutosyncSettings.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
491
src/screens/InternalProvidersSettings.tsx
Normal file
491
src/screens/InternalProvidersSettings.tsx
Normal 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
|
|
@ -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() || '??'}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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;
|
||||
|
|
@ -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
|
|
@ -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));
|
||||
|
|
|
|||
543
src/services/hdrezkaService.ts
Normal file
543
src/services/hdrezkaService.ts
Normal 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();
|
||||
137
src/services/imageCacheService.ts
Normal file
137
src/services/imageCacheService.ts
Normal 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();
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
298
src/services/xprimeService.ts
Normal file
298
src/services/xprimeService.ts
Normal 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();
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
61
src/testHDRezka.js
Normal 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);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
2
src/types/navigation.d.ts
vendored
2
src/types/navigation.d.ts
vendored
|
|
@ -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
82
src/utils/posterUtils.ts
Normal 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);
|
||||
};
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue