mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-03 08:49:07 +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
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
plan.md
|
||||||
|
release_announcement.md
|
||||||
12
App.tsx
12
App.tsx
|
|
@ -5,7 +5,7 @@
|
||||||
* @format
|
* @format
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
StyleSheet
|
StyleSheet
|
||||||
|
|
@ -24,6 +24,7 @@ import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||||
import { GenreProvider } from './src/contexts/GenreContext';
|
import { GenreProvider } from './src/contexts/GenreContext';
|
||||||
import { TraktProvider } from './src/contexts/TraktContext';
|
import { TraktProvider } from './src/contexts/TraktContext';
|
||||||
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
|
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
|
||||||
|
import SplashScreen from './src/components/SplashScreen';
|
||||||
|
|
||||||
// This fixes many navigation layout issues by using native screen containers
|
// This fixes many navigation layout issues by using native screen containers
|
||||||
enableScreens(true);
|
enableScreens(true);
|
||||||
|
|
@ -31,6 +32,7 @@ enableScreens(true);
|
||||||
// Inner app component that uses the theme context
|
// Inner app component that uses the theme context
|
||||||
const ThemedApp = () => {
|
const ThemedApp = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const [isAppReady, setIsAppReady] = useState(false);
|
||||||
|
|
||||||
// Create custom themes based on current theme
|
// Create custom themes based on current theme
|
||||||
const customDarkTheme = {
|
const customDarkTheme = {
|
||||||
|
|
@ -51,6 +53,11 @@ const ThemedApp = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handler for splash screen completion
|
||||||
|
const handleSplashComplete = () => {
|
||||||
|
setIsAppReady(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PaperProvider theme={customDarkTheme}>
|
<PaperProvider theme={customDarkTheme}>
|
||||||
<NavigationContainer
|
<NavigationContainer
|
||||||
|
|
@ -62,7 +69,8 @@ const ThemedApp = () => {
|
||||||
<StatusBar
|
<StatusBar
|
||||||
style="light"
|
style="light"
|
||||||
/>
|
/>
|
||||||
<AppNavigator />
|
{!isAppReady && <SplashScreen onFinish={handleSplashComplete} />}
|
||||||
|
{isAppReady && <AppNavigator />}
|
||||||
</View>
|
</View>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
</PaperProvider>
|
</PaperProvider>
|
||||||
|
|
|
||||||
16
app.json
16
app.json
|
|
@ -5,13 +5,13 @@
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "dark",
|
||||||
"scheme": "stremioexpo",
|
"scheme": "stremioexpo",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#020404"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
|
|
@ -41,15 +41,21 @@
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#020404",
|
||||||
|
"monochromeImage": "./assets/icon.png"
|
||||||
},
|
},
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"INTERNET",
|
"INTERNET",
|
||||||
"WAKE_LOCK"
|
"WAKE_LOCK"
|
||||||
],
|
],
|
||||||
"package": "com.nuvio.app",
|
"package": "com.nuvio.app",
|
||||||
"enableSplitAPKs": true
|
"enableSplitAPKs": true,
|
||||||
|
"versionCode": 1,
|
||||||
|
"enableProguardInReleaseBuilds": true,
|
||||||
|
"enableHermes": true,
|
||||||
|
"enableSeparateBuildPerCPUArchitecture": true,
|
||||||
|
"enableVectorDrawables": true
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
|
|
|
||||||
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"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
"autoIncrement": true
|
"autoIncrement": true,
|
||||||
|
"extends": "apk",
|
||||||
|
"android": {
|
||||||
|
"buildType": "apk",
|
||||||
|
"gradleCommand": ":app:assembleRelease",
|
||||||
|
"image": "latest"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"release": {
|
"release": {
|
||||||
"distribution": "store",
|
"distribution": "store",
|
||||||
|
|
@ -22,7 +28,8 @@
|
||||||
},
|
},
|
||||||
"apk": {
|
"apk": {
|
||||||
"android": {
|
"android": {
|
||||||
"buildType": "apk"
|
"buildType": "apk",
|
||||||
|
"gradleCommand": ":app:assembleRelease"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
516
hdrezkas.js
Normal file
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');
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
|
||||||
module.exports = (() => {
|
|
||||||
const config = getDefaultConfig(__dirname);
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
const { transformer, resolver } = config;
|
// Enable tree shaking and better minification
|
||||||
|
|
||||||
config.transformer = {
|
config.transformer = {
|
||||||
...transformer,
|
...config.transformer,
|
||||||
babelTransformerPath: require.resolve('react-native-svg-transformer'),
|
babelTransformerPath: require.resolve('react-native-svg-transformer'),
|
||||||
minifierConfig: {
|
minifierConfig: {
|
||||||
|
ecma: 8,
|
||||||
|
keep_fnames: true,
|
||||||
|
mangle: {
|
||||||
|
keep_fnames: true,
|
||||||
|
},
|
||||||
compress: {
|
compress: {
|
||||||
// Remove console.* statements in release builds
|
|
||||||
drop_console: true,
|
drop_console: true,
|
||||||
// Keep error logging for critical issues
|
drop_debugger: true,
|
||||||
pure_funcs: ['console.info', 'console.log', 'console.debug', 'console.warn'],
|
pure_funcs: ['console.log', 'console.info', 'console.debug'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optimize resolver for better tree shaking and SVG support
|
||||||
config.resolver = {
|
config.resolver = {
|
||||||
...resolver,
|
...config.resolver,
|
||||||
assetExts: resolver.assetExts.filter((ext) => ext !== 'svg'),
|
assetExts: config.resolver.assetExts.filter((ext) => ext !== 'svg'),
|
||||||
sourceExts: [...resolver.sourceExts, '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",
|
"start": "expo start",
|
||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web",
|
"web": "expo start --web"
|
||||||
"postinstall": "node patch-package.js"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~4.0.1",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@gorhom/bottom-sheet": "^5.1.2",
|
"@gorhom/bottom-sheet": "^5.1.2",
|
||||||
|
"@movie-web/providers": "^2.4.13",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
"@react-native-community/blur": "^4.4.1",
|
"@react-native-community/blur": "^4.4.1",
|
||||||
"@react-native-community/slider": "^4.5.6",
|
"@react-native-community/slider": "^4.5.6",
|
||||||
|
|
@ -25,8 +25,10 @@
|
||||||
"@shopify/flash-list": "1.7.3",
|
"@shopify/flash-list": "1.7.3",
|
||||||
"@types/lodash": "^4.17.16",
|
"@types/lodash": "^4.17.16",
|
||||||
"@types/react-native-video": "^5.0.20",
|
"@types/react-native-video": "^5.0.20",
|
||||||
"axios": "^1.8.4",
|
"axios": "^1.10.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
|
"cheerio": "^1.1.0",
|
||||||
|
"cors": "^2.8.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"eventemitter3": "^5.0.1",
|
"eventemitter3": "^5.0.1",
|
||||||
"expo": "~52.0.43",
|
"expo": "~52.0.43",
|
||||||
|
|
@ -44,7 +46,10 @@
|
||||||
"expo-status-bar": "~2.0.1",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-system-ui": "^4.0.9",
|
"expo-system-ui": "^4.0.9",
|
||||||
"expo-web-browser": "~14.0.2",
|
"expo-web-browser": "~14.0.2",
|
||||||
|
"express": "^5.1.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
"node-fetch": "^2.6.7",
|
||||||
|
"puppeteer": "^24.10.1",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-native": "0.76.9",
|
"react-native": "0.76.9",
|
||||||
"react-native-awesome-slider": "^2.9.0",
|
"react-native-awesome-slider": "^2.9.0",
|
||||||
|
|
@ -61,6 +66,7 @@
|
||||||
"react-native-tab-view": "^4.0.10",
|
"react-native-tab-view": "^4.0.10",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"react-native-video": "^6.12.0",
|
"react-native-video": "^6.12.0",
|
||||||
|
"react-native-vlc-media-player": "^1.0.87",
|
||||||
"react-native-web": "~0.19.13",
|
"react-native-web": "~0.19.13",
|
||||||
"react-native-wheel-color-picker": "^1.3.1",
|
"react-native-wheel-color-picker": "^1.3.1",
|
||||||
"subsrt": "^1.1.1"
|
"subsrt": "^1.1.1"
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
headerContainer: {
|
||||||
height: Platform.OS === 'ios' ? 100 : 90,
|
height: Platform.OS === 'ios' ? 100 : 90,
|
||||||
paddingTop: Platform.OS === 'ios' ? 35 : 20,
|
paddingTop: Platform.OS === 'ios' ? 35 : 35,
|
||||||
backgroundColor: 'rgba(0,0,0,0.3)',
|
backgroundColor: 'rgba(0,0,0,0.3)',
|
||||||
},
|
},
|
||||||
blurOverlay: {
|
blurOverlay: {
|
||||||
|
|
|
||||||
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({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
paddingVertical: 8,
|
paddingVertical: 8,
|
||||||
|
paddingBottom: 90,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ const styles = StyleSheet.create({
|
||||||
marginHorizontal: 0,
|
marginHorizontal: 0,
|
||||||
},
|
},
|
||||||
posterContainer: {
|
posterContainer: {
|
||||||
borderRadius: 16,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||||
elevation: 5,
|
elevation: 5,
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,53 @@ interface CatalogSectionProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const POSTER_WIDTH = (width - 50) / 3;
|
|
||||||
|
// Dynamic poster calculation based on screen width - show 1/4 of next poster
|
||||||
|
const calculatePosterLayout = (screenWidth: number) => {
|
||||||
|
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
|
||||||
|
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
|
||||||
|
const LEFT_PADDING = 16; // Left padding
|
||||||
|
const SPACING = 8; // Space between posters
|
||||||
|
|
||||||
|
// Calculate available width for posters (reserve space for left padding)
|
||||||
|
const availableWidth = screenWidth - LEFT_PADDING;
|
||||||
|
|
||||||
|
// Try different numbers of full posters to find the best fit
|
||||||
|
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
|
||||||
|
|
||||||
|
for (let n = 3; n <= 6; n++) {
|
||||||
|
// Calculate poster width needed for N full posters + 0.25 partial poster
|
||||||
|
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
|
||||||
|
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
|
||||||
|
// We'll use minimal right padding (8px) to maximize space
|
||||||
|
const usableWidth = availableWidth - 8;
|
||||||
|
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
|
||||||
|
|
||||||
|
console.log(`[CatalogSection] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
|
||||||
|
|
||||||
|
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
|
||||||
|
bestLayout = { numFullPosters: n, posterWidth };
|
||||||
|
console.log(`[CatalogSection] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
numFullPosters: bestLayout.numFullPosters,
|
||||||
|
posterWidth: bestLayout.posterWidth,
|
||||||
|
spacing: SPACING,
|
||||||
|
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const posterLayout = calculatePosterLayout(width);
|
||||||
|
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||||
|
|
||||||
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
const handleContentPress = (id: string, type: string) => {
|
const handleContentPress = (id: string, type: string) => {
|
||||||
navigation.navigate('Metadata', { id, type });
|
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => {
|
const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => {
|
||||||
|
|
@ -73,18 +112,18 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||||
keyExtractor={(item) => `${item.id}-${item.type}`}
|
keyExtractor={(item) => `${item.id}-${item.type}`}
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.catalogList}
|
contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]}
|
||||||
snapToInterval={POSTER_WIDTH + 12}
|
snapToInterval={POSTER_WIDTH + 8}
|
||||||
decelerationRate="fast"
|
decelerationRate="fast"
|
||||||
snapToAlignment="start"
|
snapToAlignment="start"
|
||||||
ItemSeparatorComponent={() => <View style={{ width: 12 }} />}
|
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
|
||||||
initialNumToRender={4}
|
initialNumToRender={4}
|
||||||
maxToRenderPerBatch={4}
|
maxToRenderPerBatch={4}
|
||||||
windowSize={5}
|
windowSize={5}
|
||||||
removeClippedSubviews={Platform.OS === 'android'}
|
removeClippedSubviews={Platform.OS === 'android'}
|
||||||
getItemLayout={(data, index) => ({
|
getItemLayout={(data, index) => ({
|
||||||
length: POSTER_WIDTH + 12,
|
length: POSTER_WIDTH + 8,
|
||||||
offset: (POSTER_WIDTH + 12) * index,
|
offset: (POSTER_WIDTH + 8) * index,
|
||||||
index,
|
index,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|
@ -107,19 +146,19 @@ const styles = StyleSheet.create({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
catalogTitle: {
|
catalogTitle: {
|
||||||
fontSize: 18,
|
fontSize: 19,
|
||||||
fontWeight: '800',
|
fontWeight: '700',
|
||||||
textTransform: 'uppercase',
|
letterSpacing: 0.2,
|
||||||
letterSpacing: 0.5,
|
marginBottom: 4,
|
||||||
marginBottom: 6,
|
|
||||||
},
|
},
|
||||||
titleUnderline: {
|
titleUnderline: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: -4,
|
bottom: -2,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 60,
|
width: 35,
|
||||||
height: 3,
|
height: 2,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1,
|
||||||
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
seeAllButton: {
|
seeAllButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,46 @@ interface ContentItemProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const POSTER_WIDTH = (width - 50) / 3;
|
|
||||||
|
// Dynamic poster calculation based on screen width - show 1/4 of next poster
|
||||||
|
const calculatePosterLayout = (screenWidth: number) => {
|
||||||
|
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
|
||||||
|
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
|
||||||
|
const LEFT_PADDING = 16; // Left padding
|
||||||
|
const SPACING = 8; // Space between posters
|
||||||
|
|
||||||
|
// Calculate available width for posters (reserve space for left padding)
|
||||||
|
const availableWidth = screenWidth - LEFT_PADDING;
|
||||||
|
|
||||||
|
// Try different numbers of full posters to find the best fit
|
||||||
|
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
|
||||||
|
|
||||||
|
for (let n = 3; n <= 6; n++) {
|
||||||
|
// Calculate poster width needed for N full posters + 0.25 partial poster
|
||||||
|
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
|
||||||
|
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
|
||||||
|
// We'll use minimal right padding (8px) to maximize space
|
||||||
|
const usableWidth = availableWidth - 8;
|
||||||
|
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
|
||||||
|
|
||||||
|
console.log(`[ContentItem] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
|
||||||
|
|
||||||
|
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
|
||||||
|
bestLayout = { numFullPosters: n, posterWidth };
|
||||||
|
console.log(`[ContentItem] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
numFullPosters: bestLayout.numFullPosters,
|
||||||
|
posterWidth: bestLayout.posterWidth,
|
||||||
|
spacing: SPACING,
|
||||||
|
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const posterLayout = calculatePosterLayout(width);
|
||||||
|
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||||
|
|
||||||
const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
|
const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
|
||||||
const [menuVisible, setMenuVisible] = useState(false);
|
const [menuVisible, setMenuVisible] = useState(false);
|
||||||
|
|
@ -132,28 +171,28 @@ const styles = StyleSheet.create({
|
||||||
width: POSTER_WIDTH,
|
width: POSTER_WIDTH,
|
||||||
aspectRatio: 2/3,
|
aspectRatio: 2/3,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: 16,
|
borderRadius: 4,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
elevation: 8,
|
elevation: 6,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 3 },
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.25,
|
||||||
shadowRadius: 8,
|
shadowRadius: 6,
|
||||||
borderWidth: 1,
|
borderWidth: 0.5,
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
},
|
},
|
||||||
contentItemContainer: {
|
contentItemContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 16,
|
borderRadius: 4,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
poster: {
|
poster: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 16,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
loadingOverlay: {
|
loadingOverlay: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -163,7 +202,7 @@ const styles = StyleSheet.create({
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: 16,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
watchedIndicator: {
|
watchedIndicator: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
AppState,
|
AppState,
|
||||||
AppStateStatus
|
AppStateStatus
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
|
|
@ -33,8 +34,39 @@ interface ContinueWatchingRef {
|
||||||
refresh: () => Promise<boolean>;
|
refresh: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dynamic poster calculation based on screen width for Continue Watching section
|
||||||
|
const calculatePosterLayout = (screenWidth: number) => {
|
||||||
|
const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items
|
||||||
|
const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section
|
||||||
|
const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins
|
||||||
|
|
||||||
|
// Calculate how many posters can fit (fewer items for continue watching)
|
||||||
|
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
||||||
|
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
|
||||||
|
|
||||||
|
// Limit to reasonable number of columns (2-5 for continue watching)
|
||||||
|
const numColumns = Math.min(Math.max(maxColumns, 2), 5);
|
||||||
|
|
||||||
|
// Calculate actual poster width
|
||||||
|
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numColumns,
|
||||||
|
posterWidth,
|
||||||
|
spacing: 12 // Space between posters
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const POSTER_WIDTH = (width - 40) / 2.7;
|
const posterLayout = calculatePosterLayout(width);
|
||||||
|
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||||
|
|
||||||
|
// Function to validate IMDB ID format
|
||||||
|
const isValidImdbId = (id: string): boolean => {
|
||||||
|
// IMDB IDs should start with 'tt' followed by 7-10 digits
|
||||||
|
const imdbPattern = /^tt\d{7,10}$/;
|
||||||
|
return imdbPattern.test(id);
|
||||||
|
};
|
||||||
|
|
||||||
// Create a proper imperative handle with React.forwardRef and updated type
|
// Create a proper imperative handle with React.forwardRef and updated type
|
||||||
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
|
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
|
||||||
|
|
@ -50,6 +82,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const allProgress = await storageService.getAllWatchProgress();
|
const allProgress = await storageService.getAllWatchProgress();
|
||||||
|
|
||||||
if (Object.keys(allProgress).length === 0) {
|
if (Object.keys(allProgress).length === 0) {
|
||||||
setContinueWatchingItems([]);
|
setContinueWatchingItems([]);
|
||||||
return;
|
return;
|
||||||
|
|
@ -62,19 +95,29 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
// Process each saved progress
|
// Process each saved progress
|
||||||
for (const key in allProgress) {
|
for (const key in allProgress) {
|
||||||
// Parse the key to get type and id
|
// Parse the key to get type and id
|
||||||
const [type, id, episodeId] = key.split(':');
|
const keyParts = key.split(':');
|
||||||
|
const [type, id, ...episodeIdParts] = keyParts;
|
||||||
|
const episodeId = episodeIdParts.length > 0 ? episodeIdParts.join(':') : undefined;
|
||||||
const progress = allProgress[key];
|
const progress = allProgress[key];
|
||||||
|
|
||||||
// Skip items that are more than 95% complete (effectively finished)
|
// Skip items that are more than 85% complete (effectively finished)
|
||||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||||
if (progressPercent >= 95) continue;
|
|
||||||
|
if (progressPercent >= 85) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const contentPromise = (async () => {
|
const contentPromise = (async () => {
|
||||||
try {
|
try {
|
||||||
|
// Validate IMDB ID format before attempting to fetch
|
||||||
|
if (!isValidImdbId(id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let content: StreamingContent | null = null;
|
let content: StreamingContent | null = null;
|
||||||
|
|
||||||
// Get content details using catalogService
|
// Get basic content details using catalogService (no enhanced metadata needed for continue watching)
|
||||||
content = await catalogService.getContentDetails(type, id);
|
content = await catalogService.getBasicContentDetails(type, id);
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
// Extract season and episode info from episodeId if available
|
// Extract season and episode info from episodeId if available
|
||||||
|
|
@ -83,11 +126,28 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
let episodeTitle: string | undefined;
|
let episodeTitle: string | undefined;
|
||||||
|
|
||||||
if (episodeId && type === 'series') {
|
if (episodeId && type === 'series') {
|
||||||
const match = episodeId.match(/s(\d+)e(\d+)/i);
|
// Try different episode ID formats
|
||||||
|
let match = episodeId.match(/s(\d+)e(\d+)/i); // Format: s1e1
|
||||||
if (match) {
|
if (match) {
|
||||||
season = parseInt(match[1], 10);
|
season = parseInt(match[1], 10);
|
||||||
episode = parseInt(match[2], 10);
|
episode = parseInt(match[2], 10);
|
||||||
episodeTitle = `Episode ${episode}`;
|
episodeTitle = `Episode ${episode}`;
|
||||||
|
} else {
|
||||||
|
// Try format: seriesId:season:episode (e.g., tt0108778:4:6)
|
||||||
|
const parts = episodeId.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const seasonPart = parts[parts.length - 2]; // Second to last part
|
||||||
|
const episodePart = parts[parts.length - 1]; // Last part
|
||||||
|
|
||||||
|
const seasonNum = parseInt(seasonPart, 10);
|
||||||
|
const episodeNum = parseInt(episodePart, 10);
|
||||||
|
|
||||||
|
if (!isNaN(seasonNum) && !isNaN(episodeNum)) {
|
||||||
|
season = seasonNum;
|
||||||
|
episode = episodeNum;
|
||||||
|
episodeTitle = `Episode ${episode}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,7 +188,9 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated);
|
progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated);
|
||||||
|
|
||||||
// Limit to 10 items
|
// Limit to 10 items
|
||||||
setContinueWatchingItems(progressItems.slice(0, 10));
|
const finalItems = progressItems.slice(0, 10);
|
||||||
|
|
||||||
|
setContinueWatchingItems(finalItems);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to load continue watching items:', error);
|
logger.error('Failed to load continue watching items:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -197,7 +259,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
await loadContinueWatching();
|
await loadContinueWatching();
|
||||||
// Return whether there are items to help parent determine visibility
|
// Return whether there are items to help parent determine visibility
|
||||||
return continueWatchingItems.length > 0;
|
const hasItems = continueWatchingItems.length > 0;
|
||||||
|
return hasItems;
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -206,12 +269,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
// If no continue watching items, don't render anything
|
// If no continue watching items, don't render anything
|
||||||
if (continueWatchingItems.length === 0 && !loading) {
|
if (continueWatchingItems.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<Animated.View entering={FadeIn.duration(400).delay(250)} style={styles.container}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<View style={styles.titleContainer}>
|
<View style={styles.titleContainer}>
|
||||||
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text>
|
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text>
|
||||||
|
|
@ -228,55 +291,96 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
||||||
data={continueWatchingItems}
|
data={continueWatchingItems}
|
||||||
renderItem={({ item }) => (
|
renderItem={({ item }) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.contentItem, {
|
style={[styles.wideContentItem, {
|
||||||
|
backgroundColor: currentTheme.colors.elevation1,
|
||||||
borderColor: currentTheme.colors.border,
|
borderColor: currentTheme.colors.border,
|
||||||
shadowColor: currentTheme.colors.black
|
shadowColor: currentTheme.colors.black
|
||||||
}]}
|
}]}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.8}
|
||||||
onPress={() => handleContentPress(item.id, item.type)}
|
onPress={() => handleContentPress(item.id, item.type)}
|
||||||
>
|
>
|
||||||
<View style={styles.contentItemContainer}>
|
{/* Poster Image */}
|
||||||
|
<View style={styles.posterContainer}>
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
source={{ uri: item.poster }}
|
source={{ uri: item.poster }}
|
||||||
style={styles.poster}
|
style={styles.widePoster}
|
||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
transition={200}
|
transition={200}
|
||||||
cachePolicy="memory-disk"
|
cachePolicy="memory-disk"
|
||||||
/>
|
/>
|
||||||
{item.type === 'series' && item.season && item.episode && (
|
</View>
|
||||||
<View style={[styles.episodeInfoContainer, { backgroundColor: 'rgba(0, 0, 0, 0.7)' }]}>
|
|
||||||
<Text style={[styles.episodeInfo, { color: currentTheme.colors.white }]}>
|
{/* Content Details */}
|
||||||
S{item.season.toString().padStart(2, '0')}E{item.episode.toString().padStart(2, '0')}
|
<View style={styles.contentDetails}>
|
||||||
|
<View style={styles.titleRow}>
|
||||||
|
<Text
|
||||||
|
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
|
||||||
|
<Text style={styles.progressText}>{Math.round(item.progress)}%</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Episode Info or Year */}
|
||||||
|
{(() => {
|
||||||
|
if (item.type === 'series' && item.season && item.episode) {
|
||||||
|
return (
|
||||||
|
<View style={styles.episodeRow}>
|
||||||
|
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
|
Season {item.season}
|
||||||
</Text>
|
</Text>
|
||||||
{item.episodeTitle && (
|
{item.episodeTitle && (
|
||||||
<Text style={[styles.episodeTitle, { color: currentTheme.colors.white, opacity: 0.9 }]} numberOfLines={1}>
|
<Text
|
||||||
|
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{item.episodeTitle}
|
{item.episodeTitle}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
);
|
||||||
{/* Progress bar indicator */}
|
} else {
|
||||||
<View style={styles.progressBarContainer}>
|
return (
|
||||||
|
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
|
{item.year} • {item.type === 'movie' ? 'Movie' : 'Series'}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<View style={styles.wideProgressContainer}>
|
||||||
|
<View style={styles.wideProgressTrack}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
styles.progressBar,
|
styles.wideProgressBar,
|
||||||
{ width: `${item.progress}%`, backgroundColor: currentTheme.colors.primary }
|
{
|
||||||
|
width: `${item.progress}%`,
|
||||||
|
backgroundColor: currentTheme.colors.primary
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}>
|
||||||
|
{Math.round(item.progress)}% watched
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
|
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.wideList}
|
||||||
snapToInterval={POSTER_WIDTH + 10}
|
snapToInterval={280 + 16} // Card width + margin
|
||||||
decelerationRate="fast"
|
decelerationRate="fast"
|
||||||
snapToAlignment="start"
|
snapToAlignment="start"
|
||||||
ItemSeparatorComponent={() => <View style={{ width: 10 }} />}
|
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
|
||||||
/>
|
/>
|
||||||
</View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -291,26 +395,116 @@ const styles = StyleSheet.create({
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
marginBottom: 8,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
titleContainer: {
|
titleContainer: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 20,
|
||||||
fontWeight: '800',
|
fontWeight: '700',
|
||||||
textTransform: 'uppercase',
|
letterSpacing: 0.3,
|
||||||
letterSpacing: 0.5,
|
marginBottom: 4,
|
||||||
marginBottom: 6,
|
|
||||||
},
|
},
|
||||||
titleUnderline: {
|
titleUnderline: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: -4,
|
bottom: -2,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 60,
|
width: 40,
|
||||||
height: 3,
|
height: 2,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1,
|
||||||
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
|
wideList: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 8,
|
||||||
|
paddingTop: 4,
|
||||||
|
},
|
||||||
|
wideContentItem: {
|
||||||
|
width: 280,
|
||||||
|
height: 120,
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
elevation: 6,
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.2,
|
||||||
|
shadowRadius: 6,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
posterContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
widePoster: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderTopLeftRadius: 12,
|
||||||
|
borderBottomLeftRadius: 12,
|
||||||
|
},
|
||||||
|
contentDetails: {
|
||||||
|
flex: 1,
|
||||||
|
padding: 12,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
titleRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
contentTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '700',
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
progressBadge: {
|
||||||
|
paddingHorizontal: 8,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 12,
|
||||||
|
minWidth: 44,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
progressText: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
episodeRow: {
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
episodeText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
episodeTitle: {
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
yearText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
wideProgressContainer: {
|
||||||
|
marginTop: 'auto',
|
||||||
|
},
|
||||||
|
wideProgressTrack: {
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
|
borderRadius: 2,
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
wideProgressBar: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
progressLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
// Keep old styles for backward compatibility
|
||||||
list: {
|
list: {
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingBottom: 8,
|
paddingBottom: 8,
|
||||||
|
|
@ -320,7 +514,7 @@ const styles = StyleSheet.create({
|
||||||
width: POSTER_WIDTH,
|
width: POSTER_WIDTH,
|
||||||
aspectRatio: 2/3,
|
aspectRatio: 2/3,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
|
|
@ -332,14 +526,14 @@ const styles = StyleSheet.create({
|
||||||
contentItemContainer: {
|
contentItemContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
poster: {
|
poster: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
episodeInfoContainer: {
|
episodeInfoContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -353,9 +547,6 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
},
|
},
|
||||||
episodeTitle: {
|
|
||||||
fontSize: 10,
|
|
||||||
},
|
|
||||||
progressBarContainer: {
|
progressBarContainer: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
|
|
|
||||||
|
|
@ -64,9 +64,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
// Add a ref to track logo fetch in progress
|
// Add a ref to track logo fetch in progress
|
||||||
const logoFetchInProgress = useRef<boolean>(false);
|
const logoFetchInProgress = useRef<boolean>(false);
|
||||||
|
|
||||||
|
// Enhanced poster transition animations
|
||||||
|
const posterScale = useSharedValue(1);
|
||||||
|
const posterTranslateY = useSharedValue(0);
|
||||||
|
const overlayOpacity = useSharedValue(0.15);
|
||||||
|
|
||||||
// Animation values
|
// Animation values
|
||||||
const posterAnimatedStyle = useAnimatedStyle(() => ({
|
const posterAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
opacity: posterOpacity.value,
|
opacity: posterOpacity.value,
|
||||||
|
transform: [
|
||||||
|
{ scale: posterScale.value },
|
||||||
|
{ translateY: posterTranslateY.value }
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
|
@ -84,6 +93,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
opacity: buttonsOpacity.value,
|
opacity: buttonsOpacity.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const overlayAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: overlayOpacity.value,
|
||||||
|
}));
|
||||||
|
|
||||||
// Preload the image
|
// Preload the image
|
||||||
const preloadImage = async (url: string): Promise<boolean> => {
|
const preloadImage = async (url: string): Promise<boolean> => {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
|
|
@ -122,153 +135,132 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
if (!featuredContent || logoFetchInProgress.current) return;
|
if (!featuredContent || logoFetchInProgress.current) return;
|
||||||
|
|
||||||
const fetchLogo = async () => {
|
const fetchLogo = async () => {
|
||||||
// Set fetch in progress flag
|
|
||||||
logoFetchInProgress.current = true;
|
logoFetchInProgress.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contentId = featuredContent.id;
|
const contentId = featuredContent.id;
|
||||||
|
const contentData = featuredContent; // Use a clearer variable name
|
||||||
|
const currentLogo = contentData.logo;
|
||||||
|
|
||||||
// Get logo source preference from settings
|
// Get preferences
|
||||||
const logoPreference = settings.logoSourcePreference || 'metahub'; // Default to metahub if not set
|
const logoPreference = settings.logoSourcePreference || 'metahub';
|
||||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en'; // Get preferred language
|
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||||
|
|
||||||
// Check if current logo matches preferences
|
// Reset state for new fetch
|
||||||
const currentLogo = featuredContent.logo;
|
setLogoUrl(null);
|
||||||
if (currentLogo) {
|
setLogoLoadError(false);
|
||||||
const isCurrentMetahub = isMetahubUrl(currentLogo);
|
|
||||||
const isCurrentTmdb = isTmdbUrl(currentLogo);
|
|
||||||
|
|
||||||
// If logo already matches preference, use it
|
// Extract IDs
|
||||||
if ((logoPreference === 'metahub' && isCurrentMetahub) ||
|
let imdbId: string | null = null;
|
||||||
(logoPreference === 'tmdb' && isCurrentTmdb)) {
|
if (contentData.id.startsWith('tt')) {
|
||||||
setLogoUrl(currentLogo);
|
imdbId = contentData.id;
|
||||||
logoFetchInProgress.current = false;
|
} else if ((contentData as any).imdbId) {
|
||||||
return;
|
imdbId = (contentData as any).imdbId;
|
||||||
}
|
} else if ((contentData as any).externalIds?.imdb_id) {
|
||||||
|
imdbId = (contentData as any).externalIds.imdb_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract IMDB ID if available
|
let tmdbId: string | null = null;
|
||||||
let imdbId = null;
|
if (contentData.id.startsWith('tmdb:')) {
|
||||||
if (featuredContent.id.startsWith('tt')) {
|
tmdbId = contentData.id.split(':')[1];
|
||||||
// If the ID itself is an IMDB ID
|
} else if ((contentData as any).tmdb_id) {
|
||||||
imdbId = featuredContent.id;
|
tmdbId = String((contentData as any).tmdb_id);
|
||||||
} else if ((featuredContent as any).imdbId) {
|
|
||||||
// Try to get IMDB ID from the content object if available
|
|
||||||
imdbId = (featuredContent as any).imdbId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract TMDB ID if available
|
// If we only have IMDB ID, try to find TMDB ID proactively
|
||||||
let tmdbId = null;
|
if (imdbId && !tmdbId) {
|
||||||
if (contentId.startsWith('tmdb:')) {
|
|
||||||
tmdbId = contentId.split(':')[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// First source based on preference
|
|
||||||
if (logoPreference === 'metahub' && imdbId) {
|
|
||||||
// Try to get logo from Metahub first
|
|
||||||
const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(metahubUrl, { method: 'HEAD' });
|
|
||||||
if (response.ok) {
|
|
||||||
setLogoUrl(metahubUrl);
|
|
||||||
logoFetchInProgress.current = false;
|
|
||||||
return; // Exit if Metahub logo was found
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Removed logger.warn
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall back to TMDB if Metahub fails and we have a TMDB ID
|
|
||||||
if (tmdbId) {
|
|
||||||
const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie';
|
|
||||||
try {
|
try {
|
||||||
const tmdbService = TMDBService.getInstance();
|
const tmdbService = TMDBService.getInstance();
|
||||||
const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
|
const foundData = await tmdbService.findTMDBIdByIMDB(imdbId);
|
||||||
|
if (foundData) {
|
||||||
if (logoUrl) {
|
tmdbId = String(foundData);
|
||||||
setLogoUrl(logoUrl);
|
|
||||||
} else if (currentLogo) {
|
|
||||||
// If TMDB fails too, use existing logo if any
|
|
||||||
setLogoUrl(currentLogo);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (findError) {
|
||||||
// Removed logger.error
|
// logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError);
|
||||||
if (currentLogo) setLogoUrl(currentLogo);
|
|
||||||
}
|
|
||||||
} else if (currentLogo) {
|
|
||||||
// Use existing logo if we don't have TMDB ID
|
|
||||||
setLogoUrl(currentLogo);
|
|
||||||
}
|
|
||||||
} else if (logoPreference === 'tmdb') {
|
|
||||||
// Try to get logo from TMDB first
|
|
||||||
if (tmdbId) {
|
|
||||||
const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie';
|
|
||||||
try {
|
|
||||||
const tmdbService = TMDBService.getInstance();
|
|
||||||
const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
|
|
||||||
|
|
||||||
if (logoUrl) {
|
|
||||||
setLogoUrl(logoUrl);
|
|
||||||
logoFetchInProgress.current = false;
|
|
||||||
return; // Exit if TMDB logo was found
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Removed logger.error
|
|
||||||
}
|
|
||||||
} else if (imdbId) {
|
|
||||||
// If we have IMDB ID but no TMDB ID, try to find TMDB ID
|
|
||||||
try {
|
|
||||||
const tmdbService = TMDBService.getInstance();
|
|
||||||
const foundTmdbId = await tmdbService.findTMDBIdByIMDB(imdbId);
|
|
||||||
|
|
||||||
if (foundTmdbId) {
|
|
||||||
const tmdbType = featuredContent.type === 'series' ? 'tv' : 'movie';
|
|
||||||
const logoUrl = await tmdbService.getContentLogo(tmdbType, foundTmdbId.toString(), preferredLanguage);
|
|
||||||
|
|
||||||
if (logoUrl) {
|
|
||||||
setLogoUrl(logoUrl);
|
|
||||||
logoFetchInProgress.current = false;
|
|
||||||
return; // Exit if TMDB logo was found
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Removed logger.error
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to Metahub if TMDB fails and we have an IMDB ID
|
const tmdbType = contentData.type === 'series' ? 'tv' : 'movie';
|
||||||
|
let finalLogoUrl: string | null = null;
|
||||||
|
let primaryAttempted = false;
|
||||||
|
let fallbackAttempted = false;
|
||||||
|
|
||||||
|
// --- Logo Fetching Logic ---
|
||||||
|
|
||||||
|
if (logoPreference === 'metahub') {
|
||||||
|
// Primary: Metahub (needs imdbId)
|
||||||
if (imdbId) {
|
if (imdbId) {
|
||||||
|
primaryAttempted = true;
|
||||||
const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
|
const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(metahubUrl, { method: 'HEAD' });
|
const response = await fetch(metahubUrl, { method: 'HEAD' });
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setLogoUrl(metahubUrl);
|
finalLogoUrl = metahubUrl;
|
||||||
|
}
|
||||||
|
} catch (error) { /* Log if needed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: TMDB (needs tmdbId)
|
||||||
|
if (!finalLogoUrl && tmdbId) {
|
||||||
|
fallbackAttempted = true;
|
||||||
|
try {
|
||||||
|
const tmdbService = TMDBService.getInstance();
|
||||||
|
const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
|
||||||
|
if (logoUrl) {
|
||||||
|
finalLogoUrl = logoUrl;
|
||||||
|
}
|
||||||
|
} catch (error) { /* Log if needed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
} else { // logoPreference === 'tmdb'
|
||||||
|
// Primary: TMDB (needs tmdbId)
|
||||||
|
if (tmdbId) {
|
||||||
|
primaryAttempted = true;
|
||||||
|
try {
|
||||||
|
const tmdbService = TMDBService.getInstance();
|
||||||
|
const logoUrl = await tmdbService.getContentLogo(tmdbType, tmdbId, preferredLanguage);
|
||||||
|
if (logoUrl) {
|
||||||
|
finalLogoUrl = logoUrl;
|
||||||
|
}
|
||||||
|
} catch (error) { /* Log if needed */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Metahub (needs imdbId)
|
||||||
|
if (!finalLogoUrl && imdbId) {
|
||||||
|
fallbackAttempted = true;
|
||||||
|
const metahubUrl = `https://images.metahub.space/logo/medium/${imdbId}/img`;
|
||||||
|
try {
|
||||||
|
const response = await fetch(metahubUrl, { method: 'HEAD' });
|
||||||
|
if (response.ok) {
|
||||||
|
finalLogoUrl = metahubUrl;
|
||||||
|
}
|
||||||
|
} catch (error) { /* Log if needed */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Set Final Logo ---
|
||||||
|
if (finalLogoUrl) {
|
||||||
|
setLogoUrl(finalLogoUrl);
|
||||||
} else if (currentLogo) {
|
} else if (currentLogo) {
|
||||||
// If Metahub fails too, use existing logo if any
|
// Use existing logo only if primary and fallback failed or weren't applicable
|
||||||
setLogoUrl(currentLogo);
|
setLogoUrl(currentLogo);
|
||||||
|
} else {
|
||||||
|
// No logo found from any source
|
||||||
|
setLogoLoadError(true);
|
||||||
|
// logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Removed logger.warn
|
// logger.error('[FeaturedContent] Error in fetchLogo:', error);
|
||||||
if (currentLogo) setLogoUrl(currentLogo);
|
setLogoLoadError(true);
|
||||||
}
|
|
||||||
} else if (currentLogo) {
|
|
||||||
// Use existing logo if we don't have IMDB ID
|
|
||||||
setLogoUrl(currentLogo);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
// Removed logger.error
|
|
||||||
// Optionally set a fallback logo or handle the error state
|
|
||||||
setLogoUrl(featuredContent.logo ?? null); // Fallback to initial logo or null
|
|
||||||
} finally {
|
} finally {
|
||||||
logoFetchInProgress.current = false;
|
logoFetchInProgress.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Trigger fetch when content changes
|
||||||
fetchLogo();
|
fetchLogo();
|
||||||
}, [featuredContent?.id, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
|
}, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
|
||||||
|
|
||||||
// Load poster and logo
|
// Load poster and logo
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -276,41 +268,92 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
|
|
||||||
const posterUrl = featuredContent.banner || featuredContent.poster;
|
const posterUrl = featuredContent.banner || featuredContent.poster;
|
||||||
const contentId = featuredContent.id;
|
const contentId = featuredContent.id;
|
||||||
|
const isContentChange = contentId !== prevContentIdRef.current;
|
||||||
|
|
||||||
// Reset states for new content
|
// Enhanced content change detection and animations
|
||||||
if (contentId !== prevContentIdRef.current) {
|
if (isContentChange) {
|
||||||
|
// Animate out current content
|
||||||
|
if (prevContentIdRef.current) {
|
||||||
|
posterOpacity.value = withTiming(0, {
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
});
|
||||||
|
posterScale.value = withTiming(0.95, {
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
});
|
||||||
|
overlayOpacity.value = withTiming(0.6, {
|
||||||
|
duration: 300,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
});
|
||||||
|
contentOpacity.value = withTiming(0.3, {
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
});
|
||||||
|
buttonsOpacity.value = withTiming(0.3, {
|
||||||
|
duration: 200,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Initial load - start from 0
|
||||||
posterOpacity.value = 0;
|
posterOpacity.value = 0;
|
||||||
|
posterScale.value = 1.1;
|
||||||
|
overlayOpacity.value = 0;
|
||||||
|
contentOpacity.value = 0;
|
||||||
|
buttonsOpacity.value = 0;
|
||||||
|
}
|
||||||
logoOpacity.value = 0;
|
logoOpacity.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
prevContentIdRef.current = contentId;
|
prevContentIdRef.current = contentId;
|
||||||
|
|
||||||
// Set poster URL immediately for instant display
|
// Set poster URL for immediate display
|
||||||
if (posterUrl) setBannerUrl(posterUrl);
|
if (posterUrl) setBannerUrl(posterUrl);
|
||||||
|
|
||||||
// Load images in background
|
// Load images with enhanced animations
|
||||||
const loadImages = async () => {
|
const loadImages = async () => {
|
||||||
// Load poster
|
// Small delay to allow fade out animation to complete
|
||||||
|
await new Promise(resolve => setTimeout(resolve, isContentChange && prevContentIdRef.current ? 300 : 0));
|
||||||
|
|
||||||
|
// Load poster with enhanced transition
|
||||||
if (posterUrl) {
|
if (posterUrl) {
|
||||||
const posterSuccess = await preloadImage(posterUrl);
|
const posterSuccess = await preloadImage(posterUrl);
|
||||||
if (posterSuccess) {
|
if (posterSuccess) {
|
||||||
posterOpacity.value = withTiming(1, {
|
// Animate in new poster with scale and fade
|
||||||
duration: 600,
|
posterScale.value = withTiming(1, {
|
||||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1)
|
duration: 800,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
});
|
});
|
||||||
|
posterOpacity.value = withTiming(1, {
|
||||||
|
duration: 700,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
});
|
||||||
|
overlayOpacity.value = withTiming(0.15, {
|
||||||
|
duration: 600,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animate content back in with delay
|
||||||
|
contentOpacity.value = withDelay(200, withTiming(1, {
|
||||||
|
duration: 600,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
}));
|
||||||
|
buttonsOpacity.value = withDelay(400, withTiming(1, {
|
||||||
|
duration: 500,
|
||||||
|
easing: Easing.out(Easing.cubic)
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load logo if available
|
// Load logo if available with enhanced timing
|
||||||
if (logoUrl) {
|
if (logoUrl) {
|
||||||
const logoSuccess = await preloadImage(logoUrl);
|
const logoSuccess = await preloadImage(logoUrl);
|
||||||
if (logoSuccess) {
|
if (logoSuccess) {
|
||||||
logoOpacity.value = withDelay(300, withTiming(1, {
|
logoOpacity.value = withDelay(500, withTiming(1, {
|
||||||
duration: 500,
|
duration: 600,
|
||||||
easing: Easing.bezier(0.25, 0.1, 0.25, 1)
|
easing: Easing.out(Easing.cubic)
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
// If prefetch fails, mark as error to show title text instead
|
|
||||||
setLogoLoadError(true);
|
setLogoLoadError(true);
|
||||||
console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`);
|
console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`);
|
||||||
}
|
}
|
||||||
|
|
@ -325,8 +368,11 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(800).easing(Easing.out(Easing.cubic))}
|
||||||
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.95}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.navigate('Metadata', {
|
navigation.navigate('Metadata', {
|
||||||
id: featuredContent.id,
|
id: featuredContent.id,
|
||||||
|
|
@ -341,14 +387,18 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
style={styles.featuredImage as ViewStyle}
|
style={styles.featuredImage as ViewStyle}
|
||||||
resizeMode="cover"
|
resizeMode="cover"
|
||||||
>
|
>
|
||||||
|
{/* Subtle content overlay for better readability */}
|
||||||
|
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
|
||||||
|
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[
|
colors={[
|
||||||
'transparent',
|
|
||||||
'rgba(0,0,0,0.1)',
|
'rgba(0,0,0,0.1)',
|
||||||
'rgba(0,0,0,0.7)',
|
'rgba(0,0,0,0.2)',
|
||||||
|
'rgba(0,0,0,0.4)',
|
||||||
|
'rgba(0,0,0,0.8)',
|
||||||
currentTheme.colors.darkBackground,
|
currentTheme.colors.darkBackground,
|
||||||
]}
|
]}
|
||||||
locations={[0, 0.3, 0.7, 1]}
|
locations={[0, 0.2, 0.5, 0.8, 1]}
|
||||||
style={styles.featuredGradient as ViewStyle}
|
style={styles.featuredGradient as ViewStyle}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
|
|
@ -391,6 +441,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.myListButton as ViewStyle}
|
style={styles.myListButton as ViewStyle}
|
||||||
onPress={handleSaveToLibrary}
|
onPress={handleSaveToLibrary}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name={isSaved ? "bookmark" : "bookmark-border"}
|
name={isSaved ? "bookmark" : "bookmark-border"}
|
||||||
|
|
@ -412,6 +463,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
|
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
|
||||||
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
||||||
|
|
@ -429,6 +481,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
|
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
|
||||||
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||||
|
|
@ -440,16 +493,24 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
</ImageBackground>
|
</ImageBackground>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
featuredContainer: {
|
featuredContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: height * 0.48,
|
height: height * 0.55, // Slightly taller for better proportions
|
||||||
marginTop: 0,
|
marginTop: 0,
|
||||||
marginBottom: 8,
|
marginBottom: 12,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
},
|
},
|
||||||
imageContainer: {
|
imageContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -464,6 +525,7 @@ const styles = StyleSheet.create({
|
||||||
featuredImage: {
|
featuredImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
transform: [{ scale: 1.05 }], // Subtle zoom for depth
|
||||||
},
|
},
|
||||||
backgroundFallback: {
|
backgroundFallback: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|
@ -479,12 +541,14 @@ const styles = StyleSheet.create({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
paddingTop: 20,
|
||||||
},
|
},
|
||||||
featuredContentContainer: {
|
featuredContentContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 20,
|
||||||
paddingBottom: 4,
|
paddingBottom: 8,
|
||||||
|
paddingTop: 40,
|
||||||
},
|
},
|
||||||
featuredLogo: {
|
featuredLogo: {
|
||||||
width: width * 0.7,
|
width: width * 0.7,
|
||||||
|
|
@ -523,19 +587,20 @@ const styles = StyleSheet.create({
|
||||||
},
|
},
|
||||||
featuredButtons: {
|
featuredButtons: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-evenly',
|
justifyContent: 'space-evenly',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
flex: 1,
|
minHeight: 70,
|
||||||
maxHeight: 55,
|
paddingTop: 12,
|
||||||
paddingTop: 0,
|
paddingBottom: 20,
|
||||||
|
paddingHorizontal: 8,
|
||||||
},
|
},
|
||||||
playButton: {
|
playButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingVertical: 14,
|
paddingVertical: 12,
|
||||||
paddingHorizontal: 32,
|
paddingHorizontal: 28,
|
||||||
borderRadius: 30,
|
borderRadius: 30,
|
||||||
elevation: 4,
|
elevation: 4,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
|
|
@ -543,7 +608,7 @@ const styles = StyleSheet.create({
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.3,
|
||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
flex: 0,
|
flex: 0,
|
||||||
width: 150,
|
width: 140,
|
||||||
},
|
},
|
||||||
myListButton: {
|
myListButton: {
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
|
@ -578,6 +643,16 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
contentOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.15)',
|
||||||
|
zIndex: 1,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default FeaturedContent;
|
export default FeaturedContent;
|
||||||
|
|
@ -303,8 +303,9 @@ const styles = StyleSheet.create({
|
||||||
marginBottom: 12,
|
marginBottom: 12,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontSize: 18,
|
fontSize: 19,
|
||||||
fontWeight: 'bold',
|
fontWeight: '700',
|
||||||
|
letterSpacing: 0.2,
|
||||||
},
|
},
|
||||||
viewAllButton: {
|
viewAllButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|
@ -329,7 +330,7 @@ const styles = StyleSheet.create({
|
||||||
episodeItem: {
|
episodeItem: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
poster: {
|
poster: {
|
||||||
|
|
|
||||||
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';
|
import { catalogService } from '../../services/catalogService';
|
||||||
|
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const POSTER_WIDTH = (width - 48) / 3.5; // Adjust number for desired items visible
|
|
||||||
|
// Dynamic poster calculation based on screen width for More Like This section
|
||||||
|
const calculatePosterLayout = (screenWidth: number) => {
|
||||||
|
const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section
|
||||||
|
const MAX_POSTER_WIDTH = 130; // Maximum poster width
|
||||||
|
const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins
|
||||||
|
|
||||||
|
// Calculate how many posters can fit (aim for slightly more items than main sections)
|
||||||
|
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
||||||
|
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
|
||||||
|
|
||||||
|
// Limit to reasonable number of columns (3-7 for this section)
|
||||||
|
const numColumns = Math.min(Math.max(maxColumns, 3), 7);
|
||||||
|
|
||||||
|
// Calculate actual poster width
|
||||||
|
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numColumns,
|
||||||
|
posterWidth,
|
||||||
|
spacing: 12 // Space between posters
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const posterLayout = calculatePosterLayout(width);
|
||||||
|
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||||
const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
|
const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
|
||||||
|
|
||||||
interface MoreLikeThisSectionProps {
|
interface MoreLikeThisSectionProps {
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ interface MovieContentProps {
|
||||||
export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
|
export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0;
|
const hasCast = Array.isArray(metadata.cast) && metadata.cast.length > 0;
|
||||||
const castDisplay = hasCast ? (metadata.cast as string[]).slice(0, 5).join(', ') : '';
|
const castDisplay = hasCast ? metadata.cast!.slice(0, 5).join(', ') : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
|
@ -23,12 +23,6 @@ export const MovieContent: React.FC<MovieContentProps> = ({ metadata }) => {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{metadata.writer && (
|
|
||||||
<View style={styles.metadataRow}>
|
|
||||||
<Text style={[styles.metadataLabel, { color: currentTheme.colors.textMuted }]}>Writer:</Text>
|
|
||||||
<Text style={[styles.metadataValue, { color: currentTheme.colors.text }]}>{metadata.writer}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{hasCast && (
|
{hasCast && (
|
||||||
<View style={styles.metadataRow}>
|
<View style={styles.metadataRow}>
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import React, { useEffect, useState, useRef } from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
|
||||||
import { Image } from 'expo-image';
|
import { Image } from 'expo-image';
|
||||||
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { useSettings } from '../../hooks/useSettings';
|
||||||
import { Episode } from '../../types/metadata';
|
import { Episode } from '../../types/metadata';
|
||||||
import { tmdbService } from '../../services/tmdbService';
|
import { tmdbService } from '../../services/tmdbService';
|
||||||
import { storageService } from '../../services/storageService';
|
import { storageService } from '../../services/storageService';
|
||||||
|
|
@ -34,19 +36,21 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
metadata
|
metadata
|
||||||
}) => {
|
}) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const { settings } = useSettings();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isTablet = width > 768;
|
const isTablet = width > 768;
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number } }>({});
|
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({});
|
||||||
|
|
||||||
// Add ref for the season selector ScrollView
|
// Add refs for the scroll views
|
||||||
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
||||||
|
const episodeScrollViewRef = useRef<ScrollView | null>(null);
|
||||||
|
|
||||||
const loadEpisodesProgress = async () => {
|
const loadEpisodesProgress = async () => {
|
||||||
if (!metadata?.id) return;
|
if (!metadata?.id) return;
|
||||||
|
|
||||||
const allProgress = await storageService.getAllWatchProgress();
|
const allProgress = await storageService.getAllWatchProgress();
|
||||||
const progress: { [key: string]: { currentTime: number; duration: number } } = {};
|
const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {};
|
||||||
|
|
||||||
episodes.forEach(episode => {
|
episodes.forEach(episode => {
|
||||||
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
|
|
@ -54,7 +58,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
if (allProgress[key]) {
|
if (allProgress[key]) {
|
||||||
progress[episodeId] = {
|
progress[episodeId] = {
|
||||||
currentTime: allProgress[key].currentTime,
|
currentTime: allProgress[key].currentTime,
|
||||||
duration: allProgress[key].duration
|
duration: allProgress[key].duration,
|
||||||
|
lastUpdated: allProgress[key].lastUpdated
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -62,6 +67,67 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
setEpisodeProgress(progress);
|
setEpisodeProgress(progress);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Function to find and scroll to the most recently watched episode
|
||||||
|
const scrollToMostRecentEpisode = () => {
|
||||||
|
if (!metadata?.id || !episodeScrollViewRef.current || settings.episodeLayoutStyle !== 'horizontal') {
|
||||||
|
console.log('[SeriesContent] Scroll conditions not met:', {
|
||||||
|
hasMetadataId: !!metadata?.id,
|
||||||
|
hasScrollRef: !!episodeScrollViewRef.current,
|
||||||
|
isHorizontal: settings.episodeLayoutStyle === 'horizontal'
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
|
||||||
|
if (currentSeasonEpisodes.length === 0) {
|
||||||
|
console.log('[SeriesContent] No episodes in current season:', selectedSeason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the most recently watched episode in the current season
|
||||||
|
let mostRecentEpisodeIndex = -1;
|
||||||
|
let mostRecentTimestamp = 0;
|
||||||
|
let mostRecentEpisodeName = '';
|
||||||
|
|
||||||
|
currentSeasonEpisodes.forEach((episode, index) => {
|
||||||
|
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
|
const progress = episodeProgress[episodeId];
|
||||||
|
|
||||||
|
if (progress && progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
|
||||||
|
mostRecentTimestamp = progress.lastUpdated;
|
||||||
|
mostRecentEpisodeIndex = index;
|
||||||
|
mostRecentEpisodeName = episode.name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SeriesContent] Episode scroll analysis:', {
|
||||||
|
totalEpisodes: currentSeasonEpisodes.length,
|
||||||
|
mostRecentIndex: mostRecentEpisodeIndex,
|
||||||
|
mostRecentEpisode: mostRecentEpisodeName,
|
||||||
|
selectedSeason
|
||||||
|
});
|
||||||
|
|
||||||
|
// Scroll to the most recently watched episode if found
|
||||||
|
if (mostRecentEpisodeIndex >= 0) {
|
||||||
|
const cardWidth = isTablet ? width * 0.4 + 16 : width * 0.85 + 16;
|
||||||
|
const scrollPosition = mostRecentEpisodeIndex * cardWidth;
|
||||||
|
|
||||||
|
console.log('[SeriesContent] Scrolling to episode:', {
|
||||||
|
index: mostRecentEpisodeIndex,
|
||||||
|
cardWidth,
|
||||||
|
scrollPosition,
|
||||||
|
episodeName: mostRecentEpisodeName
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
episodeScrollViewRef.current?.scrollTo({
|
||||||
|
x: scrollPosition,
|
||||||
|
animated: true
|
||||||
|
});
|
||||||
|
}, 500); // Delay to ensure the season has loaded
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Initial load of watch progress
|
// Initial load of watch progress
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEpisodesProgress();
|
loadEpisodesProgress();
|
||||||
|
|
@ -93,6 +159,13 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
}
|
}
|
||||||
}, [selectedSeason, groupedEpisodes]);
|
}, [selectedSeason, groupedEpisodes]);
|
||||||
|
|
||||||
|
// Add effect to scroll to most recently watched episode when season changes or progress loads
|
||||||
|
useEffect(() => {
|
||||||
|
if (Object.keys(episodeProgress).length > 0 && selectedSeason) {
|
||||||
|
scrollToMostRecentEpisode();
|
||||||
|
}
|
||||||
|
}, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]);
|
||||||
|
|
||||||
if (loadingSeasons) {
|
if (loadingSeasons) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centeredContainer}>
|
<View style={styles.centeredContainer}>
|
||||||
|
|
@ -159,6 +232,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
styles.seasonButtonText,
|
||||||
{ color: currentTheme.colors.mediumEmphasis },
|
{ color: currentTheme.colors.mediumEmphasis },
|
||||||
selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }]
|
selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }]
|
||||||
]}
|
]}
|
||||||
|
|
@ -173,7 +247,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderEpisodeCard = (episode: Episode) => {
|
// Vertical layout episode card (traditional)
|
||||||
|
const renderVerticalEpisodeCard = (episode: Episode) => {
|
||||||
let episodeImage = EPISODE_PLACEHOLDER;
|
let episodeImage = EPISODE_PLACEHOLDER;
|
||||||
if (episode.still_path) {
|
if (episode.still_path) {
|
||||||
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
|
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
|
||||||
|
|
@ -210,15 +285,15 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
const progress = episodeProgress[episodeId];
|
const progress = episodeProgress[episodeId];
|
||||||
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
||||||
|
|
||||||
// Don't show progress bar if episode is complete (>= 95%)
|
// Don't show progress bar if episode is complete (>= 85%)
|
||||||
const showProgress = progress && progressPercent < 95;
|
const showProgress = progress && progressPercent < 85;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={episode.id}
|
key={episode.id}
|
||||||
style={[
|
style={[
|
||||||
styles.episodeCard,
|
styles.episodeCardVertical,
|
||||||
isTablet && styles.episodeCardTablet,
|
isTablet && styles.episodeCardVerticalTablet,
|
||||||
{ backgroundColor: currentTheme.colors.elevation2 }
|
{ backgroundColor: currentTheme.colors.elevation2 }
|
||||||
]}
|
]}
|
||||||
onPress={() => onSelectEpisode(episode)}
|
onPress={() => onSelectEpisode(episode)}
|
||||||
|
|
@ -243,7 +318,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
{progressPercent >= 95 && (
|
{progressPercent >= 85 && (
|
||||||
<View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}>
|
<View style={[styles.completedBadge, { backgroundColor: currentTheme.colors.primary }]}>
|
||||||
<MaterialIcons name="check" size={12} color={currentTheme.colors.white} />
|
<MaterialIcons name="check" size={12} color={currentTheme.colors.white} />
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -291,6 +366,170 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Horizontal layout episode card (Netflix-style)
|
||||||
|
const renderHorizontalEpisodeCard = (episode: Episode) => {
|
||||||
|
let episodeImage = EPISODE_PLACEHOLDER;
|
||||||
|
if (episode.still_path) {
|
||||||
|
const tmdbUrl = tmdbService.getImageUrl(episode.still_path, 'w500');
|
||||||
|
if (tmdbUrl) episodeImage = tmdbUrl;
|
||||||
|
} else if (metadata?.poster) {
|
||||||
|
episodeImage = metadata.poster;
|
||||||
|
}
|
||||||
|
|
||||||
|
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
|
||||||
|
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
|
||||||
|
const episodeString = seasonNumber && episodeNumber ? `EPISODE ${episodeNumber}` : '';
|
||||||
|
|
||||||
|
const formatRuntime = (runtime: number) => {
|
||||||
|
if (!runtime) return null;
|
||||||
|
const hours = Math.floor(runtime / 60);
|
||||||
|
const minutes = runtime % 60;
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m`;
|
||||||
|
}
|
||||||
|
return `${minutes}m`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get episode progress
|
||||||
|
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
|
const progress = episodeProgress[episodeId];
|
||||||
|
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
||||||
|
|
||||||
|
// Don't show progress bar if episode is complete (>= 85%)
|
||||||
|
const showProgress = progress && progressPercent < 85;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={episode.id}
|
||||||
|
style={[
|
||||||
|
styles.episodeCardHorizontal,
|
||||||
|
isTablet && styles.episodeCardHorizontalTablet,
|
||||||
|
// Gradient border styling
|
||||||
|
{
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 12,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={() => onSelectEpisode(episode)}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
{/* Gradient Border Container */}
|
||||||
|
<View style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -1,
|
||||||
|
left: -1,
|
||||||
|
right: -1,
|
||||||
|
bottom: -1,
|
||||||
|
borderRadius: 17,
|
||||||
|
zIndex: -1,
|
||||||
|
}}>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[
|
||||||
|
'#ffffff80', // White with 50% opacity
|
||||||
|
'#ffffff40', // White with 25% opacity
|
||||||
|
'#ffffff20', // White with 12% opacity
|
||||||
|
'#ffffff40', // White with 25% opacity
|
||||||
|
'#ffffff80', // White with 50% opacity
|
||||||
|
]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 17,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Background Image */}
|
||||||
|
<Image
|
||||||
|
source={{ uri: episodeImage }}
|
||||||
|
style={styles.episodeBackgroundImage}
|
||||||
|
contentFit="cover"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Standard Gradient Overlay */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={[
|
||||||
|
'rgba(0,0,0,0.05)',
|
||||||
|
'rgba(0,0,0,0.2)',
|
||||||
|
'rgba(0,0,0,0.6)',
|
||||||
|
'rgba(0,0,0,0.85)',
|
||||||
|
'rgba(0,0,0,0.95)'
|
||||||
|
]}
|
||||||
|
locations={[0, 0.2, 0.5, 0.8, 1]}
|
||||||
|
style={styles.episodeGradient}
|
||||||
|
>
|
||||||
|
{/* Content Container */}
|
||||||
|
<View style={styles.episodeContent}>
|
||||||
|
{/* Episode Number Badge */}
|
||||||
|
<View style={styles.episodeNumberBadgeHorizontal}>
|
||||||
|
<Text style={styles.episodeNumberHorizontal}>{episodeString}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Episode Title */}
|
||||||
|
<Text style={styles.episodeTitleHorizontal} numberOfLines={2}>
|
||||||
|
{episode.name}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Episode Description */}
|
||||||
|
<Text style={styles.episodeDescriptionHorizontal} numberOfLines={3}>
|
||||||
|
{episode.overview || 'No description available'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Metadata Row */}
|
||||||
|
<View style={styles.episodeMetadataRowHorizontal}>
|
||||||
|
{episode.runtime && (
|
||||||
|
<View style={styles.runtimeContainerHorizontal}>
|
||||||
|
<Text style={styles.runtimeTextHorizontal}>
|
||||||
|
{formatRuntime(episode.runtime)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{episode.vote_average > 0 && (
|
||||||
|
<View style={styles.ratingContainerHorizontal}>
|
||||||
|
<MaterialIcons name="star" size={14} color="#FFD700" />
|
||||||
|
<Text style={styles.ratingTextHorizontal}>
|
||||||
|
{episode.vote_average.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
{showProgress && (
|
||||||
|
<View style={styles.progressBarContainerHorizontal}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.progressBarHorizontal,
|
||||||
|
{
|
||||||
|
width: `${progressPercent}%`,
|
||||||
|
backgroundColor: currentTheme.colors.primary,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Completed Badge */}
|
||||||
|
{progressPercent >= 85 && (
|
||||||
|
<View style={[styles.completedBadgeHorizontal, {
|
||||||
|
backgroundColor: currentTheme.colors.primary,
|
||||||
|
}]}>
|
||||||
|
<MaterialIcons name="check" size={16} color="#fff" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
|
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -308,21 +547,48 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
|
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
{settings.episodeLayoutStyle === 'horizontal' ? (
|
||||||
|
// Horizontal Layout (Netflix-style)
|
||||||
|
<ScrollView
|
||||||
|
ref={episodeScrollViewRef}
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
style={styles.episodeList}
|
||||||
|
contentContainerStyle={styles.episodeListContentHorizontal}
|
||||||
|
decelerationRate="fast"
|
||||||
|
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
|
||||||
|
snapToAlignment="start"
|
||||||
|
>
|
||||||
|
{currentSeasonEpisodes.map((episode, index) => (
|
||||||
|
<Animated.View
|
||||||
|
key={episode.id}
|
||||||
|
entering={FadeIn.duration(400).delay(300 + index * 50)}
|
||||||
|
style={[
|
||||||
|
styles.episodeCardWrapperHorizontal,
|
||||||
|
isTablet && styles.episodeCardWrapperHorizontalTablet
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{renderHorizontalEpisodeCard(episode)}
|
||||||
|
</Animated.View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
// Vertical Layout (Traditional)
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.episodeList}
|
style={styles.episodeList}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.episodeListContent,
|
styles.episodeListContentVertical,
|
||||||
isTablet && styles.episodeListContentTablet
|
isTablet && styles.episodeListContentVerticalTablet
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isTablet ? (
|
{isTablet ? (
|
||||||
<View style={styles.episodeGrid}>
|
<View style={styles.episodeGridVertical}>
|
||||||
{currentSeasonEpisodes.map((episode, index) => (
|
{currentSeasonEpisodes.map((episode, index) => (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key={episode.id}
|
key={episode.id}
|
||||||
entering={FadeIn.duration(400).delay(300 + index * 50)}
|
entering={FadeIn.duration(400).delay(300 + index * 50)}
|
||||||
>
|
>
|
||||||
{renderEpisodeCard(episode)}
|
{renderVerticalEpisodeCard(episode)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -332,11 +598,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
key={episode.id}
|
key={episode.id}
|
||||||
entering={FadeIn.duration(400).delay(300 + index * 50)}
|
entering={FadeIn.duration(400).delay(300 + index * 50)}
|
||||||
>
|
>
|
||||||
{renderEpisodeCard(episode)}
|
{renderVerticalEpisodeCard(episode)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
@ -345,7 +612,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
padding: 16,
|
paddingVertical: 16,
|
||||||
},
|
},
|
||||||
centeredContainer: {
|
centeredContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -362,22 +629,26 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
episodeList: {
|
episodeList: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
episodeListContent: {
|
|
||||||
|
// Vertical Layout Styles
|
||||||
|
episodeListContentVertical: {
|
||||||
paddingBottom: 20,
|
paddingBottom: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
episodeListContentTablet: {
|
episodeListContentVerticalTablet: {
|
||||||
paddingHorizontal: 8,
|
paddingHorizontal: 8,
|
||||||
},
|
},
|
||||||
episodeGrid: {
|
episodeGridVertical: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
episodeCard: {
|
episodeCardVertical: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
|
@ -391,7 +662,7 @@ const styles = StyleSheet.create({
|
||||||
borderColor: 'rgba(255,255,255,0.1)',
|
borderColor: 'rgba(255,255,255,0.1)',
|
||||||
height: 120,
|
height: 120,
|
||||||
},
|
},
|
||||||
episodeCardTablet: {
|
episodeCardVerticalTablet: {
|
||||||
width: '48%',
|
width: '48%',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
height: 120,
|
height: 120,
|
||||||
|
|
@ -461,6 +732,19 @@ const styles = StyleSheet.create({
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
},
|
},
|
||||||
|
runtimeContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||||
|
paddingHorizontal: 4,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
runtimeText: {
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
airDateText: {
|
airDateText: {
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
opacity: 0.8,
|
opacity: 0.8,
|
||||||
|
|
@ -469,8 +753,170 @@ const styles = StyleSheet.create({
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
lineHeight: 18,
|
lineHeight: 18,
|
||||||
},
|
},
|
||||||
|
progressBarContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
},
|
||||||
|
progressBar: {
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
completedBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 8,
|
||||||
|
right: 8,
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.3)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Horizontal Layout Styles
|
||||||
|
episodeListContentHorizontal: {
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingRight: 16,
|
||||||
|
},
|
||||||
|
episodeCardWrapperHorizontal: {
|
||||||
|
width: Dimensions.get('window').width * 0.85,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
episodeCardWrapperHorizontalTablet: {
|
||||||
|
width: Dimensions.get('window').width * 0.4,
|
||||||
|
},
|
||||||
|
episodeCardHorizontal: {
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
elevation: 8,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 12,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.05)',
|
||||||
|
height: 200,
|
||||||
|
position: 'relative',
|
||||||
|
width: '100%',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
},
|
||||||
|
episodeCardHorizontalTablet: {
|
||||||
|
height: 180,
|
||||||
|
},
|
||||||
|
episodeBackgroundImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 16,
|
||||||
|
},
|
||||||
|
episodeGradient: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 16,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
episodeContent: {
|
||||||
|
padding: 12,
|
||||||
|
paddingBottom: 16,
|
||||||
|
},
|
||||||
|
episodeNumberBadgeHorizontal: {
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 3,
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: 6,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
episodeNumberHorizontal: {
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 0.8,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
episodeTitleHorizontal: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
letterSpacing: -0.3,
|
||||||
|
marginBottom: 4,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
|
episodeDescriptionHorizontal: {
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
fontSize: 12,
|
||||||
|
lineHeight: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
episodeMetadataRowHorizontal: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
runtimeContainerHorizontal: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
},
|
||||||
|
runtimeTextHorizontal: {
|
||||||
|
color: 'rgba(255,255,255,0.8)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
ratingContainerHorizontal: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
paddingHorizontal: 5,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
ratingTextHorizontal: {
|
||||||
|
color: '#FFD700',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
progressBarContainerHorizontal: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 3,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
progressBarHorizontal: {
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
completedBadgeHorizontal: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 12,
|
||||||
|
right: 12,
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#fff',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Season Selector Styles
|
||||||
seasonSelectorWrapper: {
|
seasonSelectorWrapper: {
|
||||||
marginBottom: 20,
|
marginBottom: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
},
|
},
|
||||||
seasonSelectorTitle: {
|
seasonSelectorTitle: {
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
|
|
@ -517,54 +963,4 @@ const styles = StyleSheet.create({
|
||||||
selectedSeasonButtonText: {
|
selectedSeasonButtonText: {
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
progressBarContainer: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: 3,
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
|
||||||
},
|
|
||||||
progressBar: {
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
progressTextContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
||||||
paddingHorizontal: 6,
|
|
||||||
paddingVertical: 3,
|
|
||||||
borderRadius: 4,
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
progressText: {
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
completedBadge: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 8,
|
|
||||||
right: 8,
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
borderRadius: 10,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'rgba(255,255,255,0.3)',
|
|
||||||
},
|
|
||||||
runtimeContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
|
||||||
paddingHorizontal: 4,
|
|
||||||
paddingVertical: 2,
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
|
||||||
runtimeText: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
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',
|
name: 'Moonlight',
|
||||||
colors: {
|
colors: {
|
||||||
...defaultColors,
|
...defaultColors,
|
||||||
primary: '#a786df',
|
primary: '#c084fc',
|
||||||
secondary: '#5e72e4',
|
secondary: '#60a5fa',
|
||||||
darkBackground: '#0f0f1a',
|
darkBackground: '#060609',
|
||||||
},
|
},
|
||||||
isEditable: false,
|
isEditable: false,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,13 @@
|
||||||
import React, { createContext, useContext, ReactNode } from 'react';
|
import React, { createContext, useContext, ReactNode } from 'react';
|
||||||
import { useTraktIntegration } from '../hooks/useTraktIntegration';
|
import { useTraktIntegration } from '../hooks/useTraktIntegration';
|
||||||
import { TraktUser, TraktWatchedItem } from '../services/traktService';
|
import {
|
||||||
|
TraktUser,
|
||||||
|
TraktWatchedItem,
|
||||||
|
TraktWatchlistItem,
|
||||||
|
TraktCollectionItem,
|
||||||
|
TraktRatingItem,
|
||||||
|
TraktPlaybackItem
|
||||||
|
} from '../services/traktService';
|
||||||
|
|
||||||
interface TraktContextProps {
|
interface TraktContextProps {
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
|
@ -8,13 +15,21 @@ interface TraktContextProps {
|
||||||
userProfile: TraktUser | null;
|
userProfile: TraktUser | null;
|
||||||
watchedMovies: TraktWatchedItem[];
|
watchedMovies: TraktWatchedItem[];
|
||||||
watchedShows: TraktWatchedItem[];
|
watchedShows: TraktWatchedItem[];
|
||||||
|
watchlistMovies: TraktWatchlistItem[];
|
||||||
|
watchlistShows: TraktWatchlistItem[];
|
||||||
|
collectionMovies: TraktCollectionItem[];
|
||||||
|
collectionShows: TraktCollectionItem[];
|
||||||
|
continueWatching: TraktPlaybackItem[];
|
||||||
|
ratedContent: TraktRatingItem[];
|
||||||
checkAuthStatus: () => Promise<void>;
|
checkAuthStatus: () => Promise<void>;
|
||||||
refreshAuthStatus: () => Promise<void>;
|
refreshAuthStatus: () => Promise<void>;
|
||||||
loadWatchedItems: () => Promise<void>;
|
loadWatchedItems: () => Promise<void>;
|
||||||
|
loadAllCollections: () => Promise<void>;
|
||||||
isMovieWatched: (imdbId: string) => Promise<boolean>;
|
isMovieWatched: (imdbId: string) => Promise<boolean>;
|
||||||
isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>;
|
isEpisodeWatched: (imdbId: string, season: number, episode: number) => Promise<boolean>;
|
||||||
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>;
|
markMovieAsWatched: (imdbId: string, watchedAt?: Date) => Promise<boolean>;
|
||||||
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>;
|
markEpisodeAsWatched: (imdbId: string, season: number, episode: number, watchedAt?: Date) => Promise<boolean>;
|
||||||
|
forceSyncTraktProgress?: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TraktContext = createContext<TraktContextProps | undefined>(undefined);
|
const TraktContext = createContext<TraktContextProps | undefined>(undefined);
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,15 @@ import { StreamingContent } from '../services/catalogService';
|
||||||
import { catalogService } from '../services/catalogService';
|
import { catalogService } from '../services/catalogService';
|
||||||
import { stremioService } from '../services/stremioService';
|
import { stremioService } from '../services/stremioService';
|
||||||
import { tmdbService } from '../services/tmdbService';
|
import { tmdbService } from '../services/tmdbService';
|
||||||
|
import { hdrezkaService } from '../services/hdrezkaService';
|
||||||
import { cacheService } from '../services/cacheService';
|
import { cacheService } from '../services/cacheService';
|
||||||
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
|
import { Cast, Episode, GroupedEpisodes, GroupedStreams } from '../types/metadata';
|
||||||
import { TMDBService } from '../services/tmdbService';
|
import { TMDBService } from '../services/tmdbService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { usePersistentSeasons } from './usePersistentSeasons';
|
import { usePersistentSeasons } from './usePersistentSeasons';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Stream } from '../types/metadata';
|
||||||
|
import { storageService } from '../services/storageService';
|
||||||
|
|
||||||
// Constants for timeouts and retries
|
// Constants for timeouts and retries
|
||||||
const API_TIMEOUT = 10000; // 10 seconds
|
const API_TIMEOUT = 10000; // 10 seconds
|
||||||
|
|
@ -56,6 +60,7 @@ const withRetry = async <T>(
|
||||||
interface UseMetadataProps {
|
interface UseMetadataProps {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
addonId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UseMetadataReturn {
|
interface UseMetadataReturn {
|
||||||
|
|
@ -90,7 +95,7 @@ interface UseMetadataReturn {
|
||||||
imdbId: string | null;
|
imdbId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn => {
|
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
|
||||||
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
|
const [metadata, setMetadata] = useState<StreamingContent | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
@ -113,6 +118,8 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
|
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
|
||||||
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
|
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
|
||||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
|
||||||
|
|
||||||
// Add hook for persistent seasons
|
// Add hook for persistent seasons
|
||||||
const { getSeason, saveSeason } = usePersistentSeasons();
|
const { getSeason, saveSeason } = usePersistentSeasons();
|
||||||
|
|
@ -150,8 +157,12 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
|
|
||||||
if (isEpisode) {
|
if (isEpisode) {
|
||||||
setEpisodeStreams(updateState);
|
setEpisodeStreams(updateState);
|
||||||
|
// Turn off loading when we get streams
|
||||||
|
setLoadingEpisodeStreams(false);
|
||||||
} else {
|
} else {
|
||||||
setGroupedStreams(updateState);
|
setGroupedStreams(updateState);
|
||||||
|
// Turn off loading when we get streams
|
||||||
|
setLoadingStreams(false);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
|
logger.log(`🤷 [${logPrefix}:${sourceName}] No streams found for addon ${addonName} (${addonId})`);
|
||||||
|
|
@ -173,22 +184,60 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
// Loading indicators should probably be managed based on callbacks completing.
|
// Loading indicators should probably be managed based on callbacks completing.
|
||||||
};
|
};
|
||||||
|
|
||||||
const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
|
const processHDRezkaSource = async (type: string, id: string, season?: number, episode?: number, isEpisode = false) => {
|
||||||
const sourceStartTime = Date.now();
|
const sourceStartTime = Date.now();
|
||||||
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
|
const logPrefix = isEpisode ? 'loadEpisodeStreams' : 'loadStreams';
|
||||||
|
const sourceName = 'hdrezka';
|
||||||
|
|
||||||
|
logger.log(`🔍 [${logPrefix}:${sourceName}] Starting fetch`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
logger.log(`🔍 [${logPrefix}:${sourceType}] Starting fetch`);
|
const streams = await hdrezkaService.getStreams(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
season,
|
||||||
|
episode
|
||||||
|
);
|
||||||
|
|
||||||
|
const processTime = Date.now() - sourceStartTime;
|
||||||
|
|
||||||
|
if (streams && streams.length > 0) {
|
||||||
|
logger.log(`✅ [${logPrefix}:${sourceName}] Received ${streams.length} streams after ${processTime}ms`);
|
||||||
|
|
||||||
|
// Format response similar to Stremio format for the UI
|
||||||
|
return {
|
||||||
|
'hdrezka': {
|
||||||
|
addonName: 'HDRezka',
|
||||||
|
streams
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
logger.log(`⚠️ [${logPrefix}:${sourceName}] No streams found after ${processTime}ms`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ [${logPrefix}:${sourceName}] Error:`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const processExternalSource = async (sourceType: string, promise: Promise<any>, isEpisode = false) => {
|
||||||
|
try {
|
||||||
|
const startTime = Date.now();
|
||||||
const result = await promise;
|
const result = await promise;
|
||||||
logger.log(`✅ [${logPrefix}:${sourceType}] Completed in ${Date.now() - sourceStartTime}ms`);
|
const processingTime = Date.now() - startTime;
|
||||||
|
|
||||||
if (Object.keys(result).length > 0) {
|
|
||||||
const totalStreams = Object.values(result).reduce((acc, group: any) => acc + (group.streams?.length || 0), 0);
|
|
||||||
logger.log(`📦 [${logPrefix}:${sourceType}] Found ${totalStreams} streams`);
|
|
||||||
|
|
||||||
|
if (result && Object.keys(result).length > 0) {
|
||||||
|
// Update the appropriate state based on whether this is for an episode or not
|
||||||
const updateState = (prevState: GroupedStreams) => {
|
const updateState = (prevState: GroupedStreams) => {
|
||||||
logger.log(`🔄 [${logPrefix}:${sourceType}] Updating state with ${Object.keys(result).length} providers`);
|
const newState = { ...prevState };
|
||||||
return { ...prevState, ...result };
|
|
||||||
|
// Merge in the new streams
|
||||||
|
Object.entries(result).forEach(([provider, data]: [string, any]) => {
|
||||||
|
newState[provider] = data;
|
||||||
|
});
|
||||||
|
|
||||||
|
return newState;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEpisode) {
|
if (isEpisode) {
|
||||||
|
|
@ -196,12 +245,19 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
} else {
|
} else {
|
||||||
setGroupedStreams(updateState);
|
setGroupedStreams(updateState);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
logger.log(`⚠️ [${logPrefix}:${sourceType}] No streams found`);
|
console.log(`✅ [processExternalSource:${sourceType}] Processed in ${processingTime}ms, found streams:`,
|
||||||
}
|
Object.values(result).reduce((acc: number, curr: any) => acc + (curr.streams?.length || 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the result for the promise chain
|
||||||
return result;
|
return result;
|
||||||
|
} else {
|
||||||
|
console.log(`⚠️ [processExternalSource:${sourceType}] No streams found after ${processingTime}ms`);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ [${logPrefix}:${sourceType}] Error:`, error);
|
console.error(`❌ [processExternalSource:${sourceType}] Error:`, error);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -356,7 +412,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
|
|
||||||
if (writers.length > 0) {
|
if (writers.length > 0) {
|
||||||
(formattedMovie as any).creators = writers;
|
(formattedMovie as any).creators = writers;
|
||||||
(formattedMovie as StreamingContent & { writer: string }).writer = writers.join(', ');
|
(formattedMovie as any).writer = writers;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -459,7 +515,7 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
// Load content with timeout and retry
|
// Load content with timeout and retry
|
||||||
withRetry(async () => {
|
withRetry(async () => {
|
||||||
const result = await withTimeout(
|
const result = await withTimeout(
|
||||||
catalogService.getContentDetails(type, actualId),
|
catalogService.getEnhancedContentDetails(type, actualId, addonId),
|
||||||
API_TIMEOUT
|
API_TIMEOUT
|
||||||
);
|
);
|
||||||
// Store the actual ID used (could be IMDB)
|
// Store the actual ID used (could be IMDB)
|
||||||
|
|
@ -485,8 +541,10 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
cacheService.setMetadata(id, type, content.value);
|
cacheService.setMetadata(id, type, content.value);
|
||||||
|
|
||||||
if (type === 'series') {
|
if (type === 'series') {
|
||||||
// Load series data in parallel with other data
|
// Load series data after the enhanced metadata is processed
|
||||||
|
setTimeout(() => {
|
||||||
loadSeriesData().catch(console.error);
|
loadSeriesData().catch(console.error);
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Content not found');
|
throw new Error('Content not found');
|
||||||
|
|
@ -509,6 +567,67 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
const loadSeriesData = async () => {
|
const loadSeriesData = async () => {
|
||||||
setLoadingSeasons(true);
|
setLoadingSeasons(true);
|
||||||
try {
|
try {
|
||||||
|
// First check if we have episode data from the addon
|
||||||
|
const addonVideos = metadata?.videos;
|
||||||
|
if (addonVideos && Array.isArray(addonVideos) && addonVideos.length > 0) {
|
||||||
|
logger.log(`🎬 Found ${addonVideos.length} episodes from addon metadata for ${metadata?.name || id}`);
|
||||||
|
|
||||||
|
// Group addon episodes by season
|
||||||
|
const groupedAddonEpisodes: GroupedEpisodes = {};
|
||||||
|
|
||||||
|
addonVideos.forEach((video: any) => {
|
||||||
|
const seasonNumber = video.season || 1;
|
||||||
|
const episodeNumber = video.episode || video.number || 1;
|
||||||
|
|
||||||
|
if (!groupedAddonEpisodes[seasonNumber]) {
|
||||||
|
groupedAddonEpisodes[seasonNumber] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert addon episode format to our Episode interface
|
||||||
|
const episode: Episode = {
|
||||||
|
id: video.id,
|
||||||
|
name: video.name || video.title || `Episode ${episodeNumber}`,
|
||||||
|
overview: video.overview || video.description || '',
|
||||||
|
season_number: seasonNumber,
|
||||||
|
episode_number: episodeNumber,
|
||||||
|
air_date: video.released ? video.released.split('T')[0] : video.firstAired ? video.firstAired.split('T')[0] : '',
|
||||||
|
still_path: video.thumbnail ? video.thumbnail.replace('https://image.tmdb.org/t/p/w500', '') : null,
|
||||||
|
vote_average: parseFloat(video.rating) || 0,
|
||||||
|
runtime: undefined,
|
||||||
|
episodeString: `S${seasonNumber.toString().padStart(2, '0')}E${episodeNumber.toString().padStart(2, '0')}`,
|
||||||
|
stremioId: video.id,
|
||||||
|
season_poster_path: null
|
||||||
|
};
|
||||||
|
|
||||||
|
groupedAddonEpisodes[seasonNumber].push(episode);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort episodes within each season
|
||||||
|
Object.keys(groupedAddonEpisodes).forEach(season => {
|
||||||
|
groupedAddonEpisodes[parseInt(season)].sort((a, b) => a.episode_number - b.episode_number);
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.log(`📺 Processed addon episodes into ${Object.keys(groupedAddonEpisodes).length} seasons`);
|
||||||
|
setGroupedEpisodes(groupedAddonEpisodes);
|
||||||
|
|
||||||
|
// Set the first available season
|
||||||
|
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
|
||||||
|
const firstSeason = Math.min(...seasons);
|
||||||
|
logger.log(`📺 Setting season ${firstSeason} as selected (${groupedAddonEpisodes[firstSeason]?.length || 0} episodes)`);
|
||||||
|
setSelectedSeason(firstSeason);
|
||||||
|
setEpisodes(groupedAddonEpisodes[firstSeason] || []);
|
||||||
|
|
||||||
|
// Try to get TMDB ID for additional metadata (cast, etc.) but don't override episodes
|
||||||
|
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
|
||||||
|
if (tmdbIdResult) {
|
||||||
|
setTmdbId(tmdbIdResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
return; // Use addon episodes, skip TMDB loading
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to TMDB if no addon episodes
|
||||||
|
logger.log('📺 No addon episodes found, falling back to TMDB');
|
||||||
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
|
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
|
||||||
if (tmdbIdResult) {
|
if (tmdbIdResult) {
|
||||||
setTmdbId(tmdbIdResult);
|
setTmdbId(tmdbIdResult);
|
||||||
|
|
@ -535,14 +654,61 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
// Get the first available season as fallback
|
// Get the first available season as fallback
|
||||||
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
|
const firstSeason = Math.min(...Object.keys(allEpisodes).map(Number));
|
||||||
|
|
||||||
// Get saved season from persistence, fallback to first season if not found
|
// Check for watch progress to auto-select season
|
||||||
const persistedSeason = getSeason(id, firstSeason);
|
let selectedSeasonNumber = firstSeason;
|
||||||
|
|
||||||
// Set the selected season from persistence
|
try {
|
||||||
setSelectedSeason(persistedSeason);
|
// Check watch progress for auto-season selection
|
||||||
|
const allProgress = await storageService.getAllWatchProgress();
|
||||||
|
|
||||||
|
// Find the most recently watched episode for this series
|
||||||
|
let mostRecentEpisodeId = '';
|
||||||
|
let mostRecentTimestamp = 0;
|
||||||
|
|
||||||
|
Object.entries(allProgress).forEach(([key, progress]) => {
|
||||||
|
if (key.includes(`series:${id}:`)) {
|
||||||
|
const episodeId = key.split(`series:${id}:`)[1];
|
||||||
|
if (progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
|
||||||
|
mostRecentTimestamp = progress.lastUpdated;
|
||||||
|
mostRecentEpisodeId = episodeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (mostRecentEpisodeId) {
|
||||||
|
// Parse season number from episode ID
|
||||||
|
const parts = mostRecentEpisodeId.split(':');
|
||||||
|
if (parts.length === 3) {
|
||||||
|
const watchProgressSeason = parseInt(parts[1], 10);
|
||||||
|
if (transformedEpisodes[watchProgressSeason]) {
|
||||||
|
selectedSeasonNumber = watchProgressSeason;
|
||||||
|
logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for ${mostRecentEpisodeId}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Try to find episode by stremioId to get season
|
||||||
|
const allEpisodesList = Object.values(transformedEpisodes).flat();
|
||||||
|
const episode = allEpisodesList.find(ep => ep.stremioId === mostRecentEpisodeId);
|
||||||
|
if (episode) {
|
||||||
|
selectedSeasonNumber = episode.season_number;
|
||||||
|
logger.log(`[useMetadata] Auto-selected season ${selectedSeasonNumber} based on most recent watch progress for episode with stremioId ${mostRecentEpisodeId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No watch progress found, use persistent storage as fallback
|
||||||
|
selectedSeasonNumber = getSeason(id, firstSeason);
|
||||||
|
logger.log(`[useMetadata] No watch progress found, using persistent season ${selectedSeasonNumber}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useMetadata] Error checking watch progress for season selection:', error);
|
||||||
|
// Fall back to persistent storage
|
||||||
|
selectedSeasonNumber = getSeason(id, firstSeason);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the selected season
|
||||||
|
setSelectedSeason(selectedSeasonNumber);
|
||||||
|
|
||||||
// Set episodes for the selected season
|
// Set episodes for the selected season
|
||||||
setEpisodes(transformedEpisodes[persistedSeason] || []);
|
setEpisodes(transformedEpisodes[selectedSeasonNumber] || []);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load episodes:', error);
|
console.error('Failed to load episodes:', error);
|
||||||
|
|
@ -575,39 +741,71 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
console.log('🚀 [loadStreams] START - Loading streams for:', id);
|
console.log('🚀 [loadStreams] START - Loading streams for:', id);
|
||||||
updateLoadingState();
|
updateLoadingState();
|
||||||
|
|
||||||
// Always clear streams first to ensure we don't show stale data
|
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
|
||||||
setGroupedStreams({});
|
|
||||||
|
|
||||||
// Get TMDB ID for external sources first before starting parallel requests
|
|
||||||
console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
|
console.log('🔍 [loadStreams] Getting TMDB ID for:', id);
|
||||||
let tmdbId;
|
let tmdbId;
|
||||||
|
let stremioId = id; // Default to original ID
|
||||||
|
|
||||||
if (id.startsWith('tmdb:')) {
|
if (id.startsWith('tmdb:')) {
|
||||||
tmdbId = id.split(':')[1];
|
tmdbId = id.split(':')[1];
|
||||||
console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId);
|
console.log('✅ [loadStreams] Using TMDB ID from ID:', tmdbId);
|
||||||
|
|
||||||
|
// Try to get IMDb ID from metadata first, then convert if needed
|
||||||
|
if (metadata?.imdb_id) {
|
||||||
|
stremioId = metadata.imdb_id;
|
||||||
|
console.log('✅ [loadStreams] Using IMDb ID from metadata for Stremio:', stremioId);
|
||||||
|
} else if (imdbId) {
|
||||||
|
stremioId = imdbId;
|
||||||
|
console.log('✅ [loadStreams] Using stored IMDb ID for Stremio:', stremioId);
|
||||||
|
} else {
|
||||||
|
// Convert TMDB ID to IMDb ID for Stremio addons (they expect IMDb format)
|
||||||
|
try {
|
||||||
|
let externalIds = null;
|
||||||
|
if (type === 'movie') {
|
||||||
|
const movieDetails = await withTimeout(tmdbService.getMovieDetails(tmdbId), API_TIMEOUT);
|
||||||
|
externalIds = movieDetails?.external_ids;
|
||||||
|
} else if (type === 'series') {
|
||||||
|
externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (externalIds?.imdb_id) {
|
||||||
|
stremioId = externalIds.imdb_id;
|
||||||
|
console.log('✅ [loadStreams] Converted TMDB to IMDb ID for Stremio:', stremioId);
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ [loadStreams] No IMDb ID found for TMDB ID, using original:', stremioId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ [loadStreams] Failed to convert TMDB to IMDb, using original ID:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (id.startsWith('tt')) {
|
} else if (id.startsWith('tt')) {
|
||||||
// This is an IMDB ID
|
// This is already an IMDB ID, perfect for Stremio
|
||||||
|
stremioId = id;
|
||||||
console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...');
|
console.log('📝 [loadStreams] Converting IMDB ID to TMDB ID...');
|
||||||
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
||||||
console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId);
|
console.log('✅ [loadStreams] Converted to TMDB ID:', tmdbId);
|
||||||
} else {
|
} else {
|
||||||
tmdbId = id;
|
tmdbId = id;
|
||||||
console.log('ℹ️ [loadStreams] Using ID as TMDB ID:', tmdbId);
|
stremioId = id;
|
||||||
|
console.log('ℹ️ [loadStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('🔄 [loadStreams] Starting stream requests');
|
// Start Stremio request using the converted ID format
|
||||||
|
console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId);
|
||||||
|
processStremioSource(type, stremioId, false);
|
||||||
|
|
||||||
// Start Stremio request using the callback method
|
// Add HDRezka source
|
||||||
processStremioSource(type, id, false);
|
const hdrezkaPromise = processExternalSource('hdrezka', processHDRezkaSource(type, id), false);
|
||||||
|
|
||||||
// No external sources are used anymore
|
// Include HDRezka in fetchPromises array
|
||||||
const fetchPromises: Promise<any>[] = [];
|
const fetchPromises: Promise<any>[] = [hdrezkaPromise];
|
||||||
|
|
||||||
// Wait only for external promises now (none in this case)
|
// Wait only for external promises now
|
||||||
const results = await Promise.allSettled(fetchPromises);
|
const results = await Promise.allSettled(fetchPromises);
|
||||||
const totalTime = Date.now() - startTime;
|
const totalTime = Date.now() - startTime;
|
||||||
console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
|
console.log(`✅ [loadStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
|
||||||
|
|
||||||
const sourceTypes: string[] = []; // No external sources
|
const sourceTypes: string[] = ['hdrezka'];
|
||||||
results.forEach((result, index) => {
|
results.forEach((result, index) => {
|
||||||
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
|
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
|
||||||
console.log(`📊 [loadStreams:${source}] Status: ${result.status}`);
|
console.log(`📊 [loadStreams:${source}] Status: ${result.status}`);
|
||||||
|
|
@ -634,15 +832,15 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add a delay before marking loading as complete to give Stremio addons more time
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoadingStreams(false);
|
||||||
|
}, 10000); // 10 second delay to allow streams to load
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [loadStreams] Failed to load streams:', error);
|
console.error('❌ [loadStreams] Failed to load streams:', error);
|
||||||
setError('Failed to load streams');
|
setError('Failed to load streams');
|
||||||
} finally {
|
setLoadingStreams(false);
|
||||||
// Loading is now complete when external sources finish, Stremio updates happen independently.
|
|
||||||
// We need a better way to track overall completion if we want a final 'FINISHED' log.
|
|
||||||
const endTime = Date.now() - startTime;
|
|
||||||
console.log(`🏁 [loadStreams] External sources FINISHED in ${endTime}ms`);
|
|
||||||
setLoadingStreams(false); // Mark loading=false, but Stremio might still be working
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -652,42 +850,76 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
|
console.log('🚀 [loadEpisodeStreams] START - Loading episode streams for:', episodeId);
|
||||||
updateEpisodeLoadingState();
|
updateEpisodeLoadingState();
|
||||||
|
|
||||||
// Get TMDB ID for external sources first before starting parallel requests
|
// Get TMDB ID for external sources and determine the correct ID for Stremio addons
|
||||||
console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
|
console.log('🔍 [loadEpisodeStreams] Getting TMDB ID for:', id);
|
||||||
let tmdbId;
|
let tmdbId;
|
||||||
|
let stremioEpisodeId = episodeId; // Default to original episode ID
|
||||||
|
|
||||||
if (id.startsWith('tmdb:')) {
|
if (id.startsWith('tmdb:')) {
|
||||||
tmdbId = id.split(':')[1];
|
tmdbId = id.split(':')[1];
|
||||||
console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
|
console.log('✅ [loadEpisodeStreams] Using TMDB ID from ID:', tmdbId);
|
||||||
|
|
||||||
|
// Try to get IMDb ID from metadata first, then convert if needed
|
||||||
|
if (metadata?.imdb_id) {
|
||||||
|
// Replace the series ID in episodeId with the IMDb ID
|
||||||
|
const [, season, episode] = episodeId.split(':');
|
||||||
|
stremioEpisodeId = `series:${metadata.imdb_id}:${season}:${episode}`;
|
||||||
|
console.log('✅ [loadEpisodeStreams] Using IMDb ID from metadata for Stremio episode:', stremioEpisodeId);
|
||||||
|
} else if (imdbId) {
|
||||||
|
const [, season, episode] = episodeId.split(':');
|
||||||
|
stremioEpisodeId = `series:${imdbId}:${season}:${episode}`;
|
||||||
|
console.log('✅ [loadEpisodeStreams] Using stored IMDb ID for Stremio episode:', stremioEpisodeId);
|
||||||
|
} else {
|
||||||
|
// Convert TMDB ID to IMDb ID for Stremio addons
|
||||||
|
try {
|
||||||
|
const externalIds = await withTimeout(tmdbService.getShowExternalIds(parseInt(tmdbId)), API_TIMEOUT);
|
||||||
|
|
||||||
|
if (externalIds?.imdb_id) {
|
||||||
|
const [, season, episode] = episodeId.split(':');
|
||||||
|
stremioEpisodeId = `series:${externalIds.imdb_id}:${season}:${episode}`;
|
||||||
|
console.log('✅ [loadEpisodeStreams] Converted TMDB to IMDb ID for Stremio episode:', stremioEpisodeId);
|
||||||
|
} else {
|
||||||
|
console.log('⚠️ [loadEpisodeStreams] No IMDb ID found for TMDB ID, using original episode ID:', stremioEpisodeId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('⚠️ [loadEpisodeStreams] Failed to convert TMDB to IMDb, using original episode ID:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
} else if (id.startsWith('tt')) {
|
} else if (id.startsWith('tt')) {
|
||||||
// This is an IMDB ID
|
// This is already an IMDB ID, perfect for Stremio
|
||||||
console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
|
console.log('📝 [loadEpisodeStreams] Converting IMDB ID to TMDB ID...');
|
||||||
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
tmdbId = await withTimeout(tmdbService.findTMDBIdByIMDB(id), API_TIMEOUT);
|
||||||
console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
|
console.log('✅ [loadEpisodeStreams] Converted to TMDB ID:', tmdbId);
|
||||||
} else {
|
} else {
|
||||||
tmdbId = id;
|
tmdbId = id;
|
||||||
console.log('ℹ️ [loadEpisodeStreams] Using ID as TMDB ID:', tmdbId);
|
console.log('ℹ️ [loadEpisodeStreams] Using ID as both TMDB and Stremio ID:', tmdbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract episode info from the episodeId
|
// Extract episode info from the episodeId for logging
|
||||||
const [, season, episode] = episodeId.split(':');
|
const [, season, episode] = episodeId.split(':');
|
||||||
const episodeQuery = `?s=${season}&e=${episode}`;
|
const episodeQuery = `?s=${season}&e=${episode}`;
|
||||||
console.log(`ℹ️ [loadEpisodeStreams] Episode query: ${episodeQuery}`);
|
console.log(`ℹ️ [loadEpisodeStreams] Episode query: ${episodeQuery}`);
|
||||||
|
|
||||||
console.log('🔄 [loadEpisodeStreams] Starting stream requests');
|
console.log('🔄 [loadEpisodeStreams] Starting stream requests');
|
||||||
|
|
||||||
const fetchPromises: Promise<any>[] = [];
|
// Start Stremio request using the converted episode ID format
|
||||||
|
console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId);
|
||||||
|
processStremioSource('series', stremioEpisodeId, true);
|
||||||
|
|
||||||
// Start Stremio request using the callback method
|
// Add HDRezka source for episodes
|
||||||
processStremioSource('series', episodeId, true);
|
const hdrezkaEpisodePromise = processExternalSource('hdrezka',
|
||||||
|
processHDRezkaSource('series', id, parseInt(season), parseInt(episode), true),
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
// No external sources are used anymore
|
const fetchPromises: Promise<any>[] = [hdrezkaEpisodePromise];
|
||||||
|
|
||||||
// Wait only for external promises now (none in this case)
|
// Wait only for external promises now
|
||||||
const results = await Promise.allSettled(fetchPromises);
|
const results = await Promise.allSettled(fetchPromises);
|
||||||
const totalTime = Date.now() - startTime;
|
const totalTime = Date.now() - startTime;
|
||||||
console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
|
console.log(`✅ [loadEpisodeStreams] External source requests completed in ${totalTime}ms (Stremio continues in background)`);
|
||||||
|
|
||||||
const sourceTypes: string[] = []; // No external sources
|
const sourceTypes: string[] = ['hdrezka'];
|
||||||
results.forEach((result, index) => {
|
results.forEach((result, index) => {
|
||||||
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
|
const source = sourceTypes[Math.min(index, sourceTypes.length - 1)];
|
||||||
console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`);
|
console.log(`📊 [loadEpisodeStreams:${source}] Status: ${result.status}`);
|
||||||
|
|
@ -699,31 +931,23 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
console.log('🧮 [loadEpisodeStreams] Summary:');
|
console.log('🧮 [loadEpisodeStreams] Summary:');
|
||||||
console.log(' Total time for external sources:', totalTime + 'ms');
|
console.log(' Total time for external sources:', totalTime + 'ms');
|
||||||
|
|
||||||
// Log the final states - might not include all Stremio addons yet
|
// Update preloaded episode streams for future use
|
||||||
console.log('📦 [loadEpisodeStreams] Current combined streams count:',
|
if (Object.keys(episodeStreams).length > 0) {
|
||||||
Object.keys(episodeStreams).length > 0 ?
|
setPreloadedEpisodeStreams(prev => ({
|
||||||
Object.values(episodeStreams).reduce((acc, group: any) => acc + group.streams.length, 0) :
|
...prev,
|
||||||
0
|
[episodeId]: { ...episodeStreams }
|
||||||
);
|
|
||||||
|
|
||||||
// Cache the final streams state - Might be incomplete
|
|
||||||
setEpisodeStreams(prev => {
|
|
||||||
// Cache episode streams - maybe incrementally?
|
|
||||||
setPreloadedEpisodeStreams(currentPreloaded => ({
|
|
||||||
...currentPreloaded,
|
|
||||||
[episodeId]: prev
|
|
||||||
}));
|
}));
|
||||||
return prev;
|
}
|
||||||
});
|
|
||||||
|
// Add a delay before marking loading as complete to give addons more time
|
||||||
|
setTimeout(() => {
|
||||||
|
setLoadingEpisodeStreams(false);
|
||||||
|
}, 10000); // 10 second delay to allow streams to load
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
|
console.error('❌ [loadEpisodeStreams] Failed to load episode streams:', error);
|
||||||
setError('Failed to load episode streams');
|
setError('Failed to load episode streams');
|
||||||
} finally {
|
setLoadingEpisodeStreams(false);
|
||||||
// Loading is now complete when external sources finish
|
|
||||||
const endTime = Date.now() - startTime;
|
|
||||||
console.log(`🏁 [loadEpisodeStreams] External sources FINISHED in ${endTime}ms`);
|
|
||||||
setLoadingEpisodeStreams(false); // Mark loading=false, but Stremio might still be working
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -770,6 +994,14 @@ export const useMetadata = ({ id, type }: UseMetadataProps): UseMetadataReturn =
|
||||||
loadMetadata();
|
loadMetadata();
|
||||||
}, [id, type]);
|
}, [id, type]);
|
||||||
|
|
||||||
|
// Re-run series data loading when metadata updates with videos
|
||||||
|
useEffect(() => {
|
||||||
|
if (metadata && type === 'series' && metadata.videos && metadata.videos.length > 0) {
|
||||||
|
logger.log(`🎬 Metadata updated with ${metadata.videos.length} episodes, reloading series data`);
|
||||||
|
loadSeriesData().catch(console.error);
|
||||||
|
}
|
||||||
|
}, [metadata?.videos, type]);
|
||||||
|
|
||||||
const loadRecommendations = useCallback(async () => {
|
const loadRecommendations = useCallback(async () => {
|
||||||
if (!tmdbId) return;
|
if (!tmdbId) return;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,242 +6,156 @@ import {
|
||||||
withSpring,
|
withSpring,
|
||||||
Easing,
|
Easing,
|
||||||
useAnimatedScrollHandler,
|
useAnimatedScrollHandler,
|
||||||
interpolate,
|
runOnUI,
|
||||||
Extrapolate,
|
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
// Animation constants
|
// Highly optimized animation configurations
|
||||||
const springConfig = {
|
const fastSpring = {
|
||||||
damping: 20,
|
damping: 15,
|
||||||
mass: 1,
|
mass: 0.8,
|
||||||
stiffness: 100
|
stiffness: 150,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Animation timing constants for staggered appearance
|
const ultraFastSpring = {
|
||||||
const ANIMATION_DELAY_CONSTANTS = {
|
damping: 12,
|
||||||
HERO: 100,
|
mass: 0.6,
|
||||||
LOGO: 250,
|
stiffness: 200,
|
||||||
PROGRESS: 350,
|
};
|
||||||
GENRES: 400,
|
|
||||||
BUTTONS: 450,
|
// Ultra-optimized easing functions
|
||||||
CONTENT: 500
|
const easings = {
|
||||||
|
fast: Easing.out(Easing.quad),
|
||||||
|
ultraFast: Easing.out(Easing.linear),
|
||||||
|
natural: Easing.bezier(0.2, 0, 0.2, 1),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => {
|
export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => {
|
||||||
// Animation values for screen entrance
|
// Consolidated entrance animations - start with visible values for Android compatibility
|
||||||
const screenScale = useSharedValue(0.92);
|
const screenOpacity = useSharedValue(1);
|
||||||
const screenOpacity = useSharedValue(0);
|
const contentOpacity = useSharedValue(1);
|
||||||
|
|
||||||
// Animation values for hero section
|
// Combined hero animations
|
||||||
const heroHeight = useSharedValue(height * 0.5);
|
const heroOpacity = useSharedValue(1);
|
||||||
const heroScale = useSharedValue(1.05);
|
const heroScale = useSharedValue(1); // Start at 1 for Android compatibility
|
||||||
const heroOpacity = useSharedValue(0);
|
const heroHeightValue = useSharedValue(height * 0.5);
|
||||||
|
|
||||||
// Animation values for content
|
// Combined UI element animations
|
||||||
const contentTranslateY = useSharedValue(60);
|
const uiElementsOpacity = useSharedValue(1);
|
||||||
|
const uiElementsTranslateY = useSharedValue(0);
|
||||||
|
|
||||||
// Animation values for logo
|
// Progress animation - simplified to single value
|
||||||
const logoOpacity = useSharedValue(0);
|
const progressOpacity = useSharedValue(0);
|
||||||
const logoScale = useSharedValue(0.9);
|
|
||||||
|
|
||||||
// Animation values for progress
|
// Scroll values - minimal
|
||||||
const watchProgressOpacity = useSharedValue(0);
|
|
||||||
const watchProgressScaleY = useSharedValue(0);
|
|
||||||
|
|
||||||
// Animation values for genres
|
|
||||||
const genresOpacity = useSharedValue(0);
|
|
||||||
const genresTranslateY = useSharedValue(20);
|
|
||||||
|
|
||||||
// Animation values for buttons
|
|
||||||
const buttonsOpacity = useSharedValue(0);
|
|
||||||
const buttonsTranslateY = useSharedValue(30);
|
|
||||||
|
|
||||||
// Scroll values for parallax effect
|
|
||||||
const scrollY = useSharedValue(0);
|
const scrollY = useSharedValue(0);
|
||||||
const dampedScrollY = useSharedValue(0);
|
const headerProgress = useSharedValue(0); // Single value for all header animations
|
||||||
|
|
||||||
// Header animation values
|
// Static header elements Y for performance
|
||||||
const headerOpacity = useSharedValue(0);
|
const staticHeaderElementsY = useSharedValue(0);
|
||||||
const headerElementsY = useSharedValue(-10);
|
|
||||||
const headerElementsOpacity = useSharedValue(0);
|
|
||||||
|
|
||||||
// Start entrance animation
|
// Ultra-fast entrance sequence - batch animations for better performance
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Use a timeout to ensure the animations starts after the component is mounted
|
// Batch all entrance animations to run simultaneously
|
||||||
const animationTimeout = setTimeout(() => {
|
const enterAnimations = () => {
|
||||||
// 1. First animate the container
|
'worklet';
|
||||||
screenScale.value = withSpring(1, springConfig);
|
|
||||||
screenOpacity.value = withSpring(1, springConfig);
|
|
||||||
|
|
||||||
// 2. Then animate the hero section with a slight delay
|
// Start with slightly reduced values and animate to full visibility
|
||||||
setTimeout(() => {
|
screenOpacity.value = withTiming(1, {
|
||||||
heroOpacity.value = withSpring(1, {
|
duration: 250,
|
||||||
damping: 14,
|
easing: easings.fast
|
||||||
stiffness: 80
|
|
||||||
});
|
});
|
||||||
heroScale.value = withSpring(1, {
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 100
|
|
||||||
});
|
|
||||||
}, ANIMATION_DELAY_CONSTANTS.HERO);
|
|
||||||
|
|
||||||
// 3. Then animate the logo
|
heroOpacity.value = withTiming(1, {
|
||||||
setTimeout(() => {
|
duration: 300,
|
||||||
logoOpacity.value = withSpring(1, {
|
easing: easings.fast
|
||||||
damping: 12,
|
|
||||||
stiffness: 100
|
|
||||||
});
|
});
|
||||||
logoScale.value = withSpring(1, {
|
|
||||||
damping: 14,
|
|
||||||
stiffness: 90
|
|
||||||
});
|
|
||||||
}, ANIMATION_DELAY_CONSTANTS.LOGO);
|
|
||||||
|
|
||||||
// 4. Then animate the watch progress if applicable
|
heroScale.value = withSpring(1, ultraFastSpring);
|
||||||
setTimeout(() => {
|
|
||||||
if (watchProgress && watchProgress.duration > 0) {
|
|
||||||
watchProgressOpacity.value = withSpring(1, {
|
|
||||||
damping: 14,
|
|
||||||
stiffness: 100
|
|
||||||
});
|
|
||||||
watchProgressScaleY.value = withSpring(1, {
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 120
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, ANIMATION_DELAY_CONSTANTS.PROGRESS);
|
|
||||||
|
|
||||||
// 5. Then animate the genres
|
uiElementsOpacity.value = withTiming(1, {
|
||||||
setTimeout(() => {
|
duration: 400,
|
||||||
genresOpacity.value = withSpring(1, {
|
easing: easings.natural
|
||||||
damping: 14,
|
|
||||||
stiffness: 100
|
|
||||||
});
|
});
|
||||||
genresTranslateY.value = withSpring(0, {
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 120
|
|
||||||
});
|
|
||||||
}, ANIMATION_DELAY_CONSTANTS.GENRES);
|
|
||||||
|
|
||||||
// 6. Then animate the buttons
|
uiElementsTranslateY.value = withSpring(0, fastSpring);
|
||||||
setTimeout(() => {
|
|
||||||
buttonsOpacity.value = withSpring(1, {
|
|
||||||
damping: 14,
|
|
||||||
stiffness: 100
|
|
||||||
});
|
|
||||||
buttonsTranslateY.value = withSpring(0, {
|
|
||||||
damping: 18,
|
|
||||||
stiffness: 120
|
|
||||||
});
|
|
||||||
}, ANIMATION_DELAY_CONSTANTS.BUTTONS);
|
|
||||||
|
|
||||||
// 7. Finally animate the content section
|
contentOpacity.value = withTiming(1, {
|
||||||
setTimeout(() => {
|
duration: 350,
|
||||||
contentTranslateY.value = withSpring(0, {
|
easing: easings.fast
|
||||||
damping: 25,
|
|
||||||
mass: 1,
|
|
||||||
stiffness: 100
|
|
||||||
});
|
});
|
||||||
}, ANIMATION_DELAY_CONSTANTS.CONTENT);
|
|
||||||
}, 50); // Small timeout to ensure component is fully mounted
|
|
||||||
|
|
||||||
return () => clearTimeout(animationTimeout);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Effect to animate watch progress when it changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (watchProgress && watchProgress.duration > 0) {
|
|
||||||
watchProgressOpacity.value = withSpring(1, {
|
|
||||||
mass: 0.2,
|
|
||||||
stiffness: 100,
|
|
||||||
damping: 14
|
|
||||||
});
|
|
||||||
watchProgressScaleY.value = withSpring(1, {
|
|
||||||
mass: 0.3,
|
|
||||||
stiffness: 120,
|
|
||||||
damping: 18
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
watchProgressOpacity.value = withSpring(0, {
|
|
||||||
mass: 0.2,
|
|
||||||
stiffness: 100,
|
|
||||||
damping: 14
|
|
||||||
});
|
|
||||||
watchProgressScaleY.value = withSpring(0, {
|
|
||||||
mass: 0.3,
|
|
||||||
stiffness: 120,
|
|
||||||
damping: 18
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [watchProgress, watchProgressOpacity, watchProgressScaleY]);
|
|
||||||
|
|
||||||
// Effect to animate logo when it's available
|
|
||||||
const animateLogo = (hasLogo: boolean) => {
|
|
||||||
if (hasLogo) {
|
|
||||||
logoOpacity.value = withTiming(1, {
|
|
||||||
duration: 500,
|
|
||||||
easing: Easing.out(Easing.ease)
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logoOpacity.value = withTiming(0, {
|
|
||||||
duration: 200,
|
|
||||||
easing: Easing.in(Easing.ease)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Scroll handler
|
// Use runOnUI for better performance
|
||||||
|
runOnUI(enterAnimations)();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Optimized watch progress animation
|
||||||
|
useEffect(() => {
|
||||||
|
const hasProgress = watchProgress && watchProgress.duration > 0;
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
'worklet';
|
||||||
|
progressOpacity.value = withTiming(hasProgress ? 1 : 0, {
|
||||||
|
duration: hasProgress ? 200 : 150,
|
||||||
|
easing: easings.fast
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
runOnUI(updateProgress)();
|
||||||
|
}, [watchProgress]);
|
||||||
|
|
||||||
|
// Ultra-optimized scroll handler with minimal calculations
|
||||||
const scrollHandler = useAnimatedScrollHandler({
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
onScroll: (event) => {
|
onScroll: (event) => {
|
||||||
|
'worklet';
|
||||||
|
|
||||||
const rawScrollY = event.contentOffset.y;
|
const rawScrollY = event.contentOffset.y;
|
||||||
scrollY.value = rawScrollY;
|
scrollY.value = rawScrollY;
|
||||||
|
|
||||||
// Apply spring-like damping for smoother transitions
|
// Single calculation for header threshold
|
||||||
dampedScrollY.value = withTiming(rawScrollY, {
|
const threshold = height * 0.4 - safeAreaTop;
|
||||||
duration: 300,
|
const progress = rawScrollY > threshold ? 1 : 0;
|
||||||
easing: Easing.bezier(0.16, 1, 0.3, 1), // Custom spring-like curve
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update header opacity based on scroll position
|
// Use single progress value for all header animations
|
||||||
const headerThreshold = height * 0.5 - safeAreaTop - 70; // Hero height - inset - buffer
|
if (headerProgress.value !== progress) {
|
||||||
if (rawScrollY > headerThreshold) {
|
headerProgress.value = withTiming(progress, {
|
||||||
headerOpacity.value = withTiming(1, { duration: 200 });
|
duration: progress ? 200 : 150,
|
||||||
headerElementsY.value = withTiming(0, { duration: 300 });
|
easing: easings.ultraFast
|
||||||
headerElementsOpacity.value = withTiming(1, { duration: 450 });
|
});
|
||||||
} else {
|
|
||||||
headerOpacity.value = withTiming(0, { duration: 150 });
|
|
||||||
headerElementsY.value = withTiming(-10, { duration: 200 });
|
|
||||||
headerElementsOpacity.value = withTiming(0, { duration: 200 });
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// Animated values
|
// Optimized shared values - reduced count
|
||||||
screenScale,
|
|
||||||
screenOpacity,
|
screenOpacity,
|
||||||
heroHeight,
|
contentOpacity,
|
||||||
heroScale,
|
|
||||||
heroOpacity,
|
heroOpacity,
|
||||||
contentTranslateY,
|
heroScale,
|
||||||
logoOpacity,
|
uiElementsOpacity,
|
||||||
logoScale,
|
uiElementsTranslateY,
|
||||||
watchProgressOpacity,
|
progressOpacity,
|
||||||
watchProgressScaleY,
|
|
||||||
genresOpacity,
|
|
||||||
genresTranslateY,
|
|
||||||
buttonsOpacity,
|
|
||||||
buttonsTranslateY,
|
|
||||||
scrollY,
|
scrollY,
|
||||||
dampedScrollY,
|
headerProgress,
|
||||||
headerOpacity,
|
|
||||||
headerElementsY,
|
// Computed values for compatibility (derived from optimized values)
|
||||||
headerElementsOpacity,
|
get heroHeight() { return heroHeightValue; },
|
||||||
|
get logoOpacity() { return uiElementsOpacity; },
|
||||||
|
get buttonsOpacity() { return uiElementsOpacity; },
|
||||||
|
get buttonsTranslateY() { return uiElementsTranslateY; },
|
||||||
|
get contentTranslateY() { return uiElementsTranslateY; },
|
||||||
|
get watchProgressOpacity() { return progressOpacity; },
|
||||||
|
get watchProgressWidth() { return progressOpacity; }, // Reuse for width animation
|
||||||
|
get headerOpacity() { return headerProgress; },
|
||||||
|
get headerElementsY() {
|
||||||
|
return staticHeaderElementsY; // Use pre-created shared value
|
||||||
|
},
|
||||||
|
get headerElementsOpacity() { return headerProgress; },
|
||||||
|
|
||||||
// Functions
|
// Functions
|
||||||
scrollHandler,
|
scrollHandler,
|
||||||
animateLogo,
|
animateLogo: () => {}, // Simplified - no separate logo animation
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -196,7 +196,15 @@ export const useMetadataAssets = (
|
||||||
else if (shouldFetchLogo && logoFetchInProgress.current) {
|
else if (shouldFetchLogo && logoFetchInProgress.current) {
|
||||||
logger.log(`[useMetadataAssets:Logo] Skipping logo fetch because logoFetchInProgress is true.`);
|
logger.log(`[useMetadataAssets:Logo] Skipping logo fetch because logoFetchInProgress is true.`);
|
||||||
}
|
}
|
||||||
}, [id, type, metadata, setMetadata, imdbId, settings.logoSourcePreference, settings.tmdbLanguagePreference]); // Added tmdbLanguagePreference dependency
|
}, [
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
imdbId,
|
||||||
|
metadata?.logo, // Depend on the logo value itself, not the whole object
|
||||||
|
settings.logoSourcePreference,
|
||||||
|
settings.tmdbLanguagePreference,
|
||||||
|
setMetadata // Keep setMetadata, but ensure it's memoized in parent
|
||||||
|
]);
|
||||||
|
|
||||||
// Fetch banner image based on logo source preference - optimized version
|
// Fetch banner image based on logo source preference - optimized version
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -218,8 +226,14 @@ export const useMetadataAssets = (
|
||||||
const fetchBanner = async () => {
|
const fetchBanner = async () => {
|
||||||
logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`);
|
logger.log(`[useMetadataAssets:Banner] Starting banner fetch.`);
|
||||||
setLoadingBanner(true);
|
setLoadingBanner(true);
|
||||||
setBannerImage(null); // Clear existing banner to prevent mixed sources
|
|
||||||
setBannerSource(null); // Clear source tracking
|
// Show fallback banner immediately to prevent blank state
|
||||||
|
const fallbackBanner = metadata?.banner || metadata?.poster || null;
|
||||||
|
if (fallbackBanner && !bannerImage) {
|
||||||
|
setBannerImage(fallbackBanner);
|
||||||
|
setBannerSource('default');
|
||||||
|
logger.log(`[useMetadataAssets:Banner] Setting immediate fallback banner: ${fallbackBanner}`);
|
||||||
|
}
|
||||||
|
|
||||||
let finalBanner: string | null = null;
|
let finalBanner: string | null = null;
|
||||||
let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default';
|
let bannerSourceType: 'tmdb' | 'metahub' | 'default' = 'default';
|
||||||
|
|
@ -411,17 +425,31 @@ export const useMetadataAssets = (
|
||||||
|
|
||||||
// Set the final state
|
// Set the final state
|
||||||
logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`);
|
logger.log(`[useMetadataAssets:Banner] Final decision: Setting banner to ${finalBanner} (Source: ${bannerSourceType})`);
|
||||||
|
|
||||||
|
// Only update if the banner actually changed to avoid unnecessary re-renders
|
||||||
|
if (finalBanner !== bannerImage || bannerSourceType !== bannerSource) {
|
||||||
setBannerImage(finalBanner);
|
setBannerImage(finalBanner);
|
||||||
setBannerSource(bannerSourceType); // Track the source of the final image
|
setBannerSource(bannerSourceType); // Track the source of the final image
|
||||||
|
logger.log(`[useMetadataAssets:Banner] Banner updated from ${bannerImage} to ${finalBanner}`);
|
||||||
|
} else {
|
||||||
|
logger.log(`[useMetadataAssets:Banner] Banner unchanged, skipping update`);
|
||||||
|
}
|
||||||
|
|
||||||
forcedBannerRefreshDone.current = true; // Mark this cycle as complete
|
forcedBannerRefreshDone.current = true; // Mark this cycle as complete
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error);
|
logger.error(`[useMetadataAssets:Banner] Error in outer fetchBanner try block:`, error);
|
||||||
// Ensure fallback to default even on outer error
|
// Ensure fallback to default even on outer error
|
||||||
const defaultBanner = metadata?.banner || metadata?.poster || null;
|
const defaultBanner = metadata?.banner || metadata?.poster || null;
|
||||||
|
|
||||||
|
// Only set if it's different from current banner
|
||||||
|
if (defaultBanner !== bannerImage) {
|
||||||
setBannerImage(defaultBanner);
|
setBannerImage(defaultBanner);
|
||||||
setBannerSource('default');
|
setBannerSource('default');
|
||||||
logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`);
|
logger.log(`[useMetadataAssets:Banner] Setting default banner due to outer error: ${defaultBanner}`);
|
||||||
|
} else {
|
||||||
|
logger.log(`[useMetadataAssets:Banner] Default banner already set, skipping update`);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`);
|
logger.log(`[useMetadataAssets:Banner] Finished banner fetch attempt.`);
|
||||||
setLoadingBanner(false);
|
setLoadingBanner(false);
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,9 @@ export interface AppSettings {
|
||||||
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
|
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
|
||||||
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
|
logoSourcePreference: 'metahub' | 'tmdb'; // Preferred source for title logos
|
||||||
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
|
tmdbLanguagePreference: string; // Preferred language for TMDB logos (ISO 639-1 code)
|
||||||
|
enableInternalProviders: boolean; // Toggle for internal providers like HDRezka
|
||||||
|
episodeLayoutStyle: 'vertical' | 'horizontal'; // Layout style for episode cards
|
||||||
|
autoplayBestStream: boolean; // Automatically play the best available stream
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: AppSettings = {
|
export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
|
|
@ -50,6 +53,9 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||||
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
|
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
|
||||||
logoSourcePreference: 'metahub', // Default to Metahub as first source
|
logoSourcePreference: 'metahub', // Default to Metahub as first source
|
||||||
tmdbLanguagePreference: 'en', // Default to English
|
tmdbLanguagePreference: 'en', // Default to English
|
||||||
|
enableInternalProviders: true, // Enable internal providers by default
|
||||||
|
episodeLayoutStyle: 'horizontal', // Default to the new horizontal layout
|
||||||
|
autoplayBestStream: false, // Disabled by default for user choice
|
||||||
};
|
};
|
||||||
|
|
||||||
const SETTINGS_STORAGE_KEY = 'app_settings';
|
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||||
|
|
|
||||||
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 { useState, useEffect, useCallback } from 'react';
|
||||||
import { traktService, TraktUser, TraktWatchedItem } from '../services/traktService';
|
import { AppState, AppStateStatus } from 'react-native';
|
||||||
|
import {
|
||||||
|
traktService,
|
||||||
|
TraktUser,
|
||||||
|
TraktWatchedItem,
|
||||||
|
TraktWatchlistItem,
|
||||||
|
TraktCollectionItem,
|
||||||
|
TraktRatingItem,
|
||||||
|
TraktContentData,
|
||||||
|
TraktPlaybackItem
|
||||||
|
} from '../services/traktService';
|
||||||
|
import { storageService } from '../services/storageService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
export function useTraktIntegration() {
|
export function useTraktIntegration() {
|
||||||
|
|
@ -8,19 +19,30 @@ export function useTraktIntegration() {
|
||||||
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
|
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
|
||||||
const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]);
|
const [watchedMovies, setWatchedMovies] = useState<TraktWatchedItem[]>([]);
|
||||||
const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]);
|
const [watchedShows, setWatchedShows] = useState<TraktWatchedItem[]>([]);
|
||||||
|
const [watchlistMovies, setWatchlistMovies] = useState<TraktWatchlistItem[]>([]);
|
||||||
|
const [watchlistShows, setWatchlistShows] = useState<TraktWatchlistItem[]>([]);
|
||||||
|
const [collectionMovies, setCollectionMovies] = useState<TraktCollectionItem[]>([]);
|
||||||
|
const [collectionShows, setCollectionShows] = useState<TraktCollectionItem[]>([]);
|
||||||
|
const [continueWatching, setContinueWatching] = useState<TraktPlaybackItem[]>([]);
|
||||||
|
const [ratedContent, setRatedContent] = useState<TraktRatingItem[]>([]);
|
||||||
const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now());
|
const [lastAuthCheck, setLastAuthCheck] = useState<number>(Date.now());
|
||||||
|
|
||||||
// Check authentication status
|
// Check authentication status
|
||||||
const checkAuthStatus = useCallback(async () => {
|
const checkAuthStatus = useCallback(async () => {
|
||||||
|
logger.log('[useTraktIntegration] checkAuthStatus called');
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const authenticated = await traktService.isAuthenticated();
|
const authenticated = await traktService.isAuthenticated();
|
||||||
|
logger.log(`[useTraktIntegration] Authentication check result: ${authenticated}`);
|
||||||
setIsAuthenticated(authenticated);
|
setIsAuthenticated(authenticated);
|
||||||
|
|
||||||
if (authenticated) {
|
if (authenticated) {
|
||||||
|
logger.log('[useTraktIntegration] User is authenticated, fetching profile...');
|
||||||
const profile = await traktService.getUserProfile();
|
const profile = await traktService.getUserProfile();
|
||||||
|
logger.log(`[useTraktIntegration] User profile: ${profile.username}`);
|
||||||
setUserProfile(profile);
|
setUserProfile(profile);
|
||||||
} else {
|
} else {
|
||||||
|
logger.log('[useTraktIntegration] User is not authenticated');
|
||||||
setUserProfile(null);
|
setUserProfile(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,8 +68,8 @@ export function useTraktIntegration() {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
const [movies, shows] = await Promise.all([
|
const [movies, shows] = await Promise.all([
|
||||||
traktService.getWatchedMovies(),
|
traktService.getWatchedMoviesWithImages(),
|
||||||
traktService.getWatchedShows()
|
traktService.getWatchedShowsWithImages()
|
||||||
]);
|
]);
|
||||||
setWatchedMovies(movies);
|
setWatchedMovies(movies);
|
||||||
setWatchedShows(shows);
|
setWatchedShows(shows);
|
||||||
|
|
@ -58,6 +80,41 @@ export function useTraktIntegration() {
|
||||||
}
|
}
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// Load all collections (watchlist, collection, continue watching, ratings)
|
||||||
|
const loadAllCollections = useCallback(async () => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const [
|
||||||
|
watchlistMovies,
|
||||||
|
watchlistShows,
|
||||||
|
collectionMovies,
|
||||||
|
collectionShows,
|
||||||
|
continueWatching,
|
||||||
|
ratings
|
||||||
|
] = await Promise.all([
|
||||||
|
traktService.getWatchlistMoviesWithImages(),
|
||||||
|
traktService.getWatchlistShowsWithImages(),
|
||||||
|
traktService.getCollectionMoviesWithImages(),
|
||||||
|
traktService.getCollectionShowsWithImages(),
|
||||||
|
traktService.getPlaybackProgressWithImages(),
|
||||||
|
traktService.getRatingsWithImages()
|
||||||
|
]);
|
||||||
|
|
||||||
|
setWatchlistMovies(watchlistMovies);
|
||||||
|
setWatchlistShows(watchlistShows);
|
||||||
|
setCollectionMovies(collectionMovies);
|
||||||
|
setCollectionShows(collectionShows);
|
||||||
|
setContinueWatching(continueWatching);
|
||||||
|
setRatedContent(ratings);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error loading all collections:', error);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
// Check if a movie is watched
|
// Check if a movie is watched
|
||||||
const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => {
|
const isMovieWatched = useCallback(async (imdbId: string): Promise<boolean> => {
|
||||||
if (!isAuthenticated) return false;
|
if (!isAuthenticated) return false;
|
||||||
|
|
@ -128,6 +185,224 @@ export function useTraktIntegration() {
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, loadWatchedItems]);
|
}, [isAuthenticated, loadWatchedItems]);
|
||||||
|
|
||||||
|
// Start watching content (scrobble start)
|
||||||
|
const startWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => {
|
||||||
|
if (!isAuthenticated) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await traktService.scrobbleStart(contentData, progress);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error starting watch:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// Update progress while watching (scrobble pause)
|
||||||
|
const updateProgress = useCallback(async (
|
||||||
|
contentData: TraktContentData,
|
||||||
|
progress: number,
|
||||||
|
force: boolean = false
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!isAuthenticated) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await traktService.scrobblePause(contentData, progress, force);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error updating progress:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// Stop watching content (scrobble stop)
|
||||||
|
const stopWatching = useCallback(async (contentData: TraktContentData, progress: number): Promise<boolean> => {
|
||||||
|
if (!isAuthenticated) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await traktService.scrobbleStop(contentData, progress);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error stopping watch:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// Sync progress to Trakt (legacy method)
|
||||||
|
const syncProgress = useCallback(async (
|
||||||
|
contentData: TraktContentData,
|
||||||
|
progress: number,
|
||||||
|
force: boolean = false
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!isAuthenticated) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await traktService.syncProgressToTrakt(contentData, progress, force);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error syncing progress:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// Get playback progress from Trakt
|
||||||
|
const getTraktPlaybackProgress = useCallback(async (type?: 'movies' | 'shows'): Promise<TraktPlaybackItem[]> => {
|
||||||
|
logger.log(`[useTraktIntegration] getTraktPlaybackProgress called - isAuthenticated: ${isAuthenticated}, type: ${type || 'all'}`);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
logger.log('[useTraktIntegration] getTraktPlaybackProgress: Not authenticated');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.log('[useTraktIntegration] Calling traktService.getPlaybackProgress...');
|
||||||
|
const result = await traktService.getPlaybackProgress(type);
|
||||||
|
logger.log(`[useTraktIntegration] traktService.getPlaybackProgress returned ${result.length} items`);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error getting playback progress:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// Sync all local progress to Trakt
|
||||||
|
const syncAllProgress = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!isAuthenticated) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const unsyncedProgress = await storageService.getUnsyncedProgress();
|
||||||
|
logger.log(`[useTraktIntegration] Found ${unsyncedProgress.length} unsynced progress entries`);
|
||||||
|
|
||||||
|
let syncedCount = 0;
|
||||||
|
const batchSize = 5; // Process in smaller batches
|
||||||
|
const delayBetweenBatches = 2000; // 2 seconds between batches
|
||||||
|
|
||||||
|
// Process items in batches to avoid overwhelming the API
|
||||||
|
for (let i = 0; i < unsyncedProgress.length; i += batchSize) {
|
||||||
|
const batch = unsyncedProgress.slice(i, i + batchSize);
|
||||||
|
|
||||||
|
// Process batch items with individual error handling
|
||||||
|
const batchPromises = batch.map(async (item) => {
|
||||||
|
try {
|
||||||
|
// Build content data from stored progress
|
||||||
|
const contentData: TraktContentData = {
|
||||||
|
type: item.type as 'movie' | 'episode',
|
||||||
|
imdbId: item.id,
|
||||||
|
title: 'Unknown', // We don't store title in progress, this would need metadata lookup
|
||||||
|
year: 0,
|
||||||
|
season: item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined,
|
||||||
|
episode: item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const progressPercent = (item.progress.currentTime / item.progress.duration) * 100;
|
||||||
|
|
||||||
|
const success = await traktService.syncProgressToTrakt(contentData, progressPercent, true);
|
||||||
|
if (success) {
|
||||||
|
await storageService.updateTraktSyncStatus(item.id, item.type, true, progressPercent, item.episodeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error syncing individual progress:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for batch to complete
|
||||||
|
const batchResults = await Promise.all(batchPromises);
|
||||||
|
syncedCount += batchResults.filter(result => result).length;
|
||||||
|
|
||||||
|
// Delay between batches to avoid rate limiting
|
||||||
|
if (i + batchSize < unsyncedProgress.length) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, delayBetweenBatches));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[useTraktIntegration] Synced ${syncedCount}/${unsyncedProgress.length} progress entries`);
|
||||||
|
return syncedCount > 0;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error syncing all progress:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
|
// Fetch and merge Trakt progress with local progress
|
||||||
|
const fetchAndMergeTraktProgress = useCallback(async (): Promise<boolean> => {
|
||||||
|
logger.log(`[useTraktIntegration] fetchAndMergeTraktProgress called - isAuthenticated: ${isAuthenticated}`);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
logger.log('[useTraktIntegration] Not authenticated, skipping Trakt progress fetch');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch both playback progress and recently watched movies
|
||||||
|
logger.log('[useTraktIntegration] Fetching Trakt playback progress and watched movies...');
|
||||||
|
const [traktProgress, watchedMovies] = await Promise.all([
|
||||||
|
getTraktPlaybackProgress(),
|
||||||
|
traktService.getWatchedMovies()
|
||||||
|
]);
|
||||||
|
|
||||||
|
logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items, ${watchedMovies.length} watched movies`);
|
||||||
|
|
||||||
|
// Process playback progress (in-progress items)
|
||||||
|
for (const item of traktProgress) {
|
||||||
|
try {
|
||||||
|
let id: string;
|
||||||
|
let type: string;
|
||||||
|
let episodeId: string | undefined;
|
||||||
|
|
||||||
|
if (item.type === 'movie' && item.movie) {
|
||||||
|
id = item.movie.ids.imdb;
|
||||||
|
type = 'movie';
|
||||||
|
logger.log(`[useTraktIntegration] Processing Trakt movie progress: ${item.movie.title} (${id}) - ${item.progress}%`);
|
||||||
|
} else if (item.type === 'episode' && item.show && item.episode) {
|
||||||
|
id = item.show.ids.imdb;
|
||||||
|
type = 'series';
|
||||||
|
episodeId = `${id}:${item.episode.season}:${item.episode.number}`;
|
||||||
|
logger.log(`[useTraktIntegration] Processing Trakt episode progress: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`);
|
||||||
|
} else {
|
||||||
|
logger.warn(`[useTraktIntegration] Skipping invalid Trakt progress item:`, item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[useTraktIntegration] Merging progress for ${type} ${id}: ${item.progress}% from ${item.paused_at}`);
|
||||||
|
await storageService.mergeWithTraktProgress(
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
item.progress,
|
||||||
|
item.paused_at,
|
||||||
|
episodeId
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error merging individual Trakt progress:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process watched movies (100% completed)
|
||||||
|
for (const movie of watchedMovies) {
|
||||||
|
try {
|
||||||
|
if (movie.movie?.ids?.imdb) {
|
||||||
|
const id = movie.movie.ids.imdb;
|
||||||
|
const watchedAt = movie.last_watched_at;
|
||||||
|
logger.log(`[useTraktIntegration] Processing watched movie: ${movie.movie.title} (${id}) - 100% watched on ${watchedAt}`);
|
||||||
|
|
||||||
|
await storageService.mergeWithTraktProgress(
|
||||||
|
id,
|
||||||
|
'movie',
|
||||||
|
100, // 100% progress for watched items
|
||||||
|
watchedAt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error merging watched movie:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} progress items + ${watchedMovies.length} watched movies`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, getTraktPlaybackProgress]);
|
||||||
|
|
||||||
// Initialize and check auth status
|
// Initialize and check auth status
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
checkAuthStatus();
|
checkAuthStatus();
|
||||||
|
|
@ -140,18 +415,98 @@ export function useTraktIntegration() {
|
||||||
}
|
}
|
||||||
}, [isAuthenticated, loadWatchedItems]);
|
}, [isAuthenticated, loadWatchedItems]);
|
||||||
|
|
||||||
|
// Auto-sync when authenticated changes OR when auth status is refreshed
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
// Fetch Trakt progress and merge with local
|
||||||
|
logger.log('[useTraktIntegration] User authenticated, fetching Trakt progress to replace local data');
|
||||||
|
fetchAndMergeTraktProgress().then((success) => {
|
||||||
|
if (success) {
|
||||||
|
logger.log('[useTraktIntegration] Trakt progress merged successfully - local data replaced with Trakt data');
|
||||||
|
} else {
|
||||||
|
logger.warn('[useTraktIntegration] Failed to merge Trakt progress');
|
||||||
|
}
|
||||||
|
// Small delay to ensure storage subscribers are notified
|
||||||
|
setTimeout(() => {
|
||||||
|
logger.log('[useTraktIntegration] Trakt progress merge completed, UI should refresh');
|
||||||
|
}, 100);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, fetchAndMergeTraktProgress]);
|
||||||
|
|
||||||
|
// App focus sync - sync when app comes back into focus (much smarter than periodic)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const handleAppStateChange = (nextAppState: AppStateStatus) => {
|
||||||
|
if (nextAppState === 'active') {
|
||||||
|
logger.log('[useTraktIntegration] App became active, syncing Trakt data');
|
||||||
|
fetchAndMergeTraktProgress().then((success) => {
|
||||||
|
if (success) {
|
||||||
|
logger.log('[useTraktIntegration] App focus sync completed successfully');
|
||||||
|
}
|
||||||
|
}).catch(error => {
|
||||||
|
logger.error('[useTraktIntegration] App focus sync failed:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const subscription = AppState.addEventListener('change', handleAppStateChange);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription?.remove();
|
||||||
|
};
|
||||||
|
}, [isAuthenticated, fetchAndMergeTraktProgress]);
|
||||||
|
|
||||||
|
// Trigger sync when auth status is manually refreshed (for login scenarios)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
logger.log('[useTraktIntegration] Auth status refresh detected, triggering Trakt progress merge');
|
||||||
|
fetchAndMergeTraktProgress().then((success) => {
|
||||||
|
if (success) {
|
||||||
|
logger.log('[useTraktIntegration] Trakt progress merged after manual auth refresh');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]);
|
||||||
|
|
||||||
|
// Manual force sync function for testing/troubleshooting
|
||||||
|
const forceSyncTraktProgress = useCallback(async (): Promise<boolean> => {
|
||||||
|
logger.log('[useTraktIntegration] Manual force sync triggered');
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
logger.log('[useTraktIntegration] Cannot force sync - not authenticated');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return await fetchAndMergeTraktProgress();
|
||||||
|
}, [isAuthenticated, fetchAndMergeTraktProgress]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isLoading,
|
isLoading,
|
||||||
userProfile,
|
userProfile,
|
||||||
watchedMovies,
|
watchedMovies,
|
||||||
watchedShows,
|
watchedShows,
|
||||||
|
watchlistMovies,
|
||||||
|
watchlistShows,
|
||||||
|
collectionMovies,
|
||||||
|
collectionShows,
|
||||||
|
continueWatching,
|
||||||
|
ratedContent,
|
||||||
checkAuthStatus,
|
checkAuthStatus,
|
||||||
loadWatchedItems,
|
loadWatchedItems,
|
||||||
|
loadAllCollections,
|
||||||
isMovieWatched,
|
isMovieWatched,
|
||||||
isEpisodeWatched,
|
isEpisodeWatched,
|
||||||
markMovieAsWatched,
|
markMovieAsWatched,
|
||||||
markEpisodeAsWatched,
|
markEpisodeAsWatched,
|
||||||
refreshAuthStatus
|
refreshAuthStatus,
|
||||||
|
startWatching,
|
||||||
|
updateProgress,
|
||||||
|
stopWatching,
|
||||||
|
syncProgress, // legacy
|
||||||
|
getTraktPlaybackProgress,
|
||||||
|
syncAllProgress,
|
||||||
|
fetchAndMergeTraktProgress,
|
||||||
|
forceSyncTraktProgress // For manual testing
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useCallback, useEffect } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useFocusEffect } from '@react-navigation/native';
|
import { useFocusEffect } from '@react-navigation/native';
|
||||||
|
import { useTraktContext } from '../contexts/TraktContext';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { storageService } from '../services/storageService';
|
import { storageService } from '../services/storageService';
|
||||||
|
|
||||||
|
|
@ -8,6 +9,8 @@ interface WatchProgressData {
|
||||||
duration: number;
|
duration: number;
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
episodeId?: string;
|
episodeId?: string;
|
||||||
|
traktSynced?: boolean;
|
||||||
|
traktProgress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useWatchProgress = (
|
export const useWatchProgress = (
|
||||||
|
|
@ -17,6 +20,7 @@ export const useWatchProgress = (
|
||||||
episodes: any[] = []
|
episodes: any[] = []
|
||||||
) => {
|
) => {
|
||||||
const [watchProgress, setWatchProgress] = useState<WatchProgressData | null>(null);
|
const [watchProgress, setWatchProgress] = useState<WatchProgressData | null>(null);
|
||||||
|
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
||||||
|
|
||||||
// Function to get episode details from episodeId
|
// Function to get episode details from episodeId
|
||||||
const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => {
|
const getEpisodeDetails = useCallback((episodeId: string): { seasonNumber: string; episodeNumber: string; episodeName: string } | null => {
|
||||||
|
|
@ -52,7 +56,7 @@ export const useWatchProgress = (
|
||||||
return null;
|
return null;
|
||||||
}, [episodes]);
|
}, [episodes]);
|
||||||
|
|
||||||
// Load watch progress
|
// Enhanced load watch progress with Trakt integration
|
||||||
const loadWatchProgress = useCallback(async () => {
|
const loadWatchProgress = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (id && type) {
|
if (id && type) {
|
||||||
|
|
@ -87,75 +91,39 @@ export const useWatchProgress = (
|
||||||
if (episodeId) {
|
if (episodeId) {
|
||||||
const progress = await storageService.getWatchProgress(id, type, episodeId);
|
const progress = await storageService.getWatchProgress(id, type, episodeId);
|
||||||
if (progress) {
|
if (progress) {
|
||||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
// Always show the current episode progress when viewing it specifically
|
||||||
|
// This allows HeroSection to properly display watched state
|
||||||
// If current episode is finished (≥95%), try to find next unwatched episode
|
setWatchProgress({
|
||||||
if (progressPercent >= 95) {
|
...progress,
|
||||||
const currentEpNum = getEpisodeNumber(episodeId);
|
episodeId,
|
||||||
if (currentEpNum && episodes.length > 0) {
|
traktSynced: progress.traktSynced,
|
||||||
// Find the next episode
|
traktProgress: progress.traktProgress
|
||||||
const nextEpisode = episodes.find(ep => {
|
|
||||||
// First check in same season
|
|
||||||
if (ep.season_number === currentEpNum.season && ep.episode_number > currentEpNum.episode) {
|
|
||||||
const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
|
|
||||||
const epProgress = seriesProgresses.find(p => p.episodeId === epId);
|
|
||||||
if (!epProgress) return true;
|
|
||||||
const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100;
|
|
||||||
return percent < 95;
|
|
||||||
}
|
|
||||||
// Then check next seasons
|
|
||||||
if (ep.season_number > currentEpNum.season) {
|
|
||||||
const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
|
|
||||||
const epProgress = seriesProgresses.find(p => p.episodeId === epId);
|
|
||||||
if (!epProgress) return true;
|
|
||||||
const percent = (epProgress.progress.currentTime / epProgress.progress.duration) * 100;
|
|
||||||
return percent < 95;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (nextEpisode) {
|
|
||||||
const nextEpisodeId = nextEpisode.stremioId ||
|
|
||||||
`${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`;
|
|
||||||
const nextProgress = await storageService.getWatchProgress(id, type, nextEpisodeId);
|
|
||||||
if (nextProgress) {
|
|
||||||
setWatchProgress({ ...nextProgress, episodeId: nextEpisodeId });
|
|
||||||
} else {
|
|
||||||
setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: nextEpisodeId });
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no next episode found or current episode is finished, show no progress
|
|
||||||
setWatchProgress(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If current episode is not finished, show its progress
|
|
||||||
setWatchProgress({ ...progress, episodeId });
|
|
||||||
} else {
|
} else {
|
||||||
setWatchProgress(null);
|
setWatchProgress(null);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Find the first unfinished episode
|
// FIXED: Find the most recently watched episode instead of first unfinished
|
||||||
const unfinishedEpisode = episodes.find(ep => {
|
// Sort by lastUpdated timestamp (most recent first)
|
||||||
const epId = ep.stremioId || `${id}:${ep.season_number}:${ep.episode_number}`;
|
const sortedProgresses = seriesProgresses.sort((a, b) =>
|
||||||
const progress = seriesProgresses.find(p => p.episodeId === epId);
|
b.progress.lastUpdated - a.progress.lastUpdated
|
||||||
if (!progress) return true;
|
);
|
||||||
const percent = (progress.progress.currentTime / progress.progress.duration) * 100;
|
|
||||||
return percent < 95;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (unfinishedEpisode) {
|
if (sortedProgresses.length > 0) {
|
||||||
const epId = unfinishedEpisode.stremioId ||
|
// Use the most recently watched episode
|
||||||
`${id}:${unfinishedEpisode.season_number}:${unfinishedEpisode.episode_number}`;
|
const mostRecentProgress = sortedProgresses[0];
|
||||||
const progress = await storageService.getWatchProgress(id, type, epId);
|
const progress = mostRecentProgress.progress;
|
||||||
if (progress) {
|
|
||||||
setWatchProgress({ ...progress, episodeId: epId });
|
logger.log(`[useWatchProgress] Using most recent progress for ${mostRecentProgress.episodeId}, updated at ${new Date(progress.lastUpdated).toLocaleString()}`);
|
||||||
} else {
|
|
||||||
setWatchProgress({ currentTime: 0, duration: 0, lastUpdated: Date.now(), episodeId: epId });
|
setWatchProgress({
|
||||||
}
|
...progress,
|
||||||
|
episodeId: mostRecentProgress.episodeId,
|
||||||
|
traktSynced: progress.traktSynced,
|
||||||
|
traktProgress: progress.traktProgress
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
// No watched episodes found
|
||||||
setWatchProgress(null);
|
setWatchProgress(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -163,12 +131,14 @@ export const useWatchProgress = (
|
||||||
// For movies
|
// For movies
|
||||||
const progress = await storageService.getWatchProgress(id, type, episodeId);
|
const progress = await storageService.getWatchProgress(id, type, episodeId);
|
||||||
if (progress && progress.currentTime > 0) {
|
if (progress && progress.currentTime > 0) {
|
||||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
// Always show progress data, even if watched (≥95%)
|
||||||
if (progressPercent >= 95) {
|
// The HeroSection will handle the "watched" state display
|
||||||
setWatchProgress(null);
|
setWatchProgress({
|
||||||
} else {
|
...progress,
|
||||||
setWatchProgress({ ...progress, episodeId });
|
episodeId,
|
||||||
}
|
traktSynced: progress.traktSynced,
|
||||||
|
traktProgress: progress.traktProgress
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setWatchProgress(null);
|
setWatchProgress(null);
|
||||||
}
|
}
|
||||||
|
|
@ -180,21 +150,33 @@ export const useWatchProgress = (
|
||||||
}
|
}
|
||||||
}, [id, type, episodeId, episodes]);
|
}, [id, type, episodeId, episodes]);
|
||||||
|
|
||||||
// Function to get play button text based on watch progress
|
// Enhanced function to get play button text with Trakt awareness
|
||||||
const getPlayButtonText = useCallback(() => {
|
const getPlayButtonText = useCallback(() => {
|
||||||
if (!watchProgress || watchProgress.currentTime <= 0) {
|
if (!watchProgress || watchProgress.currentTime <= 0) {
|
||||||
return 'Play';
|
return 'Play';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consider episode complete if progress is >= 95%
|
// Consider episode complete if progress is >= 85%
|
||||||
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
|
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
|
||||||
if (progressPercent >= 95) {
|
if (progressPercent >= 85) {
|
||||||
return 'Play';
|
return 'Play';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we have Trakt data and it differs significantly from local, show "Resume"
|
||||||
|
// but the UI will show the discrepancy
|
||||||
return 'Resume';
|
return 'Resume';
|
||||||
}, [watchProgress]);
|
}, [watchProgress]);
|
||||||
|
|
||||||
|
// Subscribe to storage changes for real-time updates
|
||||||
|
useEffect(() => {
|
||||||
|
const unsubscribe = storageService.subscribeToWatchProgressUpdates(() => {
|
||||||
|
logger.log('[useWatchProgress] Storage updated, reloading progress');
|
||||||
|
loadWatchProgress();
|
||||||
|
});
|
||||||
|
|
||||||
|
return unsubscribe;
|
||||||
|
}, [loadWatchProgress]);
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadWatchProgress();
|
loadWatchProgress();
|
||||||
|
|
@ -207,6 +189,16 @@ export const useWatchProgress = (
|
||||||
}, [loadWatchProgress])
|
}, [loadWatchProgress])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Re-load when Trakt authentication status changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (isTraktAuthenticated !== undefined) {
|
||||||
|
// Small delay to ensure Trakt context is fully initialized
|
||||||
|
setTimeout(() => {
|
||||||
|
loadWatchProgress();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}, [isTraktAuthenticated, loadWatchProgress]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
watchProgress,
|
watchProgress,
|
||||||
getEpisodeDetails,
|
getEpisodeDetails,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import DiscoverScreen from '../screens/DiscoverScreen';
|
||||||
import LibraryScreen from '../screens/LibraryScreen';
|
import LibraryScreen from '../screens/LibraryScreen';
|
||||||
import SettingsScreen from '../screens/SettingsScreen';
|
import SettingsScreen from '../screens/SettingsScreen';
|
||||||
import MetadataScreen from '../screens/MetadataScreen';
|
import MetadataScreen from '../screens/MetadataScreen';
|
||||||
import VideoPlayer from '../screens/VideoPlayer';
|
import VideoPlayer from '../components/player/VideoPlayer';
|
||||||
import CatalogScreen from '../screens/CatalogScreen';
|
import CatalogScreen from '../screens/CatalogScreen';
|
||||||
import AddonsScreen from '../screens/AddonsScreen';
|
import AddonsScreen from '../screens/AddonsScreen';
|
||||||
import SearchScreen from '../screens/SearchScreen';
|
import SearchScreen from '../screens/SearchScreen';
|
||||||
|
|
@ -39,6 +39,7 @@ import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
|
||||||
import LogoSourceSettings from '../screens/LogoSourceSettings';
|
import LogoSourceSettings from '../screens/LogoSourceSettings';
|
||||||
import ThemeScreen from '../screens/ThemeScreen';
|
import ThemeScreen from '../screens/ThemeScreen';
|
||||||
import ProfilesScreen from '../screens/ProfilesScreen';
|
import ProfilesScreen from '../screens/ProfilesScreen';
|
||||||
|
import InternalProvidersSettings from '../screens/InternalProvidersSettings';
|
||||||
|
|
||||||
// Stack navigator types
|
// Stack navigator types
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
|
|
@ -53,6 +54,7 @@ export type RootStackParamList = {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
episodeId?: string;
|
episodeId?: string;
|
||||||
|
addonId?: string;
|
||||||
};
|
};
|
||||||
Streams: {
|
Streams: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -74,9 +76,12 @@ export type RootStackParamList = {
|
||||||
quality?: string;
|
quality?: string;
|
||||||
year?: number;
|
year?: number;
|
||||||
streamProvider?: string;
|
streamProvider?: string;
|
||||||
|
streamName?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
episodeId?: string;
|
episodeId?: string;
|
||||||
|
imdbId?: string;
|
||||||
|
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
|
||||||
};
|
};
|
||||||
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
|
Catalog: { id: string; type: string; addonId?: string; name?: string; genreFilter?: string };
|
||||||
Credits: { mediaId: string; mediaType: string };
|
Credits: { mediaId: string; mediaType: string };
|
||||||
|
|
@ -97,6 +102,7 @@ export type RootStackParamList = {
|
||||||
LogoSourceSettings: undefined;
|
LogoSourceSettings: undefined;
|
||||||
ThemeSettings: undefined;
|
ThemeSettings: undefined;
|
||||||
ProfilesSettings: undefined;
|
ProfilesSettings: undefined;
|
||||||
|
InternalProvidersSettings: undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
@ -657,10 +663,45 @@ const MainTabs = () => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create custom fade animation interpolator for MetadataScreen
|
||||||
|
const customFadeInterpolator = ({ current, layouts }: any) => {
|
||||||
|
return {
|
||||||
|
cardStyle: {
|
||||||
|
opacity: current.progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, 1],
|
||||||
|
}),
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
scale: current.progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0.95, 1],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
overlayStyle: {
|
||||||
|
opacity: current.progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, 0.3],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// Stack Navigator
|
// Stack Navigator
|
||||||
const AppNavigator = () => {
|
const AppNavigator = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
|
// Handle Android-specific optimizations
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
// Ensure consistent background color for Android
|
||||||
|
StatusBar.setBackgroundColor('transparent', true);
|
||||||
|
StatusBar.setTranslucent(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
|
|
@ -669,60 +710,169 @@ const AppNavigator = () => {
|
||||||
barStyle="light-content"
|
barStyle="light-content"
|
||||||
/>
|
/>
|
||||||
<PaperProvider theme={CustomDarkTheme}>
|
<PaperProvider theme={CustomDarkTheme}>
|
||||||
|
<View style={{
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
// Prevent white flashes on Android
|
||||||
|
opacity: 1,
|
||||||
|
})
|
||||||
|
}}>
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
screenOptions={{
|
screenOptions={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
// Disable animations for smoother transitions
|
// Use slide_from_right for consistency and smooth transitions
|
||||||
animation: 'none',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
|
||||||
// Ensure content is not popping in and out
|
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||||
|
// Ensure consistent background during transitions
|
||||||
contentStyle: {
|
contentStyle: {
|
||||||
backgroundColor: currentTheme.colors.darkBackground,
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
}
|
},
|
||||||
|
// Improve Android performance with custom interpolator
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
cardStyleInterpolator: ({ current, layouts }: any) => {
|
||||||
|
return {
|
||||||
|
cardStyle: {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateX: current.progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [layouts.screen.width, 0],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="MainTabs"
|
name="MainTabs"
|
||||||
component={MainTabs as any}
|
component={MainTabs as any}
|
||||||
|
options={{
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Metadata"
|
name="Metadata"
|
||||||
component={MetadataScreen as any}
|
component={MetadataScreen}
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: 'fade',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||||
|
...(Platform.OS === 'ios' && {
|
||||||
|
cardStyleInterpolator: customFadeInterpolator,
|
||||||
|
animationTypeForReplace: 'push',
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: 'horizontal',
|
||||||
|
}),
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Streams"
|
name="Streams"
|
||||||
component={StreamsScreen as any}
|
component={StreamsScreen as any}
|
||||||
options={{
|
options={{
|
||||||
headerShown: false,
|
headerShown: false,
|
||||||
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'fade_from_bottom',
|
animation: Platform.OS === 'ios' ? 'slide_from_bottom' : 'none',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 0 : 300,
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: Platform.OS === 'ios' ? 'vertical' : 'horizontal',
|
||||||
...(Platform.OS === 'ios' && { presentation: 'modal' }),
|
...(Platform.OS === 'ios' && { presentation: 'modal' }),
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Player"
|
name="Player"
|
||||||
component={VideoPlayer as any}
|
component={VideoPlayer as any}
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 200 : 300,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: '#000000', // Pure black for video player
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Catalog"
|
name="Catalog"
|
||||||
component={CatalogScreen as any}
|
component={CatalogScreen as any}
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Addons"
|
name="Addons"
|
||||||
component={AddonsScreen as any}
|
component={AddonsScreen as any}
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Search"
|
name="Search"
|
||||||
component={SearchScreen as any}
|
component={SearchScreen as any}
|
||||||
|
options={{
|
||||||
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 350,
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: 'horizontal',
|
||||||
|
...(Platform.OS === 'android' && {
|
||||||
|
cardStyleInterpolator: ({ current, layouts }: any) => {
|
||||||
|
return {
|
||||||
|
cardStyle: {
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
translateX: current.progress.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [layouts.screen.width, 0],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
opacity: current.progress.interpolate({
|
||||||
|
inputRange: [0, 0.3, 1],
|
||||||
|
outputRange: [0, 0.85, 1],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="CatalogSettings"
|
name="CatalogSettings"
|
||||||
component={CatalogSettingsScreen as any}
|
component={CatalogSettingsScreen as any}
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="HomeScreenSettings"
|
name="HomeScreenSettings"
|
||||||
component={HomeScreenSettings}
|
component={HomeScreenSettings}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -736,8 +886,8 @@ const AppNavigator = () => {
|
||||||
name="HeroCatalogs"
|
name="HeroCatalogs"
|
||||||
component={HeroCatalogsScreen}
|
component={HeroCatalogsScreen}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -751,8 +901,8 @@ const AppNavigator = () => {
|
||||||
name="ShowRatings"
|
name="ShowRatings"
|
||||||
component={ShowRatingsScreen}
|
component={ShowRatingsScreen}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'fade_from_bottom' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 200 : 200,
|
||||||
...(Platform.OS === 'ios' && { presentation: 'modal' }),
|
...(Platform.OS === 'ios' && { presentation: 'modal' }),
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -765,17 +915,31 @@ const AppNavigator = () => {
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="Calendar"
|
name="Calendar"
|
||||||
component={CalendarScreen as any}
|
component={CalendarScreen as any}
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="NotificationSettings"
|
name="NotificationSettings"
|
||||||
component={NotificationSettingsScreen as any}
|
component={NotificationSettingsScreen as any}
|
||||||
|
options={{
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="MDBListSettings"
|
name="MDBListSettings"
|
||||||
component={MDBListSettingsScreen}
|
component={MDBListSettingsScreen}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -789,8 +953,8 @@ const AppNavigator = () => {
|
||||||
name="TMDBSettings"
|
name="TMDBSettings"
|
||||||
component={TMDBSettingsScreen}
|
component={TMDBSettingsScreen}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -804,8 +968,8 @@ const AppNavigator = () => {
|
||||||
name="TraktSettings"
|
name="TraktSettings"
|
||||||
component={TraktSettingsScreen}
|
component={TraktSettingsScreen}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -819,8 +983,8 @@ const AppNavigator = () => {
|
||||||
name="PlayerSettings"
|
name="PlayerSettings"
|
||||||
component={PlayerSettingsScreen}
|
component={PlayerSettingsScreen}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -834,8 +998,8 @@ const AppNavigator = () => {
|
||||||
name="LogoSourceSettings"
|
name="LogoSourceSettings"
|
||||||
component={LogoSourceSettings}
|
component={LogoSourceSettings}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -849,8 +1013,8 @@ const AppNavigator = () => {
|
||||||
name="ThemeSettings"
|
name="ThemeSettings"
|
||||||
component={ThemeScreen}
|
component={ThemeScreen}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -864,8 +1028,23 @@ const AppNavigator = () => {
|
||||||
name="ProfilesSettings"
|
name="ProfilesSettings"
|
||||||
component={ProfilesScreen}
|
component={ProfilesScreen}
|
||||||
options={{
|
options={{
|
||||||
animation: 'fade',
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
animationDuration: 200,
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
|
presentation: 'card',
|
||||||
|
gestureEnabled: true,
|
||||||
|
gestureDirection: 'horizontal',
|
||||||
|
headerShown: false,
|
||||||
|
contentStyle: {
|
||||||
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="InternalProvidersSettings"
|
||||||
|
component={InternalProvidersSettings}
|
||||||
|
options={{
|
||||||
|
animation: Platform.OS === 'android' ? 'slide_from_right' : 'fade',
|
||||||
|
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||||
presentation: 'card',
|
presentation: 'card',
|
||||||
gestureEnabled: true,
|
gestureEnabled: true,
|
||||||
gestureDirection: 'horizontal',
|
gestureDirection: 'horizontal',
|
||||||
|
|
@ -876,6 +1055,7 @@ const AppNavigator = () => {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
|
</View>
|
||||||
</PaperProvider>
|
</PaperProvider>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ import { NavigationProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||||
|
import { BlurView as CommunityBlurView } from '@react-native-community/blur';
|
||||||
|
import Constants, { ExecutionEnvironment } from 'expo-constants';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
||||||
|
|
@ -552,6 +554,36 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
blurOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.4)',
|
||||||
|
},
|
||||||
|
androidBlurContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
androidBlur: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
},
|
||||||
|
androidFallbackBlur: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
backgroundColor: 'black',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const AddonsScreen = () => {
|
const AddonsScreen = () => {
|
||||||
|
|
@ -1233,7 +1265,24 @@ const AddonsScreen = () => {
|
||||||
setAddonDetails(null);
|
setAddonDetails(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<BlurView intensity={80} style={styles.modalContainer} tint="dark">
|
<View style={styles.modalContainer}>
|
||||||
|
{Platform.OS === 'ios' ? (
|
||||||
|
<ExpoBlurView intensity={80} style={styles.blurOverlay} tint="dark" />
|
||||||
|
) : (
|
||||||
|
Constants.executionEnvironment === ExecutionEnvironment.StoreClient ? (
|
||||||
|
<View style={[styles.androidBlurContainer, styles.androidFallbackBlur]} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.androidBlurContainer}>
|
||||||
|
<CommunityBlurView
|
||||||
|
style={styles.androidBlur}
|
||||||
|
blurType="dark"
|
||||||
|
blurAmount={8}
|
||||||
|
overlayColor="rgba(0,0,0,0.4)"
|
||||||
|
reducedTransparencyFallbackColor="black"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
)}
|
||||||
<View style={styles.modalContent}>
|
<View style={styles.modalContent}>
|
||||||
{addonDetails && (
|
{addonDetails && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1332,7 +1381,7 @@ const AddonsScreen = () => {
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,38 @@ const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||||
|
|
||||||
// Screen dimensions and grid layout
|
// Screen dimensions and grid layout
|
||||||
const { width } = Dimensions.get('window');
|
const { width } = Dimensions.get('window');
|
||||||
const NUM_COLUMNS = 3;
|
|
||||||
|
// Dynamic column calculation based on screen width
|
||||||
|
const calculateCatalogLayout = (screenWidth: number) => {
|
||||||
|
const MIN_ITEM_WIDTH = 120; // Increased minimum for better readability
|
||||||
|
const MAX_ITEM_WIDTH = 160; // Adjusted maximum
|
||||||
|
const HORIZONTAL_PADDING = SPACING.lg * 2; // Total horizontal padding
|
||||||
|
const ITEM_SPACING = SPACING.sm; // Space between items
|
||||||
|
|
||||||
|
// Calculate how many columns can fit
|
||||||
|
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
||||||
|
const maxColumns = Math.floor(availableWidth / (MIN_ITEM_WIDTH + ITEM_SPACING));
|
||||||
|
|
||||||
|
// Limit to reasonable number of columns (2-4 for better UX)
|
||||||
|
const numColumns = Math.min(Math.max(maxColumns, 2), 4);
|
||||||
|
|
||||||
|
// Calculate actual item width with proper spacing
|
||||||
|
const totalSpacing = ITEM_SPACING * (numColumns - 1);
|
||||||
|
const itemWidth = (availableWidth - totalSpacing) / numColumns;
|
||||||
|
|
||||||
|
// For 2 columns, ensure we use the full available width
|
||||||
|
const finalItemWidth = numColumns === 2 ? itemWidth : Math.min(itemWidth, MAX_ITEM_WIDTH);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numColumns,
|
||||||
|
itemWidth: finalItemWidth
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const catalogLayout = calculateCatalogLayout(width);
|
||||||
|
const NUM_COLUMNS = catalogLayout.numColumns;
|
||||||
const ITEM_MARGIN = SPACING.sm;
|
const ITEM_MARGIN = SPACING.sm;
|
||||||
const ITEM_WIDTH = (width - (SPACING.lg * 2) - (ITEM_MARGIN * 2 * NUM_COLUMNS)) / NUM_COLUMNS;
|
const ITEM_WIDTH = catalogLayout.itemWidth;
|
||||||
|
|
||||||
// Create a styles creator function that accepts the theme colors
|
// Create a styles creator function that accepts the theme colors
|
||||||
const createStyles = (colors: any) => StyleSheet.create({
|
const createStyles = (colors: any) => StyleSheet.create({
|
||||||
|
|
@ -79,13 +108,9 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
padding: SPACING.lg,
|
padding: SPACING.lg,
|
||||||
paddingTop: SPACING.sm,
|
paddingTop: SPACING.sm,
|
||||||
},
|
},
|
||||||
columnWrapper: {
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
item: {
|
item: {
|
||||||
width: ITEM_WIDTH,
|
|
||||||
marginBottom: SPACING.lg,
|
marginBottom: SPACING.lg,
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
backgroundColor: colors.elevation2,
|
backgroundColor: colors.elevation2,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
|
|
@ -97,8 +122,8 @@ const createStyles = (colors: any) => StyleSheet.create({
|
||||||
poster: {
|
poster: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
aspectRatio: 2/3,
|
aspectRatio: 2/3,
|
||||||
borderTopLeftRadius: 12,
|
borderTopLeftRadius: 8,
|
||||||
borderTopRightRadius: 12,
|
borderTopRightRadius: 8,
|
||||||
backgroundColor: colors.elevation3,
|
backgroundColor: colors.elevation3,
|
||||||
},
|
},
|
||||||
itemContent: {
|
itemContent: {
|
||||||
|
|
@ -168,13 +193,60 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
const [hasMore, setHasMore] = useState(true);
|
const [hasMore, setHasMore] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
|
const [dataSource, setDataSource] = useState<DataSource>(DataSource.STREMIO_ADDONS);
|
||||||
|
const [actualCatalogName, setActualCatalogName] = useState<string | null>(null);
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const colors = currentTheme.colors;
|
const colors = currentTheme.colors;
|
||||||
const styles = createStyles(colors);
|
const styles = createStyles(colors);
|
||||||
const isDarkMode = true;
|
const isDarkMode = true;
|
||||||
|
|
||||||
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
|
const { getCustomName, isLoadingCustomNames } = useCustomCatalogNames();
|
||||||
const displayName = getCustomName(addonId || '', type || '', id || '', originalName || '');
|
|
||||||
|
// Create display name with proper type suffix
|
||||||
|
const createDisplayName = (catalogName: string) => {
|
||||||
|
if (!catalogName) return '';
|
||||||
|
|
||||||
|
// Check if the name already includes content type indicators
|
||||||
|
const lowerName = catalogName.toLowerCase();
|
||||||
|
const contentType = type === 'movie' ? 'Movies' : type === 'series' ? 'TV Shows' : `${type.charAt(0).toUpperCase() + type.slice(1)}s`;
|
||||||
|
|
||||||
|
// If the name already contains type information, return as is
|
||||||
|
if (lowerName.includes('movie') || lowerName.includes('tv') || lowerName.includes('show') || lowerName.includes('series')) {
|
||||||
|
return catalogName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise append the content type
|
||||||
|
return `${catalogName} ${contentType}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use actual catalog name if available, otherwise fallback to custom name or original name
|
||||||
|
const displayName = actualCatalogName
|
||||||
|
? getCustomName(addonId || '', type || '', id || '', createDisplayName(actualCatalogName))
|
||||||
|
: getCustomName(addonId || '', type || '', id || '', originalName ? createDisplayName(originalName) : '') ||
|
||||||
|
(genreFilter ? `${genreFilter} ${type === 'movie' ? 'Movies' : 'TV Shows'}` :
|
||||||
|
`${type.charAt(0).toUpperCase() + type.slice(1)}s`);
|
||||||
|
|
||||||
|
// Add effect to get the actual catalog name from addon manifest
|
||||||
|
useEffect(() => {
|
||||||
|
const getActualCatalogName = async () => {
|
||||||
|
if (addonId && type && id) {
|
||||||
|
try {
|
||||||
|
const manifests = await stremioService.getInstalledAddonsAsync();
|
||||||
|
const addon = manifests.find(a => a.id === addonId);
|
||||||
|
|
||||||
|
if (addon && addon.catalogs) {
|
||||||
|
const catalog = addon.catalogs.find(c => c.type === type && c.id === id);
|
||||||
|
if (catalog && catalog.name) {
|
||||||
|
setActualCatalogName(catalog.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to get actual catalog name:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
getActualCatalogName();
|
||||||
|
}, [addonId, type, id]);
|
||||||
|
|
||||||
// Add effect to get data source preference when component mounts
|
// Add effect to get data source preference when component mounts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -415,11 +487,23 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
}
|
}
|
||||||
}, [loading, hasMore, page, loadItems]);
|
}, [loading, hasMore, page, loadItems]);
|
||||||
|
|
||||||
const renderItem = useCallback(({ item }: { item: Meta }) => {
|
const renderItem = useCallback(({ item, index }: { item: Meta; index: number }) => {
|
||||||
|
// Calculate if this is the last item in a row
|
||||||
|
const isLastInRow = (index + 1) % NUM_COLUMNS === 0;
|
||||||
|
// For 2-column layout, ensure proper spacing
|
||||||
|
const rightMargin = isLastInRow ? 0 : SPACING.sm;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.item}
|
style={[
|
||||||
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
|
styles.item,
|
||||||
|
{
|
||||||
|
marginRight: rightMargin,
|
||||||
|
// For 2 columns, ensure items fill the available space properly
|
||||||
|
width: NUM_COLUMNS === 2 ? ITEM_WIDTH : ITEM_WIDTH
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type, addonId })}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
|
|
@ -443,7 +527,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}, [navigation, styles]);
|
}, [navigation, styles, NUM_COLUMNS, ITEM_WIDTH]);
|
||||||
|
|
||||||
const renderEmptyState = () => (
|
const renderEmptyState = () => (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
|
|
@ -542,6 +626,7 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={(item) => `${item.id}-${item.type}`}
|
keyExtractor={(item) => `${item.id}-${item.type}`}
|
||||||
numColumns={NUM_COLUMNS}
|
numColumns={NUM_COLUMNS}
|
||||||
|
key={NUM_COLUMNS}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
|
|
@ -560,7 +645,6 @@ const CatalogScreen: React.FC<CatalogScreenProps> = ({ route, navigation }) => {
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
contentContainerStyle={styles.list}
|
contentContainerStyle={styles.list}
|
||||||
columnWrapperStyle={styles.columnWrapper}
|
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
/>
|
/>
|
||||||
) : renderEmptyState()}
|
) : renderEmptyState()}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
import React, { useState, useEffect, useCallback, useRef, useMemo, useLayoutEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
FlatList,
|
FlatList,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
RefreshControl,
|
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
useColorScheme,
|
useColorScheme,
|
||||||
|
|
@ -16,12 +15,14 @@ import {
|
||||||
Platform,
|
Platform,
|
||||||
Image,
|
Image,
|
||||||
Modal,
|
Modal,
|
||||||
Pressable
|
Pressable,
|
||||||
|
Alert
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService';
|
import { StreamingContent, CatalogContent, catalogService } from '../services/catalogService';
|
||||||
|
import { stremioService } from '../services/stremioService';
|
||||||
import { Stream } from '../types/metadata';
|
import { Stream } from '../types/metadata';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
@ -60,6 +61,7 @@ import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
|
||||||
import homeStyles, { sharedStyles } from '../styles/homeStyles';
|
import homeStyles, { sharedStyles } from '../styles/homeStyles';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
import type { Theme } from '../contexts/ThemeContext';
|
import type { Theme } from '../contexts/ThemeContext';
|
||||||
|
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||||
|
|
||||||
// Define interfaces for our data
|
// Define interfaces for our data
|
||||||
interface Category {
|
interface Category {
|
||||||
|
|
@ -83,7 +85,7 @@ interface ContinueWatchingRef {
|
||||||
refresh: () => Promise<boolean>;
|
refresh: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
|
const DropUpMenu = React.memo(({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
|
||||||
const translateY = useSharedValue(300);
|
const translateY = useSharedValue(300);
|
||||||
const opacity = useSharedValue(0);
|
const opacity = useSharedValue(0);
|
||||||
const isDarkMode = useColorScheme() === 'dark';
|
const isDarkMode = useColorScheme() === 'dark';
|
||||||
|
|
@ -98,9 +100,15 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
|
||||||
opacity.value = withTiming(0, { duration: 200 });
|
opacity.value = withTiming(0, { duration: 200 });
|
||||||
translateY.value = withTiming(300, { duration: 300 });
|
translateY.value = withTiming(300, { duration: 300 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup animations when component unmounts
|
||||||
|
return () => {
|
||||||
|
opacity.value = 0;
|
||||||
|
translateY.value = 300;
|
||||||
|
};
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
const gesture = Gesture.Pan()
|
const gesture = useMemo(() => Gesture.Pan()
|
||||||
.onStart(() => {
|
.onStart(() => {
|
||||||
// Store initial position if needed
|
// Store initial position if needed
|
||||||
})
|
})
|
||||||
|
|
@ -124,7 +132,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
|
||||||
translateY.value = withTiming(0, { duration: 300 });
|
translateY.value = withTiming(0, { duration: 300 });
|
||||||
opacity.value = withTiming(1, { duration: 200 });
|
opacity.value = withTiming(1, { duration: 200 });
|
||||||
}
|
}
|
||||||
});
|
}), [onClose]);
|
||||||
|
|
||||||
const overlayStyle = useAnimatedStyle(() => ({
|
const overlayStyle = useAnimatedStyle(() => ({
|
||||||
opacity: opacity.value,
|
opacity: opacity.value,
|
||||||
|
|
@ -138,7 +146,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
|
||||||
backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white,
|
backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const menuOptions = [
|
const menuOptions = useMemo(() => [
|
||||||
{
|
{
|
||||||
icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
|
icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
|
||||||
label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
|
label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
|
||||||
|
|
@ -159,7 +167,12 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
|
||||||
label: 'Share',
|
label: 'Share',
|
||||||
action: 'share'
|
action: 'share'
|
||||||
}
|
}
|
||||||
];
|
], [item.inLibrary]);
|
||||||
|
|
||||||
|
const handleOptionSelect = useCallback((action: string) => {
|
||||||
|
onOptionSelect(action);
|
||||||
|
onClose();
|
||||||
|
}, [onOptionSelect, onClose]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
|
|
@ -200,10 +213,7 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
|
||||||
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' },
|
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' },
|
||||||
index === menuOptions.length - 1 && styles.lastMenuOption
|
index === menuOptions.length - 1 && styles.lastMenuOption
|
||||||
]}
|
]}
|
||||||
onPress={() => {
|
onPress={() => handleOptionSelect(option.action)}
|
||||||
onOptionSelect(option.action);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
|
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
|
||||||
|
|
@ -225,9 +235,9 @@ const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps)
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
|
const ContentItem = React.memo(({ item: initialItem, onPress }: ContentItemProps) => {
|
||||||
const [menuVisible, setMenuVisible] = useState(false);
|
const [menuVisible, setMenuVisible] = useState(false);
|
||||||
const [localItem, setLocalItem] = useState(initialItem);
|
const [localItem, setLocalItem] = useState(initialItem);
|
||||||
const [isWatched, setIsWatched] = useState(false);
|
const [isWatched, setIsWatched] = useState(false);
|
||||||
|
|
@ -256,8 +266,8 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
|
||||||
setIsWatched(prev => !prev);
|
setIsWatched(prev => !prev);
|
||||||
break;
|
break;
|
||||||
case 'playlist':
|
case 'playlist':
|
||||||
break;
|
|
||||||
case 'share':
|
case 'share':
|
||||||
|
// These options don't have implementations yet
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}, [localItem]);
|
}, [localItem]);
|
||||||
|
|
@ -266,16 +276,20 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
|
||||||
setMenuVisible(false);
|
setMenuVisible(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Only update localItem when initialItem changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLocalItem(initialItem);
|
setLocalItem(initialItem);
|
||||||
}, [initialItem]);
|
}, [initialItem]);
|
||||||
|
|
||||||
|
// Subscribe to library updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
|
const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
|
||||||
const isInLibrary = libraryItems.some(
|
const isInLibrary = libraryItems.some(
|
||||||
libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type
|
libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type
|
||||||
);
|
);
|
||||||
|
if (isInLibrary !== localItem.inLibrary) {
|
||||||
setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
|
setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => unsubscribe();
|
return () => unsubscribe();
|
||||||
|
|
@ -330,15 +344,24 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{menuVisible && (
|
||||||
<DropUpMenu
|
<DropUpMenu
|
||||||
visible={menuVisible}
|
visible={menuVisible}
|
||||||
onClose={handleMenuClose}
|
onClose={handleMenuClose}
|
||||||
item={localItem}
|
item={localItem}
|
||||||
onOptionSelect={handleOptionSelect}
|
onOptionSelect={handleOptionSelect}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}, (prevProps, nextProps) => {
|
||||||
|
// Custom comparison function to prevent unnecessary re-renders
|
||||||
|
return (
|
||||||
|
prevProps.item.id === nextProps.item.id &&
|
||||||
|
prevProps.item.inLibrary === nextProps.item.inLibrary &&
|
||||||
|
prevProps.onPress === nextProps.onPress
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// Sample categories (real app would get these from API)
|
// Sample categories (real app would get these from API)
|
||||||
const SAMPLE_CATEGORIES: Category[] = [
|
const SAMPLE_CATEGORIES: Category[] = [
|
||||||
|
|
@ -347,7 +370,7 @@ const SAMPLE_CATEGORIES: Category[] = [
|
||||||
{ id: 'channel', name: 'Channels' },
|
{ id: 'channel', name: 'Channels' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SkeletonCatalog = () => {
|
const SkeletonCatalog = React.memo(() => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
return (
|
return (
|
||||||
<View style={styles.catalogContainer}>
|
<View style={styles.catalogContainer}>
|
||||||
|
|
@ -356,7 +379,7 @@ const SkeletonCatalog = () => {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
});
|
||||||
|
|
||||||
const HomeScreen = () => {
|
const HomeScreen = () => {
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
|
|
@ -364,17 +387,16 @@ const HomeScreen = () => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const continueWatchingRef = useRef<ContinueWatchingRef>(null);
|
const continueWatchingRef = useRef<ContinueWatchingRef>(null);
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
const { lastUpdate } = useCatalogContext(); // Add catalog context to listen for addon changes
|
||||||
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
|
const [showHeroSection, setShowHeroSection] = useState(settings.showHeroSection);
|
||||||
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
|
const [featuredContentSource, setFeaturedContentSource] = useState(settings.featuredContentSource);
|
||||||
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const refreshTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const [hasContinueWatching, setHasContinueWatching] = useState(false);
|
const [hasContinueWatching, setHasContinueWatching] = useState(false);
|
||||||
|
|
||||||
const {
|
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
|
||||||
catalogs,
|
const [catalogsLoading, setCatalogsLoading] = useState(true);
|
||||||
loading: catalogsLoading,
|
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
|
||||||
refreshing: catalogsRefreshing,
|
const totalCatalogsRef = useRef(0);
|
||||||
refreshCatalogs
|
|
||||||
} = useHomeCatalogs();
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
featuredContent,
|
featuredContent,
|
||||||
|
|
@ -384,9 +406,119 @@ const HomeScreen = () => {
|
||||||
refreshFeatured
|
refreshFeatured
|
||||||
} = useFeaturedContent();
|
} = useFeaturedContent();
|
||||||
|
|
||||||
|
// Progressive catalog loading function
|
||||||
|
const loadCatalogsProgressively = useCallback(async () => {
|
||||||
|
setCatalogsLoading(true);
|
||||||
|
setCatalogs([]);
|
||||||
|
setLoadedCatalogCount(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const addons = await catalogService.getAllAddons();
|
||||||
|
|
||||||
|
// Create placeholder array with proper order and track indices
|
||||||
|
const catalogPlaceholders: (CatalogContent | null)[] = [];
|
||||||
|
const catalogPromises: Promise<void>[] = [];
|
||||||
|
let catalogIndex = 0;
|
||||||
|
|
||||||
|
for (const addon of addons) {
|
||||||
|
if (addon.catalogs) {
|
||||||
|
for (const catalog of addon.catalogs) {
|
||||||
|
const currentIndex = catalogIndex;
|
||||||
|
catalogPlaceholders.push(null); // Reserve position
|
||||||
|
|
||||||
|
const catalogPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const addonManifest = await stremioService.getInstalledAddonsAsync();
|
||||||
|
const manifest = addonManifest.find((a: any) => a.id === addon.id);
|
||||||
|
if (!manifest) return;
|
||||||
|
|
||||||
|
const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
|
||||||
|
if (metas && metas.length > 0) {
|
||||||
|
const items = metas.map((meta: any) => ({
|
||||||
|
id: meta.id,
|
||||||
|
type: meta.type,
|
||||||
|
name: meta.name,
|
||||||
|
poster: meta.poster,
|
||||||
|
posterShape: meta.posterShape,
|
||||||
|
banner: meta.background,
|
||||||
|
logo: meta.logo,
|
||||||
|
imdbRating: meta.imdbRating,
|
||||||
|
year: meta.year,
|
||||||
|
genres: meta.genres,
|
||||||
|
description: meta.description,
|
||||||
|
runtime: meta.runtime,
|
||||||
|
released: meta.released,
|
||||||
|
trailerStreams: meta.trailerStreams,
|
||||||
|
videos: meta.videos,
|
||||||
|
directors: meta.director,
|
||||||
|
creators: meta.creator,
|
||||||
|
certification: meta.certification
|
||||||
|
}));
|
||||||
|
|
||||||
|
let displayName = catalog.name;
|
||||||
|
const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
|
||||||
|
if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
|
||||||
|
displayName = `${displayName} ${contentType}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalogContent = {
|
||||||
|
addon: addon.id,
|
||||||
|
type: catalog.type,
|
||||||
|
id: catalog.id,
|
||||||
|
name: displayName,
|
||||||
|
items
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[HomeScreen] Loaded catalog: ${displayName} at position ${currentIndex} (${items.length} items)`);
|
||||||
|
|
||||||
|
// Update the catalog at its specific position
|
||||||
|
setCatalogs(prevCatalogs => {
|
||||||
|
const newCatalogs = [...prevCatalogs];
|
||||||
|
newCatalogs[currentIndex] = catalogContent;
|
||||||
|
return newCatalogs;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error);
|
||||||
|
} finally {
|
||||||
|
setLoadedCatalogCount(prev => prev + 1);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
catalogPromises.push(catalogPromise);
|
||||||
|
catalogIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCatalogsRef.current = catalogIndex;
|
||||||
|
console.log(`[HomeScreen] Starting to load ${catalogIndex} catalogs progressively...`);
|
||||||
|
|
||||||
|
// Initialize catalogs array with proper length
|
||||||
|
setCatalogs(new Array(catalogIndex).fill(null));
|
||||||
|
|
||||||
|
// Start all catalog loading promises but don't wait for them
|
||||||
|
// They will update the state progressively as they complete
|
||||||
|
Promise.allSettled(catalogPromises).then(() => {
|
||||||
|
console.log('[HomeScreen] All catalogs processed');
|
||||||
|
|
||||||
|
// Final cleanup: Filter out null values to get only successfully loaded catalogs
|
||||||
|
setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null));
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HomeScreen] Error in progressive catalog loading:', error);
|
||||||
|
} finally {
|
||||||
|
setCatalogsLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Only count feature section as loading if it's enabled in settings
|
// Only count feature section as loading if it's enabled in settings
|
||||||
const isLoading = (showHeroSection ? featuredLoading : false) || catalogsLoading;
|
// For catalogs, we show them progressively, so only show loading if no catalogs are loaded yet
|
||||||
const isRefreshing = catalogsRefreshing;
|
const isLoading = useMemo(() =>
|
||||||
|
(showHeroSection ? featuredLoading : false) || (catalogsLoading && catalogs.length === 0),
|
||||||
|
[showHeroSection, featuredLoading, catalogsLoading, catalogs.length]
|
||||||
|
);
|
||||||
|
|
||||||
// React to settings changes
|
// React to settings changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -394,14 +526,26 @@ const HomeScreen = () => {
|
||||||
setFeaturedContentSource(settings.featuredContentSource);
|
setFeaturedContentSource(settings.featuredContentSource);
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
|
// Load catalogs progressively on mount and when settings change
|
||||||
|
useEffect(() => {
|
||||||
|
loadCatalogsProgressively();
|
||||||
|
}, [loadCatalogsProgressively]);
|
||||||
|
|
||||||
|
// Listen for catalog changes (addon additions/removals) and reload catalogs
|
||||||
|
useEffect(() => {
|
||||||
|
loadCatalogsProgressively();
|
||||||
|
}, [lastUpdate, loadCatalogsProgressively]);
|
||||||
|
|
||||||
|
// Create a refresh function for catalogs
|
||||||
|
const refreshCatalogs = useCallback(() => {
|
||||||
|
return loadCatalogsProgressively();
|
||||||
|
}, [loadCatalogsProgressively]);
|
||||||
|
|
||||||
// Subscribe directly to settings emitter for immediate updates
|
// Subscribe directly to settings emitter for immediate updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleSettingsChange = () => {
|
const handleSettingsChange = () => {
|
||||||
setShowHeroSection(settings.showHeroSection);
|
setShowHeroSection(settings.showHeroSection);
|
||||||
setFeaturedContentSource(settings.featuredContentSource);
|
setFeaturedContentSource(settings.featuredContentSource);
|
||||||
|
|
||||||
// The featured content refresh is now handled by the useFeaturedContent hook
|
|
||||||
// No need to call refreshFeatured() here to avoid duplicate refreshes
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscribe to settings changes
|
// Subscribe to settings changes
|
||||||
|
|
@ -410,18 +554,6 @@ const HomeScreen = () => {
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
||||||
// Update the featured content refresh logic to handle persistence
|
|
||||||
useEffect(() => {
|
|
||||||
// This effect was causing duplicate refreshes - it's now handled in useFeaturedContent
|
|
||||||
// We'll keep it just to sync the local state with settings
|
|
||||||
if (showHeroSection && featuredContentSource !== settings.featuredContentSource) {
|
|
||||||
// Just update the local state
|
|
||||||
setFeaturedContentSource(settings.featuredContentSource);
|
|
||||||
}
|
|
||||||
|
|
||||||
// No timeout needed since we're not refreshing here
|
|
||||||
}, [settings.featuredContentSource, showHeroSection]);
|
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
const statusBarConfig = () => {
|
const statusBarConfig = () => {
|
||||||
|
|
@ -451,16 +583,15 @@ const HomeScreen = () => {
|
||||||
StatusBar.setTranslucent(false);
|
StatusBar.setTranslucent(false);
|
||||||
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
|
StatusBar.setBackgroundColor(currentTheme.colors.darkBackground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up any lingering timeouts
|
||||||
|
if (refreshTimeoutRef.current) {
|
||||||
|
clearTimeout(refreshTimeoutRef.current);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [currentTheme.colors.darkBackground]);
|
}, [currentTheme.colors.darkBackground]);
|
||||||
|
|
||||||
useEffect(() => {
|
// Preload images function - memoized to avoid recreating on every render
|
||||||
navigation.addListener('beforeRemove', () => {});
|
|
||||||
return () => {
|
|
||||||
navigation.removeListener('beforeRemove', () => {});
|
|
||||||
};
|
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
const preloadImages = useCallback(async (content: StreamingContent[]) => {
|
const preloadImages = useCallback(async (content: StreamingContent[]) => {
|
||||||
if (!content.length) return;
|
if (!content.length) return;
|
||||||
|
|
||||||
|
|
@ -481,36 +612,24 @@ const HomeScreen = () => {
|
||||||
|
|
||||||
await Promise.all(imagePromises);
|
await Promise.all(imagePromises);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error preloading images:', error);
|
// Silently handle preload errors
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRefresh = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const refreshTasks = [
|
|
||||||
refreshCatalogs(),
|
|
||||||
continueWatchingRef.current?.refresh(),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Only refresh featured content if hero section is enabled,
|
|
||||||
// and force refresh to bypass the cache
|
|
||||||
if (showHeroSection) {
|
|
||||||
refreshTasks.push(refreshFeatured());
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(refreshTasks);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error during refresh:', error);
|
|
||||||
}
|
|
||||||
}, [refreshFeatured, refreshCatalogs, showHeroSection]);
|
|
||||||
|
|
||||||
const handleContentPress = useCallback((id: string, type: string) => {
|
const handleContentPress = useCallback((id: string, type: string) => {
|
||||||
navigation.navigate('Metadata', { id, type });
|
navigation.navigate('Metadata', { id, type });
|
||||||
}, [navigation]);
|
}, [navigation]);
|
||||||
|
|
||||||
const handlePlayStream = useCallback((stream: Stream) => {
|
const handlePlayStream = useCallback(async (stream: Stream) => {
|
||||||
if (!featuredContent) return;
|
if (!featuredContent) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Lock orientation to landscape before navigation to prevent glitches
|
||||||
|
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
|
||||||
|
|
||||||
|
// Small delay to ensure orientation is set before navigation
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
navigation.navigate('Player', {
|
navigation.navigate('Player', {
|
||||||
uri: stream.url,
|
uri: stream.url,
|
||||||
title: featuredContent.name,
|
title: featuredContent.name,
|
||||||
|
|
@ -520,30 +639,72 @@ const HomeScreen = () => {
|
||||||
id: featuredContent.id,
|
id: featuredContent.id,
|
||||||
type: featuredContent.type
|
type: featuredContent.type
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback: navigate anyway
|
||||||
|
navigation.navigate('Player', {
|
||||||
|
uri: stream.url,
|
||||||
|
title: featuredContent.name,
|
||||||
|
year: featuredContent.year,
|
||||||
|
quality: stream.title?.match(/(\d+)p/)?.[1] || undefined,
|
||||||
|
streamProvider: stream.name,
|
||||||
|
id: featuredContent.id,
|
||||||
|
type: featuredContent.type
|
||||||
|
});
|
||||||
|
}
|
||||||
}, [featuredContent, navigation]);
|
}, [featuredContent, navigation]);
|
||||||
|
|
||||||
const refreshContinueWatching = useCallback(async () => {
|
const refreshContinueWatching = useCallback(async () => {
|
||||||
|
console.log('[HomeScreen] Refreshing continue watching...');
|
||||||
if (continueWatchingRef.current) {
|
if (continueWatchingRef.current) {
|
||||||
|
try {
|
||||||
const hasContent = await continueWatchingRef.current.refresh();
|
const hasContent = await continueWatchingRef.current.refresh();
|
||||||
|
console.log(`[HomeScreen] Continue watching has content: ${hasContent}`);
|
||||||
setHasContinueWatching(hasContent);
|
setHasContinueWatching(hasContent);
|
||||||
|
|
||||||
|
// Debug: Let's check what's in storage
|
||||||
|
const allProgress = await storageService.getAllWatchProgress();
|
||||||
|
console.log('[HomeScreen] All watch progress in storage:', Object.keys(allProgress).length, 'items');
|
||||||
|
console.log('[HomeScreen] Watch progress items:', allProgress);
|
||||||
|
|
||||||
|
// Check if any items are being filtered out due to >85% progress
|
||||||
|
let filteredCount = 0;
|
||||||
|
for (const [key, progress] of Object.entries(allProgress)) {
|
||||||
|
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||||
|
if (progressPercent >= 85) {
|
||||||
|
filteredCount++;
|
||||||
|
console.log(`[HomeScreen] Filtered out ${key}: ${progressPercent.toFixed(1)}% complete`);
|
||||||
|
} else {
|
||||||
|
console.log(`[HomeScreen] Valid progress ${key}: ${progressPercent.toFixed(1)}% complete`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`[HomeScreen] Filtered out ${filteredCount} completed items`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[HomeScreen] Error refreshing continue watching:', error);
|
||||||
|
setHasContinueWatching(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[HomeScreen] Continue watching ref is null');
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePlaybackComplete = () => {
|
|
||||||
refreshContinueWatching();
|
|
||||||
};
|
|
||||||
|
|
||||||
const unsubscribe = navigation.addListener('focus', () => {
|
const unsubscribe = navigation.addListener('focus', () => {
|
||||||
|
// Only refresh continue watching section on focus
|
||||||
refreshContinueWatching();
|
refreshContinueWatching();
|
||||||
|
// Don't reload catalogs unless they haven't been loaded yet
|
||||||
|
// Catalogs will be refreshed through context updates when addons change
|
||||||
|
if (catalogs.length === 0 && !catalogsLoading) {
|
||||||
|
loadCatalogsProgressively();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return unsubscribe;
|
||||||
unsubscribe();
|
}, [navigation, refreshContinueWatching, loadCatalogsProgressively, catalogs.length, catalogsLoading]);
|
||||||
};
|
|
||||||
}, [navigation, refreshContinueWatching]);
|
|
||||||
|
|
||||||
if (isLoading && !isRefreshing) {
|
// Memoize the loading screen to prevent unnecessary re-renders
|
||||||
|
const renderLoadingScreen = useMemo(() => {
|
||||||
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
|
|
@ -558,6 +719,12 @@ const HomeScreen = () => {
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}, [isLoading, currentTheme.colors]);
|
||||||
|
|
||||||
|
// Memoize the main content section
|
||||||
|
const renderMainContent = useMemo(() => {
|
||||||
|
if (isLoading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
|
|
@ -567,23 +734,16 @@ const HomeScreen = () => {
|
||||||
translucent
|
translucent
|
||||||
/>
|
/>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
refreshControl={
|
|
||||||
<RefreshControl
|
|
||||||
refreshing={isRefreshing}
|
|
||||||
onRefresh={handleRefresh}
|
|
||||||
tintColor={currentTheme.colors.primary}
|
|
||||||
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.scrollContent,
|
styles.scrollContent,
|
||||||
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
|
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
|
||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
removeClippedSubviews={true}
|
||||||
>
|
>
|
||||||
{showHeroSection && (
|
{showHeroSection && (
|
||||||
<FeaturedContent
|
<FeaturedContent
|
||||||
key={`featured-${showHeroSection}`}
|
key={`featured-${showHeroSection}-${featuredContentSource}`}
|
||||||
featuredContent={featuredContent}
|
featuredContent={featuredContent}
|
||||||
isSaved={isSaved}
|
isSaved={isSaved}
|
||||||
handleSaveToLibrary={handleSaveToLibrary}
|
handleSaveToLibrary={handleSaveToLibrary}
|
||||||
|
|
@ -594,20 +754,52 @@ const HomeScreen = () => {
|
||||||
<ThisWeekSection />
|
<ThisWeekSection />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{hasContinueWatching && (
|
|
||||||
<Animated.View entering={FadeIn.duration(400).delay(250)}>
|
|
||||||
<ContinueWatchingSection ref={continueWatchingRef} />
|
<ContinueWatchingSection ref={continueWatchingRef} />
|
||||||
|
|
||||||
|
{/* Show catalogs as they load */}
|
||||||
|
{catalogs.map((catalog, index) => {
|
||||||
|
if (!catalog) {
|
||||||
|
// Show placeholder for loading catalog
|
||||||
|
return (
|
||||||
|
<View key={`placeholder-${index}`} style={styles.catalogPlaceholder}>
|
||||||
|
<View style={styles.placeholderHeader}>
|
||||||
|
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
|
||||||
|
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.placeholderPosters}>
|
||||||
|
{[...Array(4)].map((_, posterIndex) => (
|
||||||
|
<View
|
||||||
|
key={posterIndex}
|
||||||
|
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
key={`${catalog.addon}-${catalog.id}-${index}`}
|
||||||
|
entering={FadeIn.duration(300)}
|
||||||
|
>
|
||||||
|
<CatalogSection catalog={catalog} />
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* Show loading indicator for remaining catalogs */}
|
||||||
|
{catalogsLoading && catalogs.length < totalCatalogsRef.current && (
|
||||||
|
<View style={styles.loadingMoreCatalogs}>
|
||||||
|
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||||
|
<Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}>
|
||||||
|
Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{catalogs.length > 0 ? (
|
{/* Show empty state only if all catalogs are loaded and none are available */}
|
||||||
catalogs.map((catalog, index) => (
|
{!catalogsLoading && catalogs.length === 0 && (
|
||||||
<View key={`${catalog.addon}-${catalog.id}-${index}`}>
|
|
||||||
<CatalogSection catalog={catalog} />
|
|
||||||
</View>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
!catalogsLoading && (
|
|
||||||
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||||
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
|
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
|
||||||
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
|
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
|
||||||
|
|
@ -621,33 +813,122 @@ const HomeScreen = () => {
|
||||||
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
|
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)
|
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
}, [
|
||||||
|
isLoading,
|
||||||
|
currentTheme.colors,
|
||||||
|
showHeroSection,
|
||||||
|
featuredContent,
|
||||||
|
isSaved,
|
||||||
|
handleSaveToLibrary,
|
||||||
|
hasContinueWatching,
|
||||||
|
catalogs,
|
||||||
|
catalogsLoading,
|
||||||
|
navigation,
|
||||||
|
featuredContentSource
|
||||||
|
]);
|
||||||
|
|
||||||
|
return isLoading ? renderLoadingScreen : renderMainContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
const POSTER_WIDTH = (width - 50) / 3;
|
|
||||||
|
// Dynamic poster calculation based on screen width - show 1/4 of next poster
|
||||||
|
const calculatePosterLayout = (screenWidth: number) => {
|
||||||
|
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
|
||||||
|
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
|
||||||
|
const LEFT_PADDING = 16; // Left padding
|
||||||
|
const SPACING = 8; // Space between posters
|
||||||
|
|
||||||
|
// Calculate available width for posters (reserve space for left padding)
|
||||||
|
const availableWidth = screenWidth - LEFT_PADDING;
|
||||||
|
|
||||||
|
// Try different numbers of full posters to find the best fit
|
||||||
|
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
|
||||||
|
|
||||||
|
for (let n = 3; n <= 6; n++) {
|
||||||
|
// Calculate poster width needed for N full posters + 0.25 partial poster
|
||||||
|
// Formula: N * posterWidth + (N-1) * spacing + 0.25 * posterWidth = availableWidth - rightPadding
|
||||||
|
// Simplified: posterWidth * (N + 0.25) + (N-1) * spacing = availableWidth - rightPadding
|
||||||
|
// We'll use minimal right padding (8px) to maximize space
|
||||||
|
const usableWidth = availableWidth - 8;
|
||||||
|
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
|
||||||
|
|
||||||
|
console.log(`[HomeScreen] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
|
||||||
|
|
||||||
|
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
|
||||||
|
bestLayout = { numFullPosters: n, posterWidth };
|
||||||
|
console.log(`[HomeScreen] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
numFullPosters: bestLayout.numFullPosters,
|
||||||
|
posterWidth: bestLayout.posterWidth,
|
||||||
|
spacing: SPACING,
|
||||||
|
partialPosterWidth: bestLayout.posterWidth * 0.25 // 1/4 of next poster
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const posterLayout = calculatePosterLayout(width);
|
||||||
|
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||||
|
|
||||||
const styles = StyleSheet.create<any>({
|
const styles = StyleSheet.create<any>({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
paddingBottom: 40,
|
paddingBottom: 90,
|
||||||
},
|
},
|
||||||
loadingMainContainer: {
|
loadingMainContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingBottom: 40,
|
paddingBottom: 90,
|
||||||
},
|
},
|
||||||
loadingText: {
|
loadingText: {
|
||||||
marginTop: 12,
|
marginTop: 12,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
},
|
},
|
||||||
|
loadingMoreCatalogs: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 16,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
loadingMoreText: {
|
||||||
|
marginLeft: 12,
|
||||||
|
fontSize: 14,
|
||||||
|
},
|
||||||
|
catalogPlaceholder: {
|
||||||
|
marginBottom: 24,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
placeholderHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
placeholderTitle: {
|
||||||
|
width: 150,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
placeholderPosters: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 8,
|
||||||
|
},
|
||||||
|
placeholderPoster: {
|
||||||
|
width: POSTER_WIDTH,
|
||||||
|
aspectRatio: 2/3,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
emptyCatalog: {
|
emptyCatalog: {
|
||||||
padding: 32,
|
padding: 32,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
@ -810,19 +1091,19 @@ const styles = StyleSheet.create<any>({
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
catalogTitle: {
|
catalogTitle: {
|
||||||
fontSize: 18,
|
fontSize: 19,
|
||||||
fontWeight: '800',
|
fontWeight: '700',
|
||||||
textTransform: 'uppercase',
|
letterSpacing: 0.2,
|
||||||
letterSpacing: 0.5,
|
marginBottom: 4,
|
||||||
marginBottom: 6,
|
|
||||||
},
|
},
|
||||||
titleUnderline: {
|
titleUnderline: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: -4,
|
bottom: -2,
|
||||||
left: 0,
|
left: 0,
|
||||||
width: 60,
|
width: 35,
|
||||||
height: 3,
|
height: 2,
|
||||||
borderRadius: 1.5,
|
borderRadius: 1,
|
||||||
|
opacity: 0.8,
|
||||||
},
|
},
|
||||||
seeAllButton: {
|
seeAllButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|
@ -837,7 +1118,8 @@ const styles = StyleSheet.create<any>({
|
||||||
marginRight: 4,
|
marginRight: 4,
|
||||||
},
|
},
|
||||||
catalogList: {
|
catalogList: {
|
||||||
paddingHorizontal: 16,
|
paddingLeft: 16,
|
||||||
|
paddingRight: 16 - posterLayout.partialPosterWidth,
|
||||||
paddingBottom: 12,
|
paddingBottom: 12,
|
||||||
paddingTop: 6,
|
paddingTop: 6,
|
||||||
},
|
},
|
||||||
|
|
@ -845,21 +1127,21 @@ const styles = StyleSheet.create<any>({
|
||||||
width: POSTER_WIDTH,
|
width: POSTER_WIDTH,
|
||||||
aspectRatio: 2/3,
|
aspectRatio: 2/3,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
borderRadius: 16,
|
borderRadius: 4,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
elevation: 8,
|
elevation: 6,
|
||||||
shadowColor: '#000',
|
shadowColor: '#000',
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 3 },
|
||||||
shadowOpacity: 0.3,
|
shadowOpacity: 0.25,
|
||||||
shadowRadius: 8,
|
shadowRadius: 6,
|
||||||
borderWidth: 1,
|
borderWidth: 0.5,
|
||||||
borderColor: 'rgba(255,255,255,0.08)',
|
borderColor: 'rgba(255,255,255,0.12)',
|
||||||
},
|
},
|
||||||
poster: {
|
poster: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 16,
|
borderRadius: 4,
|
||||||
},
|
},
|
||||||
imdbLogo: {
|
imdbLogo: {
|
||||||
width: 35,
|
width: 35,
|
||||||
|
|
@ -898,7 +1180,7 @@ const styles = StyleSheet.create<any>({
|
||||||
contentItemContainer: {
|
contentItemContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 16,
|
borderRadius: 4,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
|
|
@ -1009,7 +1291,7 @@ const styles = StyleSheet.create<any>({
|
||||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderRadius: 16,
|
borderRadius: 8,
|
||||||
},
|
},
|
||||||
featuredImage: {
|
featuredImage: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -1045,4 +1327,4 @@ const styles = StyleSheet.create<any>({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default HomeScreen;
|
export default React.memo(HomeScreen);
|
||||||
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'
|
settings.logoSourcePreference || 'metahub'
|
||||||
);
|
);
|
||||||
|
|
||||||
// TMDB Language Preference
|
|
||||||
const [selectedTmdbLanguage, setSelectedTmdbLanguage] = useState<string>(
|
|
||||||
settings.tmdbLanguagePreference || 'en'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Make sure logoSource stays in sync with settings
|
// Make sure logoSource stays in sync with settings
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLogoSource(settings.logoSourcePreference || 'metahub');
|
setLogoSource(settings.logoSourcePreference || 'metahub');
|
||||||
}, [settings.logoSourcePreference]);
|
}, [settings.logoSourcePreference]);
|
||||||
|
|
||||||
// Keep selectedTmdbLanguage in sync with settings
|
|
||||||
useEffect(() => {
|
|
||||||
setSelectedTmdbLanguage(settings.tmdbLanguagePreference || 'en');
|
|
||||||
}, [settings.tmdbLanguagePreference]);
|
|
||||||
|
|
||||||
// Force reload settings from AsyncStorage when component mounts
|
|
||||||
useEffect(() => {
|
|
||||||
const loadSettingsFromStorage = async () => {
|
|
||||||
try {
|
|
||||||
const settingsJson = await AsyncStorage.getItem('app_settings');
|
|
||||||
if (settingsJson) {
|
|
||||||
const storedSettings = JSON.parse(settingsJson);
|
|
||||||
|
|
||||||
// Update local state to match stored settings
|
|
||||||
if (storedSettings.logoSourcePreference) {
|
|
||||||
setLogoSource(storedSettings.logoSourcePreference);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (storedSettings.tmdbLanguagePreference) {
|
|
||||||
setSelectedTmdbLanguage(storedSettings.tmdbLanguagePreference);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.log('[LogoSourceSettings] Successfully loaded settings from AsyncStorage');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('[LogoSourceSettings] Error loading settings from AsyncStorage:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSettingsFromStorage();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Selected example show
|
// Selected example show
|
||||||
const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]);
|
const [selectedShow, setSelectedShow] = useState(EXAMPLE_SHOWS[0]);
|
||||||
|
|
||||||
|
|
@ -429,6 +392,9 @@ const LogoSourceSettings = () => {
|
||||||
|
|
||||||
logger.log(`[LogoSourceSettings] Fetching ${show.name} with TMDB ID: ${tmdbId}, IMDB ID: ${imdbId}`);
|
logger.log(`[LogoSourceSettings] Fetching ${show.name} with TMDB ID: ${tmdbId}, IMDB ID: ${imdbId}`);
|
||||||
|
|
||||||
|
// Get preferred language directly from settings
|
||||||
|
const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en';
|
||||||
|
|
||||||
// Get TMDB logo and banner
|
// Get TMDB logo and banner
|
||||||
try {
|
try {
|
||||||
const apiKey = TMDB_API_KEY;
|
const apiKey = TMDB_API_KEY;
|
||||||
|
|
@ -451,15 +417,15 @@ const LogoSourceSettings = () => {
|
||||||
|
|
||||||
// Find initial logo (prefer selectedTmdbLanguage, then 'en')
|
// Find initial logo (prefer selectedTmdbLanguage, then 'en')
|
||||||
let initialLogoPath: string | null = null;
|
let initialLogoPath: string | null = null;
|
||||||
let initialLanguage = selectedTmdbLanguage;
|
let initialLanguage = preferredTmdbLanguage;
|
||||||
|
|
||||||
// First try to find a logo in the user's preferred language
|
// First try to find a logo in the user's preferred language
|
||||||
const preferredLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === selectedTmdbLanguage);
|
const preferredLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === preferredTmdbLanguage);
|
||||||
|
|
||||||
if (preferredLogo) {
|
if (preferredLogo) {
|
||||||
initialLogoPath = preferredLogo.file_path;
|
initialLogoPath = preferredLogo.file_path;
|
||||||
initialLanguage = selectedTmdbLanguage;
|
initialLanguage = preferredTmdbLanguage;
|
||||||
logger.log(`[LogoSourceSettings] Found initial ${selectedTmdbLanguage} TMDB logo for ${show.name}`);
|
logger.log(`[LogoSourceSettings] Found initial ${preferredTmdbLanguage} TMDB logo for ${show.name}`);
|
||||||
} else {
|
} else {
|
||||||
// Fallback to English logo
|
// Fallback to English logo
|
||||||
const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en');
|
const englishLogo = imagesData.logos.find((logo: { iso_639_1: string; file_path: string }) => logo.iso_639_1 === 'en');
|
||||||
|
|
@ -478,7 +444,6 @@ const LogoSourceSettings = () => {
|
||||||
|
|
||||||
if (initialLogoPath) {
|
if (initialLogoPath) {
|
||||||
setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`);
|
setTmdbLogo(`https://image.tmdb.org/t/p/original${initialLogoPath}`);
|
||||||
setSelectedTmdbLanguage(initialLanguage); // Set selected language based on found logo
|
|
||||||
} else {
|
} else {
|
||||||
logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`);
|
logger.warn(`[LogoSourceSettings] No valid initial TMDB logo found for ${show.name}`);
|
||||||
}
|
}
|
||||||
|
|
@ -588,9 +553,6 @@ const LogoSourceSettings = () => {
|
||||||
|
|
||||||
// Handle TMDB language selection
|
// Handle TMDB language selection
|
||||||
const handleTmdbLanguageSelect = (languageCode: string) => {
|
const handleTmdbLanguageSelect = (languageCode: string) => {
|
||||||
// First set local state for immediate UI updates
|
|
||||||
setSelectedTmdbLanguage(languageCode);
|
|
||||||
|
|
||||||
// Update the preview logo if possible
|
// Update the preview logo if possible
|
||||||
if (tmdbLogosData) {
|
if (tmdbLogosData) {
|
||||||
const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode);
|
const selectedLogoData = tmdbLogosData.find(logo => logo.iso_639_1 === languageCode);
|
||||||
|
|
@ -606,6 +568,9 @@ const LogoSourceSettings = () => {
|
||||||
saveLanguagePreference(languageCode);
|
saveLanguagePreference(languageCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Get preferred language directly from settings for UI rendering
|
||||||
|
const preferredTmdbLanguage = settings.tmdbLanguagePreference || 'en';
|
||||||
|
|
||||||
// Save language preference with proper persistence
|
// Save language preference with proper persistence
|
||||||
const saveLanguagePreference = async (languageCode: string) => {
|
const saveLanguagePreference = async (languageCode: string) => {
|
||||||
logger.log(`[LogoSourceSettings] Saving TMDB language preference: ${languageCode}`);
|
logger.log(`[LogoSourceSettings] Saving TMDB language preference: ${languageCode}`);
|
||||||
|
|
@ -614,34 +579,6 @@ const LogoSourceSettings = () => {
|
||||||
// First use the settings hook to update the setting - this is crucial
|
// First use the settings hook to update the setting - this is crucial
|
||||||
updateSetting('tmdbLanguagePreference', languageCode);
|
updateSetting('tmdbLanguagePreference', languageCode);
|
||||||
|
|
||||||
// For extra assurance, also save directly to AsyncStorage
|
|
||||||
// Get current settings from AsyncStorage
|
|
||||||
const settingsJson = await AsyncStorage.getItem('app_settings');
|
|
||||||
|
|
||||||
if (settingsJson) {
|
|
||||||
const currentSettings = JSON.parse(settingsJson);
|
|
||||||
|
|
||||||
// Update the language preference
|
|
||||||
const updatedSettings = {
|
|
||||||
...currentSettings,
|
|
||||||
tmdbLanguagePreference: languageCode
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save back to AsyncStorage using await to ensure it completes
|
|
||||||
await AsyncStorage.setItem('app_settings', JSON.stringify(updatedSettings));
|
|
||||||
logger.log(`[LogoSourceSettings] Successfully saved TMDB language preference '${languageCode}' to AsyncStorage`);
|
|
||||||
} else {
|
|
||||||
// If no settings exist yet, create new settings object with this preference
|
|
||||||
const newSettings = {
|
|
||||||
...DEFAULT_SETTINGS,
|
|
||||||
tmdbLanguagePreference: languageCode
|
|
||||||
};
|
|
||||||
|
|
||||||
// Save to AsyncStorage
|
|
||||||
await AsyncStorage.setItem('app_settings', JSON.stringify(newSettings));
|
|
||||||
logger.log(`[LogoSourceSettings] Created new settings with TMDB language preference '${languageCode}'`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any cached logo data
|
// Clear any cached logo data
|
||||||
await AsyncStorage.removeItem('_last_logos_');
|
await AsyncStorage.removeItem('_last_logos_');
|
||||||
|
|
||||||
|
|
@ -875,7 +812,7 @@ const LogoSourceSettings = () => {
|
||||||
key={langCode} // Use the unique code as key
|
key={langCode} // Use the unique code as key
|
||||||
style={[
|
style={[
|
||||||
styles.languageItem,
|
styles.languageItem,
|
||||||
selectedTmdbLanguage === langCode && styles.selectedLanguageItem
|
preferredTmdbLanguage === langCode && styles.selectedLanguageItem
|
||||||
]}
|
]}
|
||||||
onPress={() => handleTmdbLanguageSelect(langCode)}
|
onPress={() => handleTmdbLanguageSelect(langCode)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
|
@ -884,7 +821,7 @@ const LogoSourceSettings = () => {
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
styles.languageItemText,
|
styles.languageItemText,
|
||||||
selectedTmdbLanguage === langCode && styles.selectedLanguageItemText
|
preferredTmdbLanguage === langCode && styles.selectedLanguageItemText
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{(langCode || '').toUpperCase() || '??'}
|
{(langCode || '').toUpperCase() || '??'}
|
||||||
|
|
|
||||||
|
|
@ -540,7 +540,7 @@ const MDBListSettingsScreen = () => {
|
||||||
|
|
||||||
const openMDBListWebsite = () => {
|
const openMDBListWebsite = () => {
|
||||||
logger.log('[MDBListSettingsScreen] Opening MDBList website');
|
logger.log('[MDBListSettingsScreen] Opening MDBList website');
|
||||||
Linking.openURL('https://mdblist.com/settings').catch(error => {
|
Linking.openURL('https://mdblist.com/preferences').catch(error => {
|
||||||
logger.error('[MDBListSettingsScreen] Error opening website:', error);
|
logger.error('[MDBListSettingsScreen] Error opening website:', error);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
|
|
@ -24,36 +24,42 @@ import Animated, {
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
interpolate,
|
interpolate,
|
||||||
Extrapolate,
|
Extrapolate,
|
||||||
|
useSharedValue,
|
||||||
|
withTiming,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { RouteProp } from '@react-navigation/native';
|
import { RouteProp } from '@react-navigation/native';
|
||||||
import { NavigationProp } from '@react-navigation/native';
|
import { NavigationProp } from '@react-navigation/native';
|
||||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from '../hooks/useSettings';
|
||||||
|
import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScreen';
|
||||||
|
|
||||||
// Import our new components and hooks
|
// Import our optimized components and hooks
|
||||||
import HeroSection from '../components/metadata/HeroSection';
|
import HeroSection from '../components/metadata/HeroSection';
|
||||||
import FloatingHeader from '../components/metadata/FloatingHeader';
|
import FloatingHeader from '../components/metadata/FloatingHeader';
|
||||||
import MetadataDetails from '../components/metadata/MetadataDetails';
|
import MetadataDetails from '../components/metadata/MetadataDetails';
|
||||||
import { useMetadataAnimations } from '../hooks/useMetadataAnimations';
|
import { useMetadataAnimations } from '../hooks/useMetadataAnimations';
|
||||||
import { useMetadataAssets } from '../hooks/useMetadataAssets';
|
import { useMetadataAssets } from '../hooks/useMetadataAssets';
|
||||||
import { useWatchProgress } from '../hooks/useWatchProgress';
|
import { useWatchProgress } from '../hooks/useWatchProgress';
|
||||||
|
import { TraktService, TraktPlaybackItem } from '../services/traktService';
|
||||||
|
|
||||||
const { height } = Dimensions.get('window');
|
const { height } = Dimensions.get('window');
|
||||||
|
|
||||||
const MetadataScreen = () => {
|
const MetadataScreen: React.FC = () => {
|
||||||
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string }>, string>>();
|
const route = useRoute<RouteProp<Record<string, RouteParams & { episodeId?: string; addonId?: string }>, string>>();
|
||||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||||
const { id, type, episodeId } = route.params;
|
const { id, type, episodeId, addonId } = route.params;
|
||||||
|
|
||||||
// Add settings hook
|
// Consolidated hooks for better performance
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
// Get theme context
|
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
// Get safe area insets
|
|
||||||
const { top: safeAreaTop } = useSafeAreaInsets();
|
const { top: safeAreaTop } = useSafeAreaInsets();
|
||||||
|
|
||||||
|
// Optimized state management - reduced state variables
|
||||||
|
const [isContentReady, setIsContentReady] = useState(false);
|
||||||
|
const [showSkeleton, setShowSkeleton] = useState(true);
|
||||||
|
const transitionOpacity = useSharedValue(0);
|
||||||
|
const skeletonOpacity = useSharedValue(1);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
metadata,
|
metadata,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -72,222 +78,263 @@ const MetadataScreen = () => {
|
||||||
loadingRecommendations,
|
loadingRecommendations,
|
||||||
setMetadata,
|
setMetadata,
|
||||||
imdbId,
|
imdbId,
|
||||||
} = useMetadata({ id, type });
|
} = useMetadata({ id, type, addonId });
|
||||||
|
|
||||||
// Use our new hooks
|
// Optimized hooks with memoization
|
||||||
const {
|
const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
|
||||||
watchProgress,
|
const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
|
||||||
getEpisodeDetails,
|
const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress);
|
||||||
getPlayButtonText,
|
|
||||||
} = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
|
|
||||||
|
|
||||||
const {
|
// Fetch and log Trakt progress data when entering the screen
|
||||||
bannerImage,
|
useEffect(() => {
|
||||||
loadingBanner,
|
const fetchTraktProgress = async () => {
|
||||||
logoLoadError,
|
try {
|
||||||
setLogoLoadError,
|
const traktService = TraktService.getInstance();
|
||||||
setBannerImage,
|
const isAuthenticated = await traktService.isAuthenticated();
|
||||||
} = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
|
|
||||||
|
|
||||||
const animations = useMetadataAnimations(safeAreaTop, watchProgress);
|
console.log(`[MetadataScreen] === TRAKT PROGRESS DATA FOR ${type.toUpperCase()}: ${metadata?.name || id} ===`);
|
||||||
|
console.log(`[MetadataScreen] IMDB ID: ${id}`);
|
||||||
|
console.log(`[MetadataScreen] Trakt authenticated: ${isAuthenticated}`);
|
||||||
|
|
||||||
// Add wrapper for toggleLibrary that includes haptic feedback
|
if (!isAuthenticated) {
|
||||||
const handleToggleLibrary = useCallback(() => {
|
console.log(`[MetadataScreen] Not authenticated with Trakt, no progress data available`);
|
||||||
// Trigger appropriate haptic feedback based on action
|
return;
|
||||||
if (inLibrary) {
|
|
||||||
// Removed from library - light impact
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
|
||||||
} else {
|
|
||||||
// Added to library - success feedback
|
|
||||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the original toggleLibrary function
|
// Get all playback progress from Trakt
|
||||||
|
const allProgress = await traktService.getPlaybackProgress();
|
||||||
|
console.log(`[MetadataScreen] Total Trakt progress items: ${allProgress.length}`);
|
||||||
|
|
||||||
|
if (allProgress.length === 0) {
|
||||||
|
console.log(`[MetadataScreen] No Trakt progress data found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter progress for current content
|
||||||
|
let relevantProgress: TraktPlaybackItem[] = [];
|
||||||
|
|
||||||
|
if (type === 'movie') {
|
||||||
|
relevantProgress = allProgress.filter(item =>
|
||||||
|
item.type === 'movie' &&
|
||||||
|
item.movie?.ids.imdb === id.replace('tt', '')
|
||||||
|
);
|
||||||
|
} else if (type === 'series') {
|
||||||
|
relevantProgress = allProgress.filter(item =>
|
||||||
|
item.type === 'episode' &&
|
||||||
|
item.show?.ids.imdb === id.replace('tt', '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MetadataScreen] Relevant progress items for this ${type}: ${relevantProgress.length}`);
|
||||||
|
|
||||||
|
if (relevantProgress.length === 0) {
|
||||||
|
console.log(`[MetadataScreen] No Trakt progress found for this ${type}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log detailed progress information
|
||||||
|
relevantProgress.forEach((item, index) => {
|
||||||
|
console.log(`[MetadataScreen] --- Progress Item ${index + 1} ---`);
|
||||||
|
console.log(`[MetadataScreen] Type: ${item.type}`);
|
||||||
|
console.log(`[MetadataScreen] Progress: ${item.progress.toFixed(2)}%`);
|
||||||
|
console.log(`[MetadataScreen] Paused at: ${item.paused_at}`);
|
||||||
|
console.log(`[MetadataScreen] Trakt ID: ${item.id}`);
|
||||||
|
|
||||||
|
if (item.movie) {
|
||||||
|
console.log(`[MetadataScreen] Movie: ${item.movie.title} (${item.movie.year})`);
|
||||||
|
console.log(`[MetadataScreen] Movie IMDB: tt${item.movie.ids.imdb}`);
|
||||||
|
console.log(`[MetadataScreen] Movie TMDB: ${item.movie.ids.tmdb}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.episode && item.show) {
|
||||||
|
console.log(`[MetadataScreen] Show: ${item.show.title} (${item.show.year})`);
|
||||||
|
console.log(`[MetadataScreen] Show IMDB: tt${item.show.ids.imdb}`);
|
||||||
|
console.log(`[MetadataScreen] Episode: S${item.episode.season}E${item.episode.number} - ${item.episode.title}`);
|
||||||
|
console.log(`[MetadataScreen] Episode IMDB: ${item.episode.ids.imdb || 'N/A'}`);
|
||||||
|
console.log(`[MetadataScreen] Episode TMDB: ${item.episode.ids.tmdb || 'N/A'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MetadataScreen] Raw item:`, JSON.stringify(item, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find most recent progress if multiple episodes
|
||||||
|
if (type === 'series' && relevantProgress.length > 1) {
|
||||||
|
const mostRecent = relevantProgress.sort((a, b) =>
|
||||||
|
new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime()
|
||||||
|
)[0];
|
||||||
|
|
||||||
|
console.log(`[MetadataScreen] === MOST RECENT EPISODE PROGRESS ===`);
|
||||||
|
if (mostRecent.episode && mostRecent.show) {
|
||||||
|
console.log(`[MetadataScreen] Most recent: S${mostRecent.episode.season}E${mostRecent.episode.number} - ${mostRecent.episode.title}`);
|
||||||
|
console.log(`[MetadataScreen] Progress: ${mostRecent.progress.toFixed(2)}%`);
|
||||||
|
console.log(`[MetadataScreen] Watched on: ${new Date(mostRecent.paused_at).toLocaleString()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[MetadataScreen] === END TRAKT PROGRESS DATA ===`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[MetadataScreen] Failed to fetch Trakt progress:`, error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only fetch when we have metadata loaded
|
||||||
|
if (metadata && id) {
|
||||||
|
fetchTraktProgress();
|
||||||
|
}
|
||||||
|
}, [metadata, id, type]);
|
||||||
|
|
||||||
|
// Memoized derived values for performance
|
||||||
|
const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]);
|
||||||
|
|
||||||
|
// Smooth skeleton to content transition
|
||||||
|
useEffect(() => {
|
||||||
|
if (isReady && !isContentReady) {
|
||||||
|
// Small delay to ensure skeleton is rendered before starting transition
|
||||||
|
setTimeout(() => {
|
||||||
|
// Start fade out skeleton and fade in content simultaneously
|
||||||
|
skeletonOpacity.value = withTiming(0, { duration: 300 });
|
||||||
|
transitionOpacity.value = withTiming(1, { duration: 400 });
|
||||||
|
|
||||||
|
// Hide skeleton after fade out completes
|
||||||
|
setTimeout(() => {
|
||||||
|
setShowSkeleton(false);
|
||||||
|
setIsContentReady(true);
|
||||||
|
}, 300);
|
||||||
|
}, 100);
|
||||||
|
} else if (!isReady && isContentReady) {
|
||||||
|
setIsContentReady(false);
|
||||||
|
setShowSkeleton(true);
|
||||||
|
transitionOpacity.value = 0;
|
||||||
|
skeletonOpacity.value = 1;
|
||||||
|
}
|
||||||
|
}, [isReady, isContentReady]);
|
||||||
|
|
||||||
|
// Optimized callback functions with reduced dependencies
|
||||||
|
const handleToggleLibrary = useCallback(() => {
|
||||||
|
Haptics.impactAsync(inLibrary ? Haptics.ImpactFeedbackStyle.Light : Haptics.ImpactFeedbackStyle.Medium);
|
||||||
toggleLibrary();
|
toggleLibrary();
|
||||||
}, [inLibrary, toggleLibrary]);
|
}, [inLibrary, toggleLibrary]);
|
||||||
|
|
||||||
// Add wrapper for season change with distinctive haptic feedback
|
|
||||||
const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => {
|
const handleSeasonChangeWithHaptics = useCallback((seasonNumber: number) => {
|
||||||
// Change to Light impact for a more subtle feedback
|
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
// Wait a tiny bit before changing season, making the feedback more noticeable
|
|
||||||
setTimeout(() => {
|
|
||||||
handleSeasonChange(seasonNumber);
|
handleSeasonChange(seasonNumber);
|
||||||
}, 10);
|
|
||||||
}, [handleSeasonChange]);
|
}, [handleSeasonChange]);
|
||||||
|
|
||||||
// Handler functions
|
|
||||||
const handleShowStreams = useCallback(() => {
|
const handleShowStreams = useCallback(() => {
|
||||||
|
const { watchProgress } = watchProgressData;
|
||||||
if (type === 'series') {
|
if (type === 'series') {
|
||||||
// If we have watch progress with an episodeId, use that
|
const targetEpisodeId = watchProgress?.episodeId || episodeId || (episodes.length > 0 ?
|
||||||
if (watchProgress?.episodeId) {
|
(episodes[0].stremioId || `${id}:${episodes[0].season_number}:${episodes[0].episode_number}`) : undefined);
|
||||||
navigation.navigate('Streams', {
|
|
||||||
id,
|
if (targetEpisodeId) {
|
||||||
type,
|
navigation.navigate('Streams', { id, type, episodeId: targetEpisodeId });
|
||||||
episodeId: watchProgress.episodeId
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// If we have a specific episodeId from route params, use that
|
|
||||||
if (episodeId) {
|
|
||||||
navigation.navigate('Streams', { id, type, episodeId });
|
navigation.navigate('Streams', { id, type, episodeId });
|
||||||
return;
|
}, [navigation, id, type, episodes, episodeId, watchProgressData.watchProgress]);
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, if we have episodes, start with the first one
|
|
||||||
if (episodes.length > 0) {
|
|
||||||
const firstEpisode = episodes[0];
|
|
||||||
const newEpisodeId = firstEpisode.stremioId || `${id}:${firstEpisode.season_number}:${firstEpisode.episode_number}`;
|
|
||||||
navigation.navigate('Streams', { id, type, episodeId: newEpisodeId });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
navigation.navigate('Streams', { id, type, episodeId });
|
|
||||||
}, [navigation, id, type, episodes, episodeId, watchProgress]);
|
|
||||||
|
|
||||||
const handleSelectCastMember = useCallback((castMember: any) => {
|
|
||||||
// Future implementation
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleEpisodeSelect = useCallback((episode: Episode) => {
|
const handleEpisodeSelect = useCallback((episode: Episode) => {
|
||||||
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
|
const episodeId = episode.stremioId || `${id}:${episode.season_number}:${episode.episode_number}`;
|
||||||
navigation.navigate('Streams', {
|
navigation.navigate('Streams', { id, type, episodeId });
|
||||||
id,
|
|
||||||
type,
|
|
||||||
episodeId
|
|
||||||
});
|
|
||||||
}, [navigation, id, type]);
|
}, [navigation, id, type]);
|
||||||
|
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => navigation.goBack(), [navigation]);
|
||||||
navigation.goBack();
|
const handleSelectCastMember = useCallback(() => {}, []); // Simplified for performance
|
||||||
}, [navigation]);
|
|
||||||
|
|
||||||
// Animated styles
|
// Ultra-optimized animated styles - minimal calculations
|
||||||
const containerAnimatedStyle = useAnimatedStyle(() => ({
|
const containerStyle = useAnimatedStyle(() => ({
|
||||||
flex: 1,
|
opacity: animations.screenOpacity.value,
|
||||||
transform: [{ scale: animations.screenScale.value }],
|
}), []);
|
||||||
opacity: animations.screenOpacity.value
|
|
||||||
}));
|
|
||||||
|
|
||||||
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
const contentStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{ translateY: animations.contentTranslateY.value }],
|
opacity: animations.contentOpacity.value,
|
||||||
opacity: interpolate(
|
transform: [{ translateY: animations.uiElementsTranslateY.value }]
|
||||||
animations.contentTranslateY.value,
|
}), []);
|
||||||
[60, 0],
|
|
||||||
[0, 1],
|
const transitionStyle = useAnimatedStyle(() => ({
|
||||||
Extrapolate.CLAMP
|
opacity: transitionOpacity.value,
|
||||||
)
|
}), []);
|
||||||
}));
|
|
||||||
|
const skeletonStyle = useAnimatedStyle(() => ({
|
||||||
|
opacity: skeletonOpacity.value,
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
// Memoized error component for performance
|
||||||
|
const ErrorComponent = useMemo(() => {
|
||||||
|
if (!metadataError) return null;
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
style={[styles.container, {
|
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
||||||
backgroundColor: currentTheme.colors.darkBackground
|
|
||||||
}]}
|
|
||||||
edges={['bottom']}
|
edges={['bottom']}
|
||||||
>
|
>
|
||||||
<StatusBar
|
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" />
|
||||||
translucent={true}
|
|
||||||
backgroundColor="transparent"
|
|
||||||
barStyle="light-content"
|
|
||||||
/>
|
|
||||||
<View style={styles.loadingContainer}>
|
|
||||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
|
||||||
<Text style={[styles.loadingText, {
|
|
||||||
color: currentTheme.colors.mediumEmphasis
|
|
||||||
}]}>
|
|
||||||
Loading content...
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (metadataError || !metadata) {
|
|
||||||
return (
|
|
||||||
<SafeAreaView
|
|
||||||
style={[styles.container, {
|
|
||||||
backgroundColor: currentTheme.colors.darkBackground
|
|
||||||
}]}
|
|
||||||
edges={['bottom']}
|
|
||||||
>
|
|
||||||
<StatusBar
|
|
||||||
translucent={true}
|
|
||||||
backgroundColor="transparent"
|
|
||||||
barStyle="light-content"
|
|
||||||
/>
|
|
||||||
<View style={styles.errorContainer}>
|
<View style={styles.errorContainer}>
|
||||||
<MaterialIcons
|
<MaterialIcons name="error-outline" size={64} color={currentTheme.colors.textMuted} />
|
||||||
name="error-outline"
|
<Text style={[styles.errorText, { color: currentTheme.colors.highEmphasis }]}>
|
||||||
size={64}
|
|
||||||
color={currentTheme.colors.textMuted}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.errorText, {
|
|
||||||
color: currentTheme.colors.highEmphasis
|
|
||||||
}]}>
|
|
||||||
{metadataError || 'Content not found'}
|
{metadataError || 'Content not found'}
|
||||||
</Text>
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||||
styles.retryButton,
|
|
||||||
{ backgroundColor: currentTheme.colors.primary }
|
|
||||||
]}
|
|
||||||
onPress={loadMetadata}
|
onPress={loadMetadata}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons name="refresh" size={20} color={currentTheme.colors.white} style={{ marginRight: 8 }} />
|
||||||
name="refresh"
|
|
||||||
size={20}
|
|
||||||
color={currentTheme.colors.white}
|
|
||||||
style={{ marginRight: 8 }}
|
|
||||||
/>
|
|
||||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[styles.backButton, { borderColor: currentTheme.colors.primary }]}
|
||||||
styles.backButton,
|
|
||||||
{ borderColor: currentTheme.colors.primary }
|
|
||||||
]}
|
|
||||||
onPress={handleBack}
|
onPress={handleBack}
|
||||||
>
|
>
|
||||||
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>
|
<Text style={[styles.backButtonText, { color: currentTheme.colors.primary }]}>Go Back</Text>
|
||||||
Go Back
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
}, [metadataError, currentTheme, loadMetadata, handleBack]);
|
||||||
|
|
||||||
|
// Show error if exists
|
||||||
|
if (metadataError || (!loading && !metadata)) {
|
||||||
|
return ErrorComponent;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<View style={StyleSheet.absoluteFill}>
|
||||||
|
{/* Skeleton Loading Screen - with fade out transition */}
|
||||||
|
{showSkeleton && (
|
||||||
|
<Animated.View
|
||||||
|
style={[StyleSheet.absoluteFill, skeletonStyle]}
|
||||||
|
pointerEvents={metadata ? 'none' : 'auto'}
|
||||||
|
>
|
||||||
|
<MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} />
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content - with fade in transition */}
|
||||||
|
{metadata && (
|
||||||
|
<Animated.View
|
||||||
|
style={[StyleSheet.absoluteFill, transitionStyle]}
|
||||||
|
pointerEvents={metadata ? 'auto' : 'none'}
|
||||||
|
>
|
||||||
<SafeAreaView
|
<SafeAreaView
|
||||||
style={[containerAnimatedStyle, styles.container, {
|
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
||||||
backgroundColor: currentTheme.colors.darkBackground
|
|
||||||
}]}
|
|
||||||
edges={['bottom']}
|
edges={['bottom']}
|
||||||
>
|
>
|
||||||
<StatusBar
|
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
|
||||||
translucent={true}
|
|
||||||
backgroundColor="transparent"
|
{/* Floating Header - Optimized */}
|
||||||
barStyle="light-content"
|
|
||||||
animated={true}
|
|
||||||
/>
|
|
||||||
<Animated.View style={containerAnimatedStyle}>
|
|
||||||
{/* Floating Header */}
|
|
||||||
<FloatingHeader
|
<FloatingHeader
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
logoLoadError={logoLoadError}
|
logoLoadError={assetData.logoLoadError}
|
||||||
handleBack={handleBack}
|
handleBack={handleBack}
|
||||||
handleToggleLibrary={handleToggleLibrary}
|
handleToggleLibrary={handleToggleLibrary}
|
||||||
|
headerElementsY={animations.headerElementsY}
|
||||||
inLibrary={inLibrary}
|
inLibrary={inLibrary}
|
||||||
headerOpacity={animations.headerOpacity}
|
headerOpacity={animations.headerOpacity}
|
||||||
headerElementsY={animations.headerElementsY}
|
|
||||||
headerElementsOpacity={animations.headerElementsOpacity}
|
headerElementsOpacity={animations.headerElementsOpacity}
|
||||||
safeAreaTop={safeAreaTop}
|
safeAreaTop={safeAreaTop}
|
||||||
setLogoLoadError={setLogoLoadError}
|
setLogoLoadError={assetData.setLogoLoadError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Animated.ScrollView
|
<Animated.ScrollView
|
||||||
|
|
@ -295,62 +342,54 @@ const MetadataScreen = () => {
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={animations.scrollHandler}
|
onScroll={animations.scrollHandler}
|
||||||
scrollEventThrottle={16}
|
scrollEventThrottle={16}
|
||||||
|
bounces={false}
|
||||||
|
overScrollMode="never"
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
>
|
>
|
||||||
{/* Hero Section */}
|
{/* Hero Section - Optimized */}
|
||||||
<HeroSection
|
<HeroSection
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
bannerImage={bannerImage}
|
bannerImage={assetData.bannerImage}
|
||||||
loadingBanner={loadingBanner}
|
loadingBanner={assetData.loadingBanner}
|
||||||
logoLoadError={logoLoadError}
|
logoLoadError={assetData.logoLoadError}
|
||||||
scrollY={animations.scrollY}
|
scrollY={animations.scrollY}
|
||||||
dampedScrollY={animations.dampedScrollY}
|
|
||||||
heroHeight={animations.heroHeight}
|
heroHeight={animations.heroHeight}
|
||||||
heroOpacity={animations.heroOpacity}
|
heroOpacity={animations.heroOpacity}
|
||||||
heroScale={animations.heroScale}
|
|
||||||
logoOpacity={animations.logoOpacity}
|
logoOpacity={animations.logoOpacity}
|
||||||
logoScale={animations.logoScale}
|
|
||||||
genresOpacity={animations.genresOpacity}
|
|
||||||
genresTranslateY={animations.genresTranslateY}
|
|
||||||
buttonsOpacity={animations.buttonsOpacity}
|
buttonsOpacity={animations.buttonsOpacity}
|
||||||
buttonsTranslateY={animations.buttonsTranslateY}
|
buttonsTranslateY={animations.buttonsTranslateY}
|
||||||
watchProgressOpacity={animations.watchProgressOpacity}
|
watchProgressOpacity={animations.watchProgressOpacity}
|
||||||
watchProgressScaleY={animations.watchProgressScaleY}
|
watchProgressWidth={animations.watchProgressWidth}
|
||||||
watchProgress={watchProgress}
|
watchProgress={watchProgressData.watchProgress}
|
||||||
type={type as 'movie' | 'series'}
|
type={type as 'movie' | 'series'}
|
||||||
getEpisodeDetails={getEpisodeDetails}
|
getEpisodeDetails={watchProgressData.getEpisodeDetails}
|
||||||
handleShowStreams={handleShowStreams}
|
handleShowStreams={handleShowStreams}
|
||||||
handleToggleLibrary={handleToggleLibrary}
|
handleToggleLibrary={handleToggleLibrary}
|
||||||
inLibrary={inLibrary}
|
inLibrary={inLibrary}
|
||||||
id={id}
|
id={id}
|
||||||
navigation={navigation}
|
navigation={navigation}
|
||||||
getPlayButtonText={getPlayButtonText}
|
getPlayButtonText={watchProgressData.getPlayButtonText}
|
||||||
setBannerImage={setBannerImage}
|
setBannerImage={assetData.setBannerImage}
|
||||||
setLogoLoadError={setLogoLoadError}
|
setLogoLoadError={assetData.setLogoLoadError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content - Optimized */}
|
||||||
<Animated.View style={contentAnimatedStyle}>
|
<Animated.View style={contentStyle}>
|
||||||
{/* Metadata Details */}
|
|
||||||
<MetadataDetails
|
<MetadataDetails
|
||||||
metadata={metadata}
|
metadata={metadata}
|
||||||
imdbId={imdbId}
|
imdbId={imdbId}
|
||||||
type={type as 'movie' | 'series'}
|
type={type as 'movie' | 'series'}
|
||||||
renderRatings={() => imdbId ? (
|
renderRatings={() => imdbId ? (
|
||||||
<RatingsSection
|
<RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} />
|
||||||
imdbId={imdbId}
|
|
||||||
type={type === 'series' ? 'show' : 'movie'}
|
|
||||||
/>
|
|
||||||
) : null}
|
) : null}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cast Section */}
|
|
||||||
<CastSection
|
<CastSection
|
||||||
cast={cast}
|
cast={cast}
|
||||||
loadingCast={loadingCast}
|
loadingCast={loadingCast}
|
||||||
onSelectCastMember={handleSelectCastMember}
|
onSelectCastMember={handleSelectCastMember}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* More Like This Section - Only for movies */}
|
|
||||||
{type === 'movie' && (
|
{type === 'movie' && (
|
||||||
<MoreLikeThisSection
|
<MoreLikeThisSection
|
||||||
recommendations={recommendations}
|
recommendations={recommendations}
|
||||||
|
|
@ -358,7 +397,6 @@ const MetadataScreen = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Type-specific content */}
|
|
||||||
{type === 'series' ? (
|
{type === 'series' ? (
|
||||||
<SeriesContent
|
<SeriesContent
|
||||||
episodes={episodes}
|
episodes={episodes}
|
||||||
|
|
@ -367,36 +405,30 @@ const MetadataScreen = () => {
|
||||||
onSeasonChange={handleSeasonChangeWithHaptics}
|
onSeasonChange={handleSeasonChangeWithHaptics}
|
||||||
onSelectEpisode={handleEpisodeSelect}
|
onSelectEpisode={handleEpisodeSelect}
|
||||||
groupedEpisodes={groupedEpisodes}
|
groupedEpisodes={groupedEpisodes}
|
||||||
metadata={metadata}
|
metadata={metadata || undefined}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MovieContent metadata={metadata} />
|
metadata && <MovieContent metadata={metadata} />
|
||||||
)}
|
)}
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</Animated.ScrollView>
|
</Animated.ScrollView>
|
||||||
</Animated.View>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Optimized styles with minimal properties
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: 'transparent',
|
|
||||||
paddingTop: 0,
|
|
||||||
},
|
},
|
||||||
scrollView: {
|
scrollView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
loadingContainer: {
|
scrollContent: {
|
||||||
flex: 1,
|
flexGrow: 1,
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
loadingText: {
|
|
||||||
marginTop: 16,
|
|
||||||
fontSize: 16,
|
|
||||||
},
|
},
|
||||||
errorContainer: {
|
errorContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|
@ -422,13 +454,13 @@ const styles = StyleSheet.create({
|
||||||
retryButtonText: {
|
retryButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
|
color: '#fff',
|
||||||
},
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
width: 40,
|
paddingHorizontal: 24,
|
||||||
height: 40,
|
paddingVertical: 12,
|
||||||
alignItems: 'center',
|
borderRadius: 24,
|
||||||
justifyContent: 'center',
|
borderWidth: 2,
|
||||||
borderRadius: 20,
|
|
||||||
},
|
},
|
||||||
backButtonText: {
|
backButtonText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
Platform,
|
Platform,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
|
Switch,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { useSettings, AppSettings } from '../hooks/useSettings';
|
import { useSettings, AppSettings } from '../hooks/useSettings';
|
||||||
|
|
@ -219,6 +220,68 @@ const PlayerSettingsScreen: React.FC = () => {
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.sectionTitle,
|
||||||
|
{ color: currentTheme.colors.textMuted },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
PLAYBACK OPTIONS
|
||||||
|
</Text>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.card,
|
||||||
|
{
|
||||||
|
backgroundColor: currentTheme.colors.elevation2,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.settingItem}>
|
||||||
|
<View style={styles.settingContent}>
|
||||||
|
<View style={[
|
||||||
|
styles.settingIconContainer,
|
||||||
|
{ backgroundColor: 'rgba(255,255,255,0.1)' }
|
||||||
|
]}>
|
||||||
|
<MaterialIcons
|
||||||
|
name="play-arrow"
|
||||||
|
size={20}
|
||||||
|
color={currentTheme.colors.primary}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View style={styles.settingText}>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.settingTitle,
|
||||||
|
{ color: currentTheme.colors.text },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Auto-play Best Stream
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.settingDescription,
|
||||||
|
{ color: currentTheme.colors.textMuted },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
Automatically play the highest quality stream when available
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={settings.autoplayBestStream}
|
||||||
|
onValueChange={(value) => updateSetting('autoplayBestStream', value)}
|
||||||
|
trackColor={{
|
||||||
|
false: 'rgba(255,255,255,0.2)',
|
||||||
|
true: currentTheme.colors.primary + '40'
|
||||||
|
}}
|
||||||
|
thumbColor={settings.autoplayBestStream ? currentTheme.colors.primary : 'rgba(255,255,255,0.8)'}
|
||||||
|
ios_backgroundColor="rgba(255,255,255,0.2)"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,11 @@ const SearchScreen = () => {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRecentSearches();
|
loadRecentSearches();
|
||||||
|
|
||||||
|
// Cleanup function to cancel pending searches on unmount
|
||||||
|
return () => {
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const animatedSearchBarStyle = useAnimatedStyle(() => {
|
const animatedSearchBarStyle = useAnimatedStyle(() => {
|
||||||
|
|
@ -282,7 +287,14 @@ const SearchScreen = () => {
|
||||||
setShowRecent(true);
|
setShowRecent(true);
|
||||||
loadRecentSearches();
|
loadRecentSearches();
|
||||||
} else {
|
} else {
|
||||||
|
// Add a small delay to allow keyboard to dismiss smoothly before navigation
|
||||||
|
if (Platform.OS === 'android') {
|
||||||
|
setTimeout(() => {
|
||||||
navigation.goBack();
|
navigation.goBack();
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
navigation.goBack();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -299,13 +311,17 @@ const SearchScreen = () => {
|
||||||
|
|
||||||
const saveRecentSearch = async (searchQuery: string) => {
|
const saveRecentSearch = async (searchQuery: string) => {
|
||||||
try {
|
try {
|
||||||
|
setRecentSearches(prevSearches => {
|
||||||
const newRecentSearches = [
|
const newRecentSearches = [
|
||||||
searchQuery,
|
searchQuery,
|
||||||
...recentSearches.filter(s => s !== searchQuery)
|
...prevSearches.filter(s => s !== searchQuery)
|
||||||
].slice(0, MAX_RECENT_SEARCHES);
|
].slice(0, MAX_RECENT_SEARCHES);
|
||||||
|
|
||||||
setRecentSearches(newRecentSearches);
|
// Save to AsyncStorage
|
||||||
await AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
|
AsyncStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
|
||||||
|
|
||||||
|
return newRecentSearches;
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to save recent search:', error);
|
logger.error('Failed to save recent search:', error);
|
||||||
}
|
}
|
||||||
|
|
@ -320,34 +336,50 @@ const SearchScreen = () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
logger.info('Performing search for:', searchQuery);
|
||||||
const searchResults = await catalogService.searchContentCinemeta(searchQuery);
|
const searchResults = await catalogService.searchContentCinemeta(searchQuery);
|
||||||
setResults(searchResults);
|
setResults(searchResults);
|
||||||
if (searchResults.length > 0) {
|
if (searchResults.length > 0) {
|
||||||
await saveRecentSearch(searchQuery);
|
await saveRecentSearch(searchQuery);
|
||||||
}
|
}
|
||||||
|
logger.info('Search completed, found', searchResults.length, 'results');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Search failed:', error);
|
logger.error('Search failed:', error);
|
||||||
setResults([]);
|
setResults([]);
|
||||||
} finally {
|
} finally {
|
||||||
setSearching(false);
|
setSearching(false);
|
||||||
}
|
}
|
||||||
}, 200),
|
}, 800),
|
||||||
[recentSearches]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (query.trim()) {
|
if (query.trim() && query.trim().length >= 2) {
|
||||||
setSearching(true);
|
setSearching(true);
|
||||||
setSearched(true);
|
setSearched(true);
|
||||||
setShowRecent(false);
|
setShowRecent(false);
|
||||||
debouncedSearch(query);
|
debouncedSearch(query);
|
||||||
|
} else if (query.trim().length < 2 && query.trim().length > 0) {
|
||||||
|
// Show that we're waiting for more characters
|
||||||
|
setSearching(false);
|
||||||
|
setSearched(false);
|
||||||
|
setShowRecent(false);
|
||||||
|
setResults([]);
|
||||||
} else {
|
} else {
|
||||||
|
// Cancel any pending search when query is cleared
|
||||||
|
debouncedSearch.cancel();
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setSearched(false);
|
setSearched(false);
|
||||||
|
setSearching(false);
|
||||||
setShowRecent(true);
|
setShowRecent(true);
|
||||||
loadRecentSearches();
|
loadRecentSearches();
|
||||||
}
|
}
|
||||||
}, [query]);
|
|
||||||
|
// Cleanup function to cancel pending searches
|
||||||
|
return () => {
|
||||||
|
debouncedSearch.cancel();
|
||||||
|
};
|
||||||
|
}, [query, debouncedSearch]);
|
||||||
|
|
||||||
const handleClearSearch = () => {
|
const handleClearSearch = () => {
|
||||||
setQuery('');
|
setQuery('');
|
||||||
|
|
@ -472,7 +504,14 @@ const SearchScreen = () => {
|
||||||
const headerHeight = headerBaseHeight + topSpacing + 60;
|
const headerHeight = headerBaseHeight + topSpacing + 60;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<Animated.View
|
||||||
|
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
||||||
|
entering={Platform.OS === 'android' ? SlideInRight.duration(250) : FadeIn.duration(350)}
|
||||||
|
exiting={Platform.OS === 'android' ?
|
||||||
|
FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
|
||||||
|
FadeOut.duration(250)
|
||||||
|
}
|
||||||
|
>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
barStyle="light-content"
|
barStyle="light-content"
|
||||||
backgroundColor="transparent"
|
backgroundColor="transparent"
|
||||||
|
|
@ -544,6 +583,23 @@ const SearchScreen = () => {
|
||||||
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<SimpleSearchAnimation />
|
<SimpleSearchAnimation />
|
||||||
|
) : query.trim().length === 1 ? (
|
||||||
|
<Animated.View
|
||||||
|
style={styles.emptyContainer}
|
||||||
|
entering={FadeIn.duration(300)}
|
||||||
|
>
|
||||||
|
<MaterialIcons
|
||||||
|
name="search"
|
||||||
|
size={64}
|
||||||
|
color={currentTheme.colors.lightGray}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||||
|
Keep typing...
|
||||||
|
</Text>
|
||||||
|
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||||
|
Type at least 2 characters to search
|
||||||
|
</Text>
|
||||||
|
</Animated.View>
|
||||||
) : searched && !hasResultsToShow ? (
|
) : searched && !hasResultsToShow ? (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={styles.emptyContainer}
|
style={styles.emptyContainer}
|
||||||
|
|
@ -614,7 +670,7 @@ const SearchScreen = () => {
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -710,7 +766,7 @@ const styles = StyleSheet.create({
|
||||||
horizontalItemPosterContainer: {
|
horizontalItemPosterContainer: {
|
||||||
width: HORIZONTAL_ITEM_WIDTH,
|
width: HORIZONTAL_ITEM_WIDTH,
|
||||||
height: HORIZONTAL_POSTER_HEIGHT,
|
height: HORIZONTAL_POSTER_HEIGHT,
|
||||||
borderRadius: 12,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
|
|
|
||||||
|
|
@ -367,6 +367,51 @@ const SettingsScreen: React.FC = () => {
|
||||||
icon="palette"
|
icon="palette"
|
||||||
renderControl={ChevronRight}
|
renderControl={ChevronRight}
|
||||||
onPress={() => navigation.navigate('ThemeSettings')}
|
onPress={() => navigation.navigate('ThemeSettings')}
|
||||||
|
/>
|
||||||
|
<SettingItem
|
||||||
|
title="Episode Layout"
|
||||||
|
description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
|
||||||
|
icon="view-module"
|
||||||
|
renderControl={() => (
|
||||||
|
<View style={styles.selectorContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.selectorButton,
|
||||||
|
settings.episodeLayoutStyle === 'vertical' && {
|
||||||
|
backgroundColor: currentTheme.colors.primary
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={() => updateSetting('episodeLayoutStyle', 'vertical')}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.selectorText,
|
||||||
|
{ color: currentTheme.colors.mediumEmphasis },
|
||||||
|
settings.episodeLayoutStyle === 'vertical' && {
|
||||||
|
color: currentTheme.colors.white,
|
||||||
|
fontWeight: '600'
|
||||||
|
}
|
||||||
|
]}>Vertical</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.selectorButton,
|
||||||
|
settings.episodeLayoutStyle === 'horizontal' && {
|
||||||
|
backgroundColor: currentTheme.colors.primary
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
onPress={() => updateSetting('episodeLayoutStyle', 'horizontal')}
|
||||||
|
>
|
||||||
|
<Text style={[
|
||||||
|
styles.selectorText,
|
||||||
|
{ color: currentTheme.colors.mediumEmphasis },
|
||||||
|
settings.episodeLayoutStyle === 'horizontal' && {
|
||||||
|
color: currentTheme.colors.white,
|
||||||
|
fontWeight: '600'
|
||||||
|
}
|
||||||
|
]}>Horizontal</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
isLast={true}
|
isLast={true}
|
||||||
/>
|
/>
|
||||||
</SettingsCard>
|
</SettingsCard>
|
||||||
|
|
@ -406,6 +451,13 @@ const SettingsScreen: React.FC = () => {
|
||||||
onPress={() => navigation.navigate('CatalogSettings')}
|
onPress={() => navigation.navigate('CatalogSettings')}
|
||||||
badge={catalogCount}
|
badge={catalogCount}
|
||||||
/>
|
/>
|
||||||
|
<SettingItem
|
||||||
|
title="Internal Providers"
|
||||||
|
description="Enable or disable built-in providers like HDRezka"
|
||||||
|
icon="source"
|
||||||
|
renderControl={ChevronRight}
|
||||||
|
onPress={() => navigation.navigate('InternalProvidersSettings')}
|
||||||
|
/>
|
||||||
<SettingItem
|
<SettingItem
|
||||||
title="Home Screen"
|
title="Home Screen"
|
||||||
description="Customize layout and content"
|
description="Customize layout and content"
|
||||||
|
|
@ -545,7 +597,7 @@ const styles = StyleSheet.create({
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
width: '100%',
|
width: '100%',
|
||||||
paddingBottom: 32,
|
paddingBottom: 90,
|
||||||
},
|
},
|
||||||
cardContainer: {
|
cardContainer: {
|
||||||
width: '100%',
|
width: '100%',
|
||||||
|
|
@ -645,19 +697,20 @@ const styles = StyleSheet.create({
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
height: 36,
|
height: 36,
|
||||||
width: 160,
|
width: 180,
|
||||||
marginRight: 8,
|
marginRight: 8,
|
||||||
},
|
},
|
||||||
selectorButton: {
|
selectorButton: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 12,
|
paddingHorizontal: 8,
|
||||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
},
|
},
|
||||||
selectorText: {
|
selectorText: {
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
profileLockContainer: {
|
profileLockContainer: {
|
||||||
padding: 16,
|
padding: 16,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -26,10 +26,10 @@ import { tmdbService } from '../services/tmdbService';
|
||||||
import { useSettings } from '../hooks/useSettings';
|
import { useSettings } from '../hooks/useSettings';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
|
const TMDB_API_KEY_STORAGE_KEY = 'tmdb_api_key';
|
||||||
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
|
const USE_CUSTOM_TMDB_API_KEY = 'use_custom_tmdb_api_key';
|
||||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
|
||||||
|
|
||||||
const TMDBSettingsScreen = () => {
|
const TMDBSettingsScreen = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
|
|
@ -41,6 +41,7 @@ const TMDBSettingsScreen = () => {
|
||||||
const [isInputFocused, setIsInputFocused] = useState(false);
|
const [isInputFocused, setIsInputFocused] = useState(false);
|
||||||
const apiKeyInputRef = useRef<TextInput>(null);
|
const apiKeyInputRef = useRef<TextInput>(null);
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
logger.log('[TMDBSettingsScreen] Component mounted');
|
logger.log('[TMDBSettingsScreen] Component mounted');
|
||||||
|
|
@ -115,13 +116,12 @@ const TMDBSettingsScreen = () => {
|
||||||
|
|
||||||
const testApiKey = async (key: string): Promise<boolean> => {
|
const testApiKey = async (key: string): Promise<boolean> => {
|
||||||
try {
|
try {
|
||||||
// Simple API call to test the key
|
// Simple API call to test the key using the API key parameter method
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
'https://api.themoviedb.org/3/configuration',
|
`https://api.themoviedb.org/3/configuration?api_key=${key}`,
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
headers: {
|
headers: {
|
||||||
'Authorization': `Bearer ${key}`,
|
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -223,277 +223,66 @@ const TMDBSettingsScreen = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||||
container: {
|
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
|
||||||
flex: 1,
|
const headerHeight = headerBaseHeight + topSpacing;
|
||||||
backgroundColor: currentTheme.colors.darkBackground,
|
|
||||||
},
|
|
||||||
loadingContainer: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
loadingText: {
|
|
||||||
marginTop: 12,
|
|
||||||
fontSize: 16,
|
|
||||||
color: currentTheme.colors.white,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 8 : 8,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingBottom: 16,
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
backText: {
|
|
||||||
color: currentTheme.colors.primary,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
paddingBottom: 40,
|
|
||||||
},
|
|
||||||
titleContainer: {
|
|
||||||
paddingTop: 8,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: 'bold',
|
|
||||||
color: currentTheme.colors.white,
|
|
||||||
marginHorizontal: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
switchCard: {
|
|
||||||
backgroundColor: currentTheme.colors.elevation2,
|
|
||||||
borderRadius: 12,
|
|
||||||
marginHorizontal: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
padding: 16,
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
switchTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
switchTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: currentTheme.colors.white,
|
|
||||||
},
|
|
||||||
switchDescription: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: currentTheme.colors.mediumEmphasis,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
statusCard: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
backgroundColor: currentTheme.colors.elevation2,
|
|
||||||
borderRadius: 12,
|
|
||||||
marginHorizontal: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
statusIconContainer: {
|
|
||||||
marginRight: 12,
|
|
||||||
},
|
|
||||||
statusTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
statusTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: currentTheme.colors.white,
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
statusDescription: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: currentTheme.colors.mediumEmphasis,
|
|
||||||
},
|
|
||||||
card: {
|
|
||||||
backgroundColor: currentTheme.colors.elevation2,
|
|
||||||
borderRadius: 12,
|
|
||||||
marginHorizontal: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
padding: 16,
|
|
||||||
},
|
|
||||||
cardTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: currentTheme.colors.white,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
inputContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: currentTheme.colors.elevation1,
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingHorizontal: 12,
|
|
||||||
paddingVertical: 10,
|
|
||||||
color: currentTheme.colors.white,
|
|
||||||
fontSize: 15,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
},
|
|
||||||
inputFocused: {
|
|
||||||
borderColor: currentTheme.colors.primary,
|
|
||||||
},
|
|
||||||
pasteButton: {
|
|
||||||
position: 'absolute',
|
|
||||||
right: 8,
|
|
||||||
padding: 4,
|
|
||||||
},
|
|
||||||
buttonRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
},
|
|
||||||
button: {
|
|
||||||
backgroundColor: currentTheme.colors.primary,
|
|
||||||
borderRadius: 8,
|
|
||||||
paddingVertical: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: 1,
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
clearButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: currentTheme.colors.error,
|
|
||||||
marginRight: 0,
|
|
||||||
marginLeft: 8,
|
|
||||||
flex: 0,
|
|
||||||
},
|
|
||||||
buttonText: {
|
|
||||||
color: currentTheme.colors.white,
|
|
||||||
fontWeight: '500',
|
|
||||||
fontSize: 15,
|
|
||||||
},
|
|
||||||
clearButtonText: {
|
|
||||||
color: currentTheme.colors.error,
|
|
||||||
},
|
|
||||||
resultMessage: {
|
|
||||||
borderRadius: 8,
|
|
||||||
padding: 12,
|
|
||||||
marginTop: 16,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
successMessage: {
|
|
||||||
backgroundColor: currentTheme.colors.success + '1A', // 10% opacity
|
|
||||||
},
|
|
||||||
errorMessage: {
|
|
||||||
backgroundColor: currentTheme.colors.error + '1A', // 10% opacity
|
|
||||||
},
|
|
||||||
resultIcon: {
|
|
||||||
marginRight: 8,
|
|
||||||
},
|
|
||||||
resultText: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
successText: {
|
|
||||||
color: currentTheme.colors.success,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
color: currentTheme.colors.error,
|
|
||||||
},
|
|
||||||
helpLink: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
helpIcon: {
|
|
||||||
marginRight: 4,
|
|
||||||
},
|
|
||||||
helpText: {
|
|
||||||
color: currentTheme.colors.primary,
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
infoCard: {
|
|
||||||
backgroundColor: currentTheme.colors.elevation1,
|
|
||||||
borderRadius: 12,
|
|
||||||
marginHorizontal: 16,
|
|
||||||
marginBottom: 16,
|
|
||||||
padding: 16,
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
},
|
|
||||||
infoIcon: {
|
|
||||||
marginRight: 8,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
infoText: {
|
|
||||||
color: currentTheme.colors.mediumEmphasis,
|
|
||||||
fontSize: 14,
|
|
||||||
flex: 1,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar barStyle="light-content" />
|
<StatusBar barStyle="light-content" />
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||||
<Text style={styles.loadingText}>Loading Settings...</Text>
|
<Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>Loading Settings...</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</SafeAreaView>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||||
<StatusBar barStyle="light-content" />
|
<StatusBar barStyle="light-content" />
|
||||||
|
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
onPress={() => navigation.goBack()}
|
onPress={() => navigation.goBack()}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
|
<MaterialIcons name="chevron-left" size={28} color={currentTheme.colors.primary} />
|
||||||
<Text style={styles.backText}>Settings</Text>
|
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.title}>TMDb Settings</Text>
|
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
||||||
|
TMDb Settings
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scrollView}
|
style={styles.scrollView}
|
||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<View style={styles.switchCard}>
|
<View style={[styles.switchCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||||
<View style={styles.switchTextContainer}>
|
<View style={styles.switchTextContainer}>
|
||||||
<Text style={styles.switchTitle}>Use Custom TMDb API Key</Text>
|
<Text style={[styles.switchTitle, { color: currentTheme.colors.text }]}>Use Custom TMDb API Key</Text>
|
||||||
|
<Text style={[styles.switchDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
|
Enable to use your own TMDb API key instead of the built-in one.
|
||||||
|
Using your own API key may provide better performance and higher rate limits.
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={useCustomKey}
|
value={useCustomKey}
|
||||||
onValueChange={toggleUseCustomKey}
|
onValueChange={toggleUseCustomKey}
|
||||||
trackColor={{ false: currentTheme.colors.lightGray, true: currentTheme.colors.accentLight }}
|
trackColor={{ false: 'rgba(255,255,255,0.1)', true: currentTheme.colors.primary }}
|
||||||
thumbColor={Platform.OS === 'android' ? currentTheme.colors.primary : ''}
|
thumbColor={Platform.OS === 'android' ? (useCustomKey ? currentTheme.colors.white : currentTheme.colors.white) : ''}
|
||||||
ios_backgroundColor={currentTheme.colors.lightGray}
|
ios_backgroundColor={'rgba(255,255,255,0.1)'}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.switchDescription}>
|
|
||||||
Enable to use your own TMDb API key instead of the built-in one.
|
|
||||||
Using your own API key may provide better performance and higher rate limits.
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{useCustomKey && (
|
{useCustomKey && (
|
||||||
<>
|
<>
|
||||||
<View style={styles.statusCard}>
|
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name={isKeySet ? "check-circle" : "error-outline"}
|
name={isKeySet ? "check-circle" : "error-outline"}
|
||||||
size={28}
|
size={28}
|
||||||
|
|
@ -501,10 +290,10 @@ const TMDBSettingsScreen = () => {
|
||||||
style={styles.statusIconContainer}
|
style={styles.statusIconContainer}
|
||||||
/>
|
/>
|
||||||
<View style={styles.statusTextContainer}>
|
<View style={styles.statusTextContainer}>
|
||||||
<Text style={styles.statusTitle}>
|
<Text style={[styles.statusTitle, { color: currentTheme.colors.text }]}>
|
||||||
{isKeySet ? "API Key Active" : "API Key Required"}
|
{isKeySet ? "API Key Active" : "API Key Required"}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.statusDescription}>
|
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
{isKeySet
|
{isKeySet
|
||||||
? "Your custom TMDb API key is set and active."
|
? "Your custom TMDb API key is set and active."
|
||||||
: "Add your TMDb API key below."}
|
: "Add your TMDb API key below."}
|
||||||
|
|
@ -512,19 +301,26 @@ const TMDBSettingsScreen = () => {
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.card}>
|
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||||
<Text style={styles.cardTitle}>API Key</Text>
|
<Text style={[styles.cardTitle, { color: currentTheme.colors.text }]}>API Key</Text>
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<TextInput
|
<TextInput
|
||||||
ref={apiKeyInputRef}
|
ref={apiKeyInputRef}
|
||||||
style={[styles.input, isInputFocused && styles.inputFocused]}
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{
|
||||||
|
backgroundColor: currentTheme.colors.elevation1,
|
||||||
|
color: currentTheme.colors.text,
|
||||||
|
borderColor: isInputFocused ? currentTheme.colors.primary : 'transparent'
|
||||||
|
}
|
||||||
|
]}
|
||||||
value={apiKey}
|
value={apiKey}
|
||||||
onChangeText={(text) => {
|
onChangeText={(text) => {
|
||||||
setApiKey(text);
|
setApiKey(text);
|
||||||
if (testResult) setTestResult(null);
|
if (testResult) setTestResult(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Paste your TMDb API key (v4 auth)"
|
placeholder="Paste your TMDb API key (v3)"
|
||||||
placeholderTextColor={currentTheme.colors.mediumGray}
|
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
|
|
@ -541,18 +337,18 @@ const TMDBSettingsScreen = () => {
|
||||||
|
|
||||||
<View style={styles.buttonRow}>
|
<View style={styles.buttonRow}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.button}
|
style={[styles.button, { backgroundColor: currentTheme.colors.primary }]}
|
||||||
onPress={saveApiKey}
|
onPress={saveApiKey}
|
||||||
>
|
>
|
||||||
<Text style={styles.buttonText}>Save API Key</Text>
|
<Text style={[styles.buttonText, { color: currentTheme.colors.white }]}>Save API Key</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{isKeySet && (
|
{isKeySet && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.button, styles.clearButton]}
|
style={[styles.button, styles.clearButton, { borderColor: currentTheme.colors.error }]}
|
||||||
onPress={clearApiKey}
|
onPress={clearApiKey}
|
||||||
>
|
>
|
||||||
<Text style={[styles.buttonText, styles.clearButtonText]}>Clear</Text>
|
<Text style={[styles.buttonText, { color: currentTheme.colors.error }]}>Clear</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -560,7 +356,7 @@ const TMDBSettingsScreen = () => {
|
||||||
{testResult && (
|
{testResult && (
|
||||||
<View style={[
|
<View style={[
|
||||||
styles.resultMessage,
|
styles.resultMessage,
|
||||||
testResult.success ? styles.successMessage : styles.errorMessage
|
{ backgroundColor: testResult.success ? currentTheme.colors.success + '1A' : currentTheme.colors.error + '1A' }
|
||||||
]}>
|
]}>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name={testResult.success ? "check-circle" : "error"}
|
name={testResult.success ? "check-circle" : "error"}
|
||||||
|
|
@ -570,7 +366,7 @@ const TMDBSettingsScreen = () => {
|
||||||
/>
|
/>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.resultText,
|
styles.resultText,
|
||||||
testResult.success ? styles.successText : styles.errorText
|
{ color: testResult.success ? currentTheme.colors.success : currentTheme.colors.error }
|
||||||
]}>
|
]}>
|
||||||
{testResult.message}
|
{testResult.message}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
@ -582,16 +378,16 @@ const TMDBSettingsScreen = () => {
|
||||||
onPress={openTMDBWebsite}
|
onPress={openTMDBWebsite}
|
||||||
>
|
>
|
||||||
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
|
<MaterialIcons name="help" size={16} color={currentTheme.colors.primary} style={styles.helpIcon} />
|
||||||
<Text style={styles.helpText}>
|
<Text style={[styles.helpText, { color: currentTheme.colors.primary }]}>
|
||||||
How to get a TMDb API key?
|
How to get a TMDb API key?
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.infoCard}>
|
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||||
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
|
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
|
||||||
<Text style={styles.infoText}>
|
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
To get your own TMDb API key (v4 auth token), you need to create a TMDb account and request an API key from their website.
|
To get your own TMDb API key (v3), you need to create a TMDb account and request an API key from their website.
|
||||||
Using your own API key gives you dedicated quota and may improve app performance.
|
Using your own API key gives you dedicated quota and may improve app performance.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
@ -599,17 +395,226 @@ const TMDBSettingsScreen = () => {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!useCustomKey && (
|
{!useCustomKey && (
|
||||||
<View style={styles.infoCard}>
|
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||||
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
|
<MaterialIcons name="info-outline" size={22} color={currentTheme.colors.primary} style={styles.infoIcon} />
|
||||||
<Text style={styles.infoText}>
|
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
Currently using the built-in TMDb API key. This key is shared among all users.
|
Currently using the built-in TMDb API key. This key is shared among all users.
|
||||||
For better performance and reliability, consider using your own API key.
|
For better performance and reliability, consider using your own API key.
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</SafeAreaView>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
},
|
||||||
|
headerContainer: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingBottom: 8,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
zIndex: 2,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 12,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
backText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: '800',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
paddingLeft: 4,
|
||||||
|
},
|
||||||
|
scrollView: {
|
||||||
|
flex: 1,
|
||||||
|
zIndex: 1,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
switchCard: {
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
switchTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 16,
|
||||||
|
},
|
||||||
|
switchTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
switchDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
statusCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
statusIconContainer: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
statusTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
statusTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
},
|
||||||
|
statusDescription: {
|
||||||
|
fontSize: 14,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 14,
|
||||||
|
fontSize: 15,
|
||||||
|
borderWidth: 2,
|
||||||
|
},
|
||||||
|
pasteButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 12,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
buttonRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
borderRadius: 12,
|
||||||
|
paddingVertical: 14,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
flex: 1,
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
marginRight: 0,
|
||||||
|
marginLeft: 8,
|
||||||
|
flex: 0,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: 15,
|
||||||
|
},
|
||||||
|
resultMessage: {
|
||||||
|
borderRadius: 12,
|
||||||
|
padding: 16,
|
||||||
|
marginTop: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
resultIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
resultText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
helpLink: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
helpIcon: {
|
||||||
|
marginRight: 8,
|
||||||
|
},
|
||||||
|
helpText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
infoCard: {
|
||||||
|
borderRadius: 16,
|
||||||
|
marginBottom: 16,
|
||||||
|
padding: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 1,
|
||||||
|
},
|
||||||
|
infoIcon: {
|
||||||
|
marginRight: 12,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
fontSize: 14,
|
||||||
|
flex: 1,
|
||||||
|
lineHeight: 20,
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export default TMDBSettingsScreen;
|
export default TMDBSettingsScreen;
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
Platform,
|
Platform,
|
||||||
|
Linking,
|
||||||
|
Switch,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session';
|
import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session';
|
||||||
|
|
@ -20,6 +22,9 @@ import { useSettings } from '../hooks/useSettings';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import TraktIcon from '../../assets/rating-icons/trakt.svg';
|
import TraktIcon from '../../assets/rating-icons/trakt.svg';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { useTraktIntegration } from '../hooks/useTraktIntegration';
|
||||||
|
import { useTraktAutosyncSettings } from '../hooks/useTraktAutosyncSettings';
|
||||||
|
import { colors } from '../styles';
|
||||||
|
|
||||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||||
|
|
||||||
|
|
@ -45,6 +50,21 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
|
const [userProfile, setUserProfile] = useState<TraktUser | null>(null);
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
||||||
|
const {
|
||||||
|
settings: autosyncSettings,
|
||||||
|
isSyncing,
|
||||||
|
setAutosyncEnabled,
|
||||||
|
performManualSync
|
||||||
|
} = useTraktAutosyncSettings();
|
||||||
|
|
||||||
|
const {
|
||||||
|
isLoading: traktLoading,
|
||||||
|
refreshAuthStatus
|
||||||
|
} = useTraktIntegration();
|
||||||
|
|
||||||
|
const [showSyncFrequencyModal, setShowSyncFrequencyModal] = useState(false);
|
||||||
|
const [showThresholdModal, setShowThresholdModal] = useState(false);
|
||||||
|
|
||||||
const checkAuthStatus = useCallback(async () => {
|
const checkAuthStatus = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
|
@ -308,6 +328,8 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
Sync Settings
|
Sync Settings
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.settingItem}>
|
<View style={styles.settingItem}>
|
||||||
|
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.settingLabel,
|
styles.settingLabel,
|
||||||
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
|
{ color: isDarkMode ? currentTheme.colors.highEmphasis : currentTheme.colors.textDark }
|
||||||
|
|
@ -318,9 +340,20 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
styles.settingDescription,
|
styles.settingDescription,
|
||||||
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
|
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
|
||||||
]}>
|
]}>
|
||||||
Coming soon
|
Automatically sync watch progress to Trakt
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={autosyncSettings.enabled}
|
||||||
|
onValueChange={setAutosyncEnabled}
|
||||||
|
trackColor={{
|
||||||
|
false: isDarkMode ? 'rgba(120,120,128,0.3)' : 'rgba(120,120,128,0.2)',
|
||||||
|
true: currentTheme.colors.primary + '80'
|
||||||
|
}}
|
||||||
|
thumbColor={autosyncSettings.enabled ? currentTheme.colors.primary : (isDarkMode ? '#ffffff' : '#f4f3f4')}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<View style={styles.settingItem}>
|
<View style={styles.settingItem}>
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.settingLabel,
|
styles.settingLabel,
|
||||||
|
|
@ -332,23 +365,43 @@ const TraktSettingsScreen: React.FC = () => {
|
||||||
styles.settingDescription,
|
styles.settingDescription,
|
||||||
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
|
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
|
||||||
]}>
|
]}>
|
||||||
Coming soon
|
Use "Sync Now" to import your watch history and progress from Trakt
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[
|
style={[
|
||||||
styles.button,
|
styles.button,
|
||||||
{ backgroundColor: isDarkMode ? 'rgba(120,120,128,0.2)' : 'rgba(120,120,128,0.1)' }
|
{
|
||||||
|
backgroundColor: isDarkMode ? currentTheme.colors.primary + '40' : currentTheme.colors.primary + '20',
|
||||||
|
opacity: isSyncing ? 0.6 : 1
|
||||||
|
}
|
||||||
]}
|
]}
|
||||||
disabled={true}
|
disabled={isSyncing}
|
||||||
|
onPress={async () => {
|
||||||
|
const success = await performManualSync();
|
||||||
|
Alert.alert(
|
||||||
|
'Sync Complete',
|
||||||
|
success ? 'Successfully synced your watch progress with Trakt.' : 'Sync failed. Please try again.',
|
||||||
|
[{ text: 'OK' }]
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{isSyncing ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<Text style={[
|
<Text style={[
|
||||||
styles.buttonText,
|
styles.buttonText,
|
||||||
{ color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }
|
{ color: isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary }
|
||||||
]}>
|
]}>
|
||||||
Sync Now (Coming Soon)
|
Sync Now
|
||||||
</Text>
|
</Text>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -54,6 +54,22 @@ export interface StreamingContent {
|
||||||
directors?: string[];
|
directors?: string[];
|
||||||
creators?: string[];
|
creators?: string[];
|
||||||
certification?: string;
|
certification?: string;
|
||||||
|
// Enhanced metadata from addons
|
||||||
|
country?: string;
|
||||||
|
writer?: string[];
|
||||||
|
links?: Array<{
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
behaviorHints?: {
|
||||||
|
defaultVideoId?: string;
|
||||||
|
hasScheduledVideos?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
imdb_id?: string;
|
||||||
|
slug?: string;
|
||||||
|
releaseInfo?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CatalogContent {
|
export interface CatalogContent {
|
||||||
|
|
@ -442,7 +458,7 @@ class CatalogService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getContentDetails(type: string, id: string): Promise<StreamingContent | null> {
|
async getContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
|
||||||
try {
|
try {
|
||||||
// Try up to 3 times with increasing delays
|
// Try up to 3 times with increasing delays
|
||||||
let meta = null;
|
let meta = null;
|
||||||
|
|
@ -450,7 +466,7 @@ class CatalogService {
|
||||||
|
|
||||||
for (let i = 0; i < 3; i++) {
|
for (let i = 0; i < 3; i++) {
|
||||||
try {
|
try {
|
||||||
meta = await stremioService.getMetaDetails(type, id);
|
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
|
||||||
if (meta) break;
|
if (meta) break;
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
|
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -461,8 +477,8 @@ class CatalogService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (meta) {
|
if (meta) {
|
||||||
// Add to recent content
|
// Add to recent content using enhanced conversion for full metadata
|
||||||
const content = this.convertMetaToStreamingContent(meta);
|
const content = this.convertMetaToStreamingContentEnhanced(meta);
|
||||||
this.addToRecentContent(content);
|
this.addToRecentContent(content);
|
||||||
|
|
||||||
// Check if it's in the library
|
// Check if it's in the library
|
||||||
|
|
@ -482,7 +498,54 @@ class CatalogService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public method for getting enhanced metadata details (used by MetadataScreen)
|
||||||
|
async getEnhancedContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
|
||||||
|
logger.log(`🔍 [MetadataScreen] Fetching enhanced metadata for ${type}:${id} ${preferredAddonId ? `from addon ${preferredAddonId}` : ''}`);
|
||||||
|
return this.getContentDetails(type, id, preferredAddonId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public method for getting basic content details without enhanced processing (used by ContinueWatching, etc.)
|
||||||
|
async getBasicContentDetails(type: string, id: string, preferredAddonId?: string): Promise<StreamingContent | null> {
|
||||||
|
try {
|
||||||
|
// Try up to 3 times with increasing delays
|
||||||
|
let meta = null;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
try {
|
||||||
|
meta = await stremioService.getMetaDetails(type, id, preferredAddonId);
|
||||||
|
if (meta) break;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
logger.error(`Attempt ${i + 1} failed to get basic content details for ${type}:${id}:`, error);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta) {
|
||||||
|
// Use basic conversion without enhanced metadata processing
|
||||||
|
const content = this.convertMetaToStreamingContent(meta);
|
||||||
|
|
||||||
|
// Check if it's in the library
|
||||||
|
content.inLibrary = this.library[`${type}:${id}`] !== undefined;
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastError) {
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get basic content details for ${type}:${id}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private convertMetaToStreamingContent(meta: Meta): StreamingContent {
|
private convertMetaToStreamingContent(meta: Meta): StreamingContent {
|
||||||
|
// Basic conversion for catalog display - no enhanced metadata processing
|
||||||
return {
|
return {
|
||||||
id: meta.id,
|
id: meta.id,
|
||||||
type: meta.type,
|
type: meta.type,
|
||||||
|
|
@ -490,17 +553,70 @@ class CatalogService {
|
||||||
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
|
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
|
||||||
posterShape: 'poster',
|
posterShape: 'poster',
|
||||||
banner: meta.background,
|
banner: meta.background,
|
||||||
logo: `https://images.metahub.space/logo/medium/${meta.id}/img`,
|
logo: `https://images.metahub.space/logo/medium/${meta.id}/img`, // Use metahub for catalog display
|
||||||
imdbRating: meta.imdbRating,
|
imdbRating: meta.imdbRating,
|
||||||
year: meta.year,
|
year: meta.year,
|
||||||
genres: meta.genres,
|
genres: meta.genres,
|
||||||
description: meta.description,
|
description: meta.description,
|
||||||
runtime: meta.runtime,
|
runtime: meta.runtime,
|
||||||
inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
|
inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
|
||||||
certification: meta.certification
|
certification: meta.certification,
|
||||||
|
releaseInfo: meta.releaseInfo,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced conversion for detailed metadata (used only when fetching individual content details)
|
||||||
|
private convertMetaToStreamingContentEnhanced(meta: Meta): StreamingContent {
|
||||||
|
// Enhanced conversion to utilize all available metadata from addons
|
||||||
|
const converted: StreamingContent = {
|
||||||
|
id: meta.id,
|
||||||
|
type: meta.type,
|
||||||
|
name: meta.name,
|
||||||
|
poster: meta.poster || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
|
||||||
|
posterShape: 'poster',
|
||||||
|
banner: meta.background,
|
||||||
|
// Use addon's logo if available, fallback to metahub
|
||||||
|
logo: (meta as any).logo || `https://images.metahub.space/logo/medium/${meta.id}/img`,
|
||||||
|
imdbRating: meta.imdbRating,
|
||||||
|
year: meta.year,
|
||||||
|
genres: meta.genres,
|
||||||
|
description: meta.description,
|
||||||
|
runtime: meta.runtime,
|
||||||
|
inLibrary: this.library[`${meta.type}:${meta.id}`] !== undefined,
|
||||||
|
certification: meta.certification,
|
||||||
|
// Enhanced fields from addon metadata
|
||||||
|
directors: (meta as any).director ?
|
||||||
|
(Array.isArray((meta as any).director) ? (meta as any).director : [(meta as any).director])
|
||||||
|
: undefined,
|
||||||
|
writer: (meta as any).writer || undefined,
|
||||||
|
country: (meta as any).country || undefined,
|
||||||
|
imdb_id: (meta as any).imdb_id || undefined,
|
||||||
|
slug: (meta as any).slug || undefined,
|
||||||
|
releaseInfo: meta.releaseInfo || (meta as any).releaseInfo || undefined,
|
||||||
|
trailerStreams: (meta as any).trailerStreams || undefined,
|
||||||
|
links: (meta as any).links || undefined,
|
||||||
|
behaviorHints: (meta as any).behaviorHints || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cast is handled separately by the dedicated CastSection component via TMDB
|
||||||
|
|
||||||
|
// Log if rich metadata is found
|
||||||
|
if ((meta as any).trailerStreams?.length > 0) {
|
||||||
|
logger.log(`🎬 Enhanced metadata: Found ${(meta as any).trailerStreams.length} trailers for ${meta.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((meta as any).links?.length > 0) {
|
||||||
|
logger.log(`🔗 Enhanced metadata: Found ${(meta as any).links.length} links for ${meta.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle videos/episodes if available
|
||||||
|
if ((meta as any).videos) {
|
||||||
|
converted.videos = (meta as any).videos;
|
||||||
|
}
|
||||||
|
|
||||||
|
return converted;
|
||||||
|
}
|
||||||
|
|
||||||
private notifyLibrarySubscribers(): void {
|
private notifyLibrarySubscribers(): void {
|
||||||
const items = Object.values(this.library);
|
const items = Object.values(this.library);
|
||||||
this.librarySubscribers.forEach(callback => callback(items));
|
this.librarySubscribers.forEach(callback => callback(items));
|
||||||
|
|
|
||||||
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;
|
currentTime: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
lastUpdated: number;
|
lastUpdated: number;
|
||||||
|
traktSynced?: boolean;
|
||||||
|
traktLastSynced?: number;
|
||||||
|
traktProgress?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
class StorageService {
|
class StorageService {
|
||||||
|
|
@ -103,6 +106,142 @@ class StorageService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Trakt sync status for a watch progress entry
|
||||||
|
*/
|
||||||
|
public async updateTraktSyncStatus(
|
||||||
|
id: string,
|
||||||
|
type: string,
|
||||||
|
traktSynced: boolean,
|
||||||
|
traktProgress?: number,
|
||||||
|
episodeId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const existingProgress = await this.getWatchProgress(id, type, episodeId);
|
||||||
|
if (existingProgress) {
|
||||||
|
const updatedProgress: WatchProgress = {
|
||||||
|
...existingProgress,
|
||||||
|
traktSynced,
|
||||||
|
traktLastSynced: traktSynced ? Date.now() : existingProgress.traktLastSynced,
|
||||||
|
traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress
|
||||||
|
};
|
||||||
|
await this.setWatchProgress(id, type, updatedProgress, episodeId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating Trakt sync status:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all watch progress entries that need Trakt sync
|
||||||
|
*/
|
||||||
|
public async getUnsyncedProgress(): Promise<Array<{
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
episodeId?: string;
|
||||||
|
progress: WatchProgress;
|
||||||
|
}>> {
|
||||||
|
try {
|
||||||
|
const allProgress = await this.getAllWatchProgress();
|
||||||
|
const unsynced: Array<{
|
||||||
|
key: string;
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
episodeId?: string;
|
||||||
|
progress: WatchProgress;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const [key, progress] of Object.entries(allProgress)) {
|
||||||
|
// Check if needs sync (either never synced or local progress is newer)
|
||||||
|
const needsSync = !progress.traktSynced ||
|
||||||
|
(progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced);
|
||||||
|
|
||||||
|
if (needsSync) {
|
||||||
|
const parts = key.split(':');
|
||||||
|
const type = parts[0];
|
||||||
|
const id = parts[1];
|
||||||
|
const episodeId = parts[2] || undefined;
|
||||||
|
|
||||||
|
unsynced.push({
|
||||||
|
key,
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
episodeId,
|
||||||
|
progress
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return unsynced;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error getting unsynced progress:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge Trakt progress with local progress
|
||||||
|
*/
|
||||||
|
public async mergeWithTraktProgress(
|
||||||
|
id: string,
|
||||||
|
type: string,
|
||||||
|
traktProgress: number,
|
||||||
|
traktPausedAt: string,
|
||||||
|
episodeId?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const localProgress = await this.getWatchProgress(id, type, episodeId);
|
||||||
|
const traktTimestamp = new Date(traktPausedAt).getTime();
|
||||||
|
|
||||||
|
if (!localProgress) {
|
||||||
|
// No local progress, use Trakt data (estimate duration)
|
||||||
|
const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; // Default 1 hour
|
||||||
|
const newProgress: WatchProgress = {
|
||||||
|
currentTime: (traktProgress / 100) * estimatedDuration,
|
||||||
|
duration: estimatedDuration,
|
||||||
|
lastUpdated: traktTimestamp,
|
||||||
|
traktSynced: true,
|
||||||
|
traktLastSynced: Date.now(),
|
||||||
|
traktProgress
|
||||||
|
};
|
||||||
|
await this.setWatchProgress(id, type, newProgress, episodeId);
|
||||||
|
} else {
|
||||||
|
// Always prioritize Trakt progress when merging
|
||||||
|
const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100;
|
||||||
|
|
||||||
|
if (localProgress.duration > 0) {
|
||||||
|
// Use Trakt progress, keeping the existing duration
|
||||||
|
const updatedProgress: WatchProgress = {
|
||||||
|
...localProgress,
|
||||||
|
currentTime: (traktProgress / 100) * localProgress.duration,
|
||||||
|
lastUpdated: traktTimestamp,
|
||||||
|
traktSynced: true,
|
||||||
|
traktLastSynced: Date.now(),
|
||||||
|
traktProgress
|
||||||
|
};
|
||||||
|
await this.setWatchProgress(id, type, updatedProgress, episodeId);
|
||||||
|
logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%)`);
|
||||||
|
} else {
|
||||||
|
// If no duration, estimate it from Trakt progress
|
||||||
|
const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600;
|
||||||
|
const updatedProgress: WatchProgress = {
|
||||||
|
currentTime: (traktProgress / 100) * estimatedDuration,
|
||||||
|
duration: estimatedDuration,
|
||||||
|
lastUpdated: traktTimestamp,
|
||||||
|
traktSynced: true,
|
||||||
|
traktLastSynced: Date.now(),
|
||||||
|
traktProgress
|
||||||
|
};
|
||||||
|
await this.setWatchProgress(id, type, updatedProgress, episodeId);
|
||||||
|
logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%) - estimated duration`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error merging with Trakt progress:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const storageService = StorageService.getInstance();
|
export const storageService = StorageService.getInstance();
|
||||||
|
|
@ -26,9 +26,35 @@ export interface Meta {
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
runtime?: string;
|
runtime?: string;
|
||||||
cast?: string[];
|
cast?: string[];
|
||||||
director?: string;
|
director?: string | string[];
|
||||||
writer?: string;
|
writer?: string | string[];
|
||||||
certification?: string;
|
certification?: string;
|
||||||
|
// Extended fields available from some addons
|
||||||
|
country?: string;
|
||||||
|
imdb_id?: string;
|
||||||
|
slug?: string;
|
||||||
|
released?: string;
|
||||||
|
trailerStreams?: Array<{
|
||||||
|
title: string;
|
||||||
|
ytId: string;
|
||||||
|
}>;
|
||||||
|
links?: Array<{
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
behaviorHints?: {
|
||||||
|
defaultVideoId?: string;
|
||||||
|
hasScheduledVideos?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
app_extras?: {
|
||||||
|
cast?: Array<{
|
||||||
|
name: string;
|
||||||
|
character?: string;
|
||||||
|
photo?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Subtitle {
|
export interface Subtitle {
|
||||||
|
|
@ -379,17 +405,20 @@ class StremioService {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getAddonBaseURL(url: string): string {
|
private getAddonBaseURL(url: string): { baseUrl: string; queryParams?: string } {
|
||||||
// Remove trailing manifest.json if present
|
// Extract query parameters if they exist
|
||||||
let baseUrl = url.replace(/manifest\.json$/, '').replace(/\/$/, '');
|
const [baseUrl, queryString] = url.split('?');
|
||||||
|
|
||||||
|
// Remove trailing manifest.json and slashes
|
||||||
|
let cleanBaseUrl = baseUrl.replace(/manifest\.json$/, '').replace(/\/$/, '');
|
||||||
|
|
||||||
// Ensure URL has protocol
|
// Ensure URL has protocol
|
||||||
if (!baseUrl.startsWith('http')) {
|
if (!cleanBaseUrl.startsWith('http')) {
|
||||||
baseUrl = `https://${baseUrl}`;
|
cleanBaseUrl = `https://${cleanBaseUrl}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log('Addon base URL:', baseUrl);
|
logger.log('Addon base URL:', cleanBaseUrl, queryString ? `with query: ${queryString}` : '');
|
||||||
return baseUrl;
|
return { baseUrl: cleanBaseUrl, queryParams: queryString };
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
|
async getCatalog(manifest: Manifest, type: string, id: string, page = 1, filters: CatalogFilter[] = []): Promise<Meta[]> {
|
||||||
|
|
@ -412,14 +441,11 @@ class StremioService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log(`Cinemeta catalog request URL: ${url}`);
|
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url);
|
return await axios.get(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
|
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
|
||||||
logger.log(`Cinemeta returned ${response.data.metas.length} items`);
|
|
||||||
return response.data.metas;
|
return response.data.metas;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -431,7 +457,7 @@ class StremioService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = this.getAddonBaseURL(manifest.url);
|
const { baseUrl, queryParams } = this.getAddonBaseURL(manifest.url);
|
||||||
|
|
||||||
// Build the catalog URL
|
// Build the catalog URL
|
||||||
let url = `${baseUrl}/catalog/${type}/${id}.json`;
|
let url = `${baseUrl}/catalog/${type}/${id}.json`;
|
||||||
|
|
@ -450,14 +476,11 @@ class StremioService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.log(`${manifest.name} catalog request URL: ${url}`);
|
|
||||||
|
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url);
|
return await axios.get(url);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
|
if (response.data && response.data.metas && Array.isArray(response.data.metas)) {
|
||||||
logger.log(`${manifest.name} returned ${response.data.metas.length} items`);
|
|
||||||
return response.data.metas;
|
return response.data.metas;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
|
|
@ -467,8 +490,71 @@ class StremioService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMetaDetails(type: string, id: string): Promise<MetaDetails | null> {
|
async getMetaDetails(type: string, id: string, preferredAddonId?: string): Promise<MetaDetails | null> {
|
||||||
try {
|
try {
|
||||||
|
const addons = this.getInstalledAddons();
|
||||||
|
|
||||||
|
// If a preferred addon is specified, try it first
|
||||||
|
if (preferredAddonId) {
|
||||||
|
logger.log(`🎯 Trying preferred addon first: ${preferredAddonId}`);
|
||||||
|
const preferredAddon = addons.find(addon => addon.id === preferredAddonId);
|
||||||
|
|
||||||
|
if (preferredAddon && preferredAddon.resources) {
|
||||||
|
// Log what URL would be used for debugging
|
||||||
|
const { baseUrl, queryParams } = this.getAddonBaseURL(preferredAddon.url || '');
|
||||||
|
const wouldBeUrl = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`;
|
||||||
|
logger.log(`🔍 Would check URL: ${wouldBeUrl} (addon: ${preferredAddon.name})`);
|
||||||
|
|
||||||
|
// Log addon resources for debugging
|
||||||
|
logger.log(`🔍 Addon resources:`, JSON.stringify(preferredAddon.resources, null, 2));
|
||||||
|
|
||||||
|
// Check if addon supports meta resource for this type
|
||||||
|
let hasMetaSupport = false;
|
||||||
|
|
||||||
|
for (const resource of preferredAddon.resources) {
|
||||||
|
// Check if the current element is a ResourceObject
|
||||||
|
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
||||||
|
const typedResource = resource as ResourceObject;
|
||||||
|
if (typedResource.name === 'meta' &&
|
||||||
|
Array.isArray(typedResource.types) &&
|
||||||
|
typedResource.types.includes(type)) {
|
||||||
|
hasMetaSupport = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if the element is the simple string "meta" AND the addon has a top-level types array
|
||||||
|
else if (typeof resource === 'string' && resource === 'meta' && preferredAddon.types) {
|
||||||
|
if (Array.isArray(preferredAddon.types) && preferredAddon.types.includes(type)) {
|
||||||
|
hasMetaSupport = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.log(`🔍 Meta support check: ${hasMetaSupport} (addon types: ${JSON.stringify(preferredAddon.types)})`);
|
||||||
|
|
||||||
|
if (hasMetaSupport) {
|
||||||
|
try {
|
||||||
|
logger.log(`HTTP GET: ${wouldBeUrl} (preferred addon: ${preferredAddon.name})`);
|
||||||
|
const response = await this.retryRequest(async () => {
|
||||||
|
return await axios.get(wouldBeUrl, { timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.data && response.data.meta) {
|
||||||
|
logger.log(`✅ Metadata fetched successfully from preferred addon: ${wouldBeUrl}`);
|
||||||
|
return response.data.meta;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`❌ Failed to fetch meta from preferred addon ${preferredAddon.name}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Preferred addon ${preferredAddonId} does not support meta for type ${type}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.warn(`⚠️ Preferred addon ${preferredAddonId} not found or has no resources`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try Cinemeta with different base URLs
|
// Try Cinemeta with different base URLs
|
||||||
const cinemetaUrls = [
|
const cinemetaUrls = [
|
||||||
'https://v3-cinemeta.strem.io',
|
'https://v3-cinemeta.strem.io',
|
||||||
|
|
@ -478,44 +564,66 @@ class StremioService {
|
||||||
for (const baseUrl of cinemetaUrls) {
|
for (const baseUrl of cinemetaUrls) {
|
||||||
try {
|
try {
|
||||||
const url = `${baseUrl}/meta/${type}/${id}.json`;
|
const url = `${baseUrl}/meta/${type}/${id}.json`;
|
||||||
|
logger.log(`HTTP GET: ${url}`);
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url, { timeout: 10000 });
|
return await axios.get(url, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && response.data.meta) {
|
if (response.data && response.data.meta) {
|
||||||
|
logger.log(`✅ Metadata fetched successfully from: ${url}`);
|
||||||
return response.data.meta;
|
return response.data.meta;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to fetch meta from ${baseUrl}:`, error);
|
logger.warn(`❌ Failed to fetch meta from ${baseUrl}:`, error);
|
||||||
continue; // Try next URL
|
continue; // Try next URL
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If Cinemeta fails, try other addons
|
// If Cinemeta fails, try other addons (excluding the preferred one already tried)
|
||||||
const addons = this.getInstalledAddons();
|
|
||||||
|
|
||||||
for (const addon of addons) {
|
for (const addon of addons) {
|
||||||
if (!addon.resources || addon.id === 'com.linvo.cinemeta') continue;
|
if (!addon.resources || addon.id === 'com.linvo.cinemeta' || addon.id === preferredAddonId) continue;
|
||||||
|
|
||||||
const metaResource = addon.resources.find(
|
// Check if addon supports meta resource for this type (handles both string and object formats)
|
||||||
resource => resource.name === 'meta' && resource.types.includes(type)
|
let hasMetaSupport = false;
|
||||||
);
|
|
||||||
|
|
||||||
if (!metaResource) continue;
|
for (const resource of addon.resources) {
|
||||||
|
// Check if the current element is a ResourceObject
|
||||||
|
if (typeof resource === 'object' && resource !== null && 'name' in resource) {
|
||||||
|
const typedResource = resource as ResourceObject;
|
||||||
|
if (typedResource.name === 'meta' &&
|
||||||
|
Array.isArray(typedResource.types) &&
|
||||||
|
typedResource.types.includes(type)) {
|
||||||
|
hasMetaSupport = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check if the element is the simple string "meta" AND the addon has a top-level types array
|
||||||
|
else if (typeof resource === 'string' && resource === 'meta' && addon.types) {
|
||||||
|
if (Array.isArray(addon.types) && addon.types.includes(type)) {
|
||||||
|
hasMetaSupport = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMetaSupport) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = this.getAddonBaseURL(addon.url || '');
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url || '');
|
||||||
const url = `${baseUrl}/meta/${type}/${id}.json`;
|
const url = queryParams ? `${baseUrl}/meta/${type}/${id}.json?${queryParams}` : `${baseUrl}/meta/${type}/${id}.json`;
|
||||||
|
|
||||||
|
logger.log(`HTTP GET: ${url}`);
|
||||||
const response = await this.retryRequest(async () => {
|
const response = await this.retryRequest(async () => {
|
||||||
return await axios.get(url, { timeout: 10000 });
|
return await axios.get(url, { timeout: 10000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data && response.data.meta) {
|
if (response.data && response.data.meta) {
|
||||||
|
logger.log(`✅ Metadata fetched successfully from: ${url}`);
|
||||||
return response.data.meta;
|
return response.data.meta;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to fetch meta from ${addon.name}:`, error);
|
logger.warn(`❌ Failed to fetch meta from ${addon.name} (${addon.id}):`, error);
|
||||||
continue; // Try next addon
|
continue; // Try next addon
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -612,8 +720,8 @@ class StremioService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = this.getAddonBaseURL(addon.url);
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
|
||||||
const url = `${baseUrl}/stream/${type}/${id}.json`;
|
const url = queryParams ? `${baseUrl}/stream/${type}/${id}.json?${queryParams}` : `${baseUrl}/stream/${type}/${id}.json`;
|
||||||
|
|
||||||
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
|
logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}): ${url}`);
|
||||||
|
|
||||||
|
|
@ -656,8 +764,9 @@ class StremioService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = this.getAddonBaseURL(addon.url);
|
const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url);
|
||||||
const url = `${baseUrl}/stream/${type}/${id}.json`;
|
const streamPath = `/stream/${type}/${id}.json`;
|
||||||
|
const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`;
|
||||||
|
|
||||||
logger.log(`Fetching streams from URL: ${url}`);
|
logger.log(`Fetching streams from URL: ${url}`);
|
||||||
|
|
||||||
|
|
@ -671,7 +780,7 @@ class StremioService {
|
||||||
timeout,
|
timeout,
|
||||||
headers: {
|
headers: {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
|
'User-Agent': 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, 5); // Increase retries for stream fetching
|
}, 5); // Increase retries for stream fetching
|
||||||
|
|
@ -868,7 +977,7 @@ class StremioService {
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || '');
|
const baseUrl = this.getAddonBaseURL(openSubtitlesAddon.url || '').baseUrl;
|
||||||
|
|
||||||
// Construct the query URL with the correct format
|
// Construct the query URL with the correct format
|
||||||
// For series episodes, use the videoId directly which includes series ID + episode info
|
// For series episodes, use the videoId directly which includes series ID + episode info
|
||||||
|
|
@ -930,6 +1039,29 @@ class StremioService {
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if any installed addons can provide streams
|
||||||
|
async hasStreamProviders(): Promise<boolean> {
|
||||||
|
await this.ensureInitialized();
|
||||||
|
const addons = Array.from(this.installedAddons.values());
|
||||||
|
|
||||||
|
for (const addon of addons) {
|
||||||
|
if (addon.resources && Array.isArray(addon.resources)) {
|
||||||
|
// Check for 'stream' resource in the modern format
|
||||||
|
const hasStreamResource = addon.resources.some(resource =>
|
||||||
|
typeof resource === 'string'
|
||||||
|
? resource === 'stream'
|
||||||
|
: resource.name === 'stream'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasStreamResource) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const stremioService = StremioService.getInstance();
|
export const stremioService = StremioService.getInstance();
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
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';
|
import { StyleSheet, Dimensions, Platform } from 'react-native';
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
export const POSTER_WIDTH = (width - 50) / 3;
|
|
||||||
|
// Dynamic poster calculation based on screen width
|
||||||
|
const calculatePosterLayout = (screenWidth: number) => {
|
||||||
|
const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability
|
||||||
|
const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters
|
||||||
|
const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins
|
||||||
|
|
||||||
|
// Calculate how many posters can fit
|
||||||
|
const availableWidth = screenWidth - HORIZONTAL_PADDING;
|
||||||
|
const maxColumns = Math.floor(availableWidth / MIN_POSTER_WIDTH);
|
||||||
|
|
||||||
|
// Limit to reasonable number of columns (3-6)
|
||||||
|
const numColumns = Math.min(Math.max(maxColumns, 3), 6);
|
||||||
|
|
||||||
|
// Calculate actual poster width
|
||||||
|
const posterWidth = Math.min(availableWidth / numColumns, MAX_POSTER_WIDTH);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numColumns,
|
||||||
|
posterWidth,
|
||||||
|
spacing: 12 // Space between posters
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const posterLayout = calculatePosterLayout(width);
|
||||||
|
export const POSTER_WIDTH = posterLayout.posterWidth;
|
||||||
export const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
|
export const POSTER_HEIGHT = POSTER_WIDTH * 1.5;
|
||||||
export const HORIZONTAL_PADDING = 16;
|
export const HORIZONTAL_PADDING = 16;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ const useDiscoverStyles = () => {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: 80,
|
paddingTop: 80,
|
||||||
|
paddingBottom: 90,
|
||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
color: currentTheme.colors.mediumGray,
|
color: currentTheme.colors.mediumGray,
|
||||||
|
|
|
||||||
61
src/testHDRezka.js
Normal file
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;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
|
posterShape?: string;
|
||||||
banner?: string;
|
banner?: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
year?: string | number;
|
year?: string | number;
|
||||||
|
|
@ -88,12 +89,30 @@ export interface StreamingContent {
|
||||||
imdbRating?: string;
|
imdbRating?: string;
|
||||||
genres?: string[];
|
genres?: string[];
|
||||||
director?: string;
|
director?: string;
|
||||||
writer?: string;
|
writer?: string[];
|
||||||
cast?: string[];
|
cast?: string[];
|
||||||
releaseInfo?: string;
|
releaseInfo?: string;
|
||||||
directors?: string[];
|
directors?: string[];
|
||||||
creators?: string[];
|
creators?: string[];
|
||||||
certification?: string;
|
certification?: string;
|
||||||
|
released?: string;
|
||||||
|
trailerStreams?: any[];
|
||||||
|
videos?: any[];
|
||||||
|
inLibrary?: boolean;
|
||||||
|
// Enhanced metadata from addons
|
||||||
|
country?: string;
|
||||||
|
links?: Array<{
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
behaviorHints?: {
|
||||||
|
defaultVideoId?: string;
|
||||||
|
hasScheduledVideos?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
imdb_id?: string;
|
||||||
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Navigation types
|
// Navigation types
|
||||||
|
|
|
||||||
2
src/types/navigation.d.ts
vendored
2
src/types/navigation.d.ts
vendored
|
|
@ -6,6 +6,7 @@ export type RootStackParamList = {
|
||||||
Metadata: {
|
Metadata: {
|
||||||
id: string;
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
|
addonId?: string;
|
||||||
};
|
};
|
||||||
Streams: {
|
Streams: {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -27,6 +28,7 @@ export type RootStackParamList = {
|
||||||
url: string;
|
url: string;
|
||||||
lang: string;
|
lang: string;
|
||||||
}>;
|
}>;
|
||||||
|
imdbId?: string;
|
||||||
};
|
};
|
||||||
Catalog: {
|
Catalog: {
|
||||||
addonId?: string;
|
addonId?: string;
|
||||||
|
|
|
||||||
82
src/utils/posterUtils.ts
Normal file
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",
|
"extends": "expo/tsconfig.base",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"strict": true
|
"strict": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"esModuleInterop": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue