Compare commits
22 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4daab74e27 | ||
|
|
a7fbd567fd | ||
|
|
f90752bdb7 | ||
|
|
c5590639b1 | ||
|
|
098ab73ba1 | ||
|
|
060b0b927b | ||
|
|
786e06b27f | ||
|
|
ef1c34a9c0 | ||
|
|
b97481f2d9 | ||
|
|
8d74b7e7ce | ||
|
|
635c97b1ad | ||
|
|
673c96c917 | ||
|
|
15fc49d84d | ||
|
|
54cfd194f1 | ||
|
|
be561c6d9f | ||
|
|
dc8c27dfc4 | ||
|
|
ce7f92b540 | ||
|
|
f0271cd395 | ||
|
|
2a4c076854 | ||
|
|
c852c56231 | ||
|
|
614ffc12c0 | ||
|
|
d9b2545cdd |
31 changed files with 3714 additions and 992 deletions
4
.github/FUNDING.yml
vendored
Normal file
4
.github/FUNDING.yml
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
# These are supported funding model platforms
|
||||
|
||||
github: [tapframe]
|
||||
ko_fi: tapframe
|
||||
|
|
@ -30,6 +30,14 @@
|
|||
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.2.6",
|
||||
"buildVersion": "21",
|
||||
"date": "2025-10-19",
|
||||
"localizedDescription": "# Version 1.2.6 – Update Notes\n\n### New Features\n- **AirPlay Support (iOS):**\n - Introduced native AirPlay support for compatible playback.\n - Note: **MKV format not supported** due to iOS restrictions.\n- **Last Streamed Link Caching:**\n - The last streamed link is now cached for **1 hour** on the stream screen for faster playback.\n- **KSPlayer Internal Subtitle Support (iOS):**\n - KSPlayer now supports internal subtitles for improved viewing experience.\n\n### PR Merge – Responsive Video Controls by @qarqun\n- **Responsive Sizing:**\n - All controls now scale dynamically based on screen width for better phone and tablet compatibility.\n- **Play/Pause Animation:**\n - Smooth crossfade transitions with scale effects for a polished user experience.\n- **Seek Animations:**\n - Arc sweep animation on seek buttons.\n - Number slide-out showing +10/-10 seconds.\n - Touch feedback with semi-transparent circle flash.\n- **Technical Details:**\n - Button sizes calculated as a percentage of screen width.\n - All animations use `useNativeDriver` for optimal performance.\n - Separate animation references to prevent animation conflicts.\n\n### Fixes\n- Fixed an issue where **Cinemeta Addon** reappeared even after removal from the addon screen.\n\n---\n\n## Changelog & Download\n[View on GitHub](https://github.com/tapframe/NuvioStreaming/releases/tag/1.2.6)\n\nSince the VLClib alone is 45 MB, Android APKs tend to be larger.\n\n🌐 [Official Website](https://tapframe.github.io/NuvioStreaming/)",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.2.6/Stable_1-2-6.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"buildVersion": "20",
|
||||
|
|
|
|||
56
src/components/AnimatedImage.tsx
Normal file
56
src/components/AnimatedImage.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React, { memo, useEffect } from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming
|
||||
} from 'react-native-reanimated';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
||||
interface AnimatedImageProps {
|
||||
source: { uri: string } | undefined;
|
||||
style: any;
|
||||
contentFit: any;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
const AnimatedImage = memo(({
|
||||
source,
|
||||
style,
|
||||
contentFit,
|
||||
onLoad
|
||||
}: AnimatedImageProps) => {
|
||||
const opacity = useSharedValue(0);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
if (source?.uri) {
|
||||
opacity.value = withTiming(1, { duration: 300 });
|
||||
} else {
|
||||
opacity.value = 0;
|
||||
}
|
||||
}, [source?.uri]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
opacity.value = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, animatedStyle]}>
|
||||
<FastImage
|
||||
source={source}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={onLoad}
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnimatedImage;
|
||||
50
src/components/AnimatedText.tsx
Normal file
50
src/components/AnimatedText.tsx
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import React, { memo, useEffect } from 'react';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withDelay
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface AnimatedTextProps {
|
||||
children: React.ReactNode;
|
||||
style: any;
|
||||
delay?: number;
|
||||
numberOfLines?: number;
|
||||
}
|
||||
|
||||
const AnimatedText = memo(({
|
||||
children,
|
||||
style,
|
||||
delay = 0,
|
||||
numberOfLines
|
||||
}: AnimatedTextProps) => {
|
||||
const opacity = useSharedValue(0);
|
||||
const translateY = useSharedValue(20);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
|
||||
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
|
||||
}, [delay]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
opacity.value = 0;
|
||||
translateY.value = 20;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.Text style={[style, animatedStyle]} numberOfLines={numberOfLines}>
|
||||
{children}
|
||||
</Animated.Text>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnimatedText;
|
||||
48
src/components/AnimatedView.tsx
Normal file
48
src/components/AnimatedView.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import React, { memo, useEffect } from 'react';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withDelay
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
interface AnimatedViewProps {
|
||||
children: React.ReactNode;
|
||||
style?: any;
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
const AnimatedView = memo(({
|
||||
children,
|
||||
style,
|
||||
delay = 0
|
||||
}: AnimatedViewProps) => {
|
||||
const opacity = useSharedValue(0);
|
||||
const translateY = useSharedValue(20);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: opacity.value,
|
||||
transform: [{ translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
opacity.value = withDelay(delay, withTiming(1, { duration: 250 }));
|
||||
translateY.value = withDelay(delay, withTiming(0, { duration: 250 }));
|
||||
}, [delay]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
opacity.value = 0;
|
||||
translateY.value = 20;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View style={[style, animatedStyle]}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
export default AnimatedView;
|
||||
88
src/components/ProviderFilter.tsx
Normal file
88
src/components/ProviderFilter.tsx
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import React, { memo, useCallback } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, FlatList } from 'react-native';
|
||||
|
||||
interface ProviderFilterProps {
|
||||
selectedProvider: string;
|
||||
providers: Array<{ id: string; name: string; }>;
|
||||
onSelect: (id: string) => void;
|
||||
theme: any;
|
||||
}
|
||||
|
||||
const ProviderFilter = memo(({
|
||||
selectedProvider,
|
||||
providers,
|
||||
onSelect,
|
||||
theme
|
||||
}: ProviderFilterProps) => {
|
||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||
|
||||
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.filterChip,
|
||||
selectedProvider === item.id && styles.filterChipSelected
|
||||
]}
|
||||
onPress={() => onSelect(item.id)}
|
||||
>
|
||||
<Text style={[
|
||||
styles.filterChipText,
|
||||
selectedProvider === item.id && styles.filterChipTextSelected
|
||||
]}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
), [selectedProvider, onSelect, styles]);
|
||||
|
||||
return (
|
||||
<View>
|
||||
<FlatList
|
||||
data={providers}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={item => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.filterScroll}
|
||||
bounces={true}
|
||||
overScrollMode="never"
|
||||
decelerationRate="fast"
|
||||
initialNumToRender={5}
|
||||
maxToRenderPerBatch={3}
|
||||
windowSize={3}
|
||||
removeClippedSubviews={true}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: 100, // Approximate width of each item
|
||||
offset: 100 * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
filterScroll: {
|
||||
flexGrow: 0,
|
||||
},
|
||||
filterChip: {
|
||||
backgroundColor: colors.elevation2,
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
marginRight: 8,
|
||||
borderWidth: 0,
|
||||
},
|
||||
filterChipSelected: {
|
||||
backgroundColor: colors.primary,
|
||||
},
|
||||
filterChipText: {
|
||||
color: colors.highEmphasis,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
filterChipTextSelected: {
|
||||
color: colors.white,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default ProviderFilter;
|
||||
36
src/components/PulsingChip.tsx
Normal file
36
src/components/PulsingChip.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import React, { memo } from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
||||
interface PulsingChipProps {
|
||||
text: string;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
const PulsingChip = memo(({ text, delay }: PulsingChipProps) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const styles = React.useMemo(() => createStyles(currentTheme.colors), [currentTheme.colors]);
|
||||
// Make chip static to avoid continuous animation load
|
||||
return (
|
||||
<View style={styles.activeScraperChip}>
|
||||
<Text style={styles.activeScraperText}>{text}</Text>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
activeScraperChip: {
|
||||
backgroundColor: colors.elevation2,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
borderWidth: 0,
|
||||
},
|
||||
activeScraperText: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 11,
|
||||
fontWeight: '400',
|
||||
},
|
||||
});
|
||||
|
||||
export default PulsingChip;
|
||||
373
src/components/StreamCard.tsx
Normal file
373
src/components/StreamCard.tsx
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
import React, { memo, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Clipboard,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { Stream } from '../types/metadata';
|
||||
import QualityBadge from './metadata/QualityBadge';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { useDownloads } from '../contexts/DownloadsContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
|
||||
interface StreamCardProps {
|
||||
stream: Stream;
|
||||
onPress: () => void;
|
||||
index: number;
|
||||
isLoading?: boolean;
|
||||
statusMessage?: string;
|
||||
theme: any;
|
||||
showLogos?: boolean;
|
||||
scraperLogo?: string | null;
|
||||
showAlert: (title: string, message: string) => void;
|
||||
parentTitle?: string;
|
||||
parentType?: 'movie' | 'series';
|
||||
parentSeason?: number;
|
||||
parentEpisode?: number;
|
||||
parentEpisodeTitle?: string;
|
||||
parentPosterUrl?: string | null;
|
||||
providerName?: string;
|
||||
parentId?: string;
|
||||
parentImdbId?: string;
|
||||
}
|
||||
|
||||
const StreamCard = memo(({
|
||||
stream,
|
||||
onPress,
|
||||
index,
|
||||
isLoading,
|
||||
statusMessage,
|
||||
theme,
|
||||
showLogos,
|
||||
scraperLogo,
|
||||
showAlert,
|
||||
parentTitle,
|
||||
parentType,
|
||||
parentSeason,
|
||||
parentEpisode,
|
||||
parentEpisodeTitle,
|
||||
parentPosterUrl,
|
||||
providerName,
|
||||
parentId,
|
||||
parentImdbId
|
||||
}: StreamCardProps) => {
|
||||
const { settings } = useSettings();
|
||||
const { startDownload } = useDownloads();
|
||||
const { showSuccess, showInfo } = useToast();
|
||||
|
||||
// Handle long press to copy stream URL to clipboard
|
||||
const handleLongPress = useCallback(async () => {
|
||||
if (stream.url) {
|
||||
try {
|
||||
await Clipboard.setString(stream.url);
|
||||
|
||||
// Use toast for Android, custom alert for iOS
|
||||
if (Platform.OS === 'android') {
|
||||
showSuccess('URL Copied', 'Stream URL copied to clipboard!');
|
||||
} else {
|
||||
// iOS uses custom alert
|
||||
showAlert('Copied!', 'Stream URL has been copied to clipboard.');
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback: show URL in alert if clipboard fails
|
||||
if (Platform.OS === 'android') {
|
||||
showInfo('Stream URL', `Stream URL: ${stream.url}`);
|
||||
} else {
|
||||
showAlert('Stream URL', stream.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [stream.url, showAlert, showSuccess, showInfo]);
|
||||
|
||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||
|
||||
const streamInfo = useMemo(() => {
|
||||
const title = stream.title || '';
|
||||
const name = stream.name || '';
|
||||
|
||||
// Helper function to format size from bytes
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Get size from title (legacy format) or from stream.size field
|
||||
let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
|
||||
if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) {
|
||||
sizeDisplay = formatSize(stream.size);
|
||||
}
|
||||
|
||||
// Extract quality for badge display
|
||||
const basicQuality = title.match(/(\d+)p/)?.[1] || null;
|
||||
|
||||
return {
|
||||
quality: basicQuality,
|
||||
isHDR: title.toLowerCase().includes('hdr'),
|
||||
isDolby: title.toLowerCase().includes('dolby') || title.includes('DV'),
|
||||
size: sizeDisplay,
|
||||
isDebrid: stream.behaviorHints?.cached,
|
||||
displayName: name || 'Unnamed Stream',
|
||||
subTitle: title && title !== name ? title : null
|
||||
};
|
||||
}, [stream.name, stream.title, stream.behaviorHints, stream.size]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
try {
|
||||
const url = stream.url;
|
||||
if (!url) return;
|
||||
// Prevent duplicate downloads for the same exact URL
|
||||
try {
|
||||
const downloadsModule = require('../contexts/DownloadsContext');
|
||||
if (downloadsModule && downloadsModule.isDownloadingUrl && downloadsModule.isDownloadingUrl(url)) {
|
||||
showAlert('Already Downloading', 'This download has already started for this exact link.');
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
// Show immediate feedback on both platforms
|
||||
showAlert('Starting Download', 'Download will be started.');
|
||||
const parent: any = stream as any;
|
||||
const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
|
||||
const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
|
||||
const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number);
|
||||
const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number);
|
||||
const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name;
|
||||
// Prefer the stream's display name (often includes provider + resolution)
|
||||
const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
|
||||
|
||||
// Use parentId first (from route params), fallback to stream metadata
|
||||
const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
|
||||
|
||||
// Extract tmdbId if available (from parentId or parent metadata)
|
||||
let tmdbId: number | undefined = undefined;
|
||||
if (parentId && parentId.startsWith('tmdb:')) {
|
||||
tmdbId = parseInt(parentId.split(':')[1], 10);
|
||||
} else if (typeof parent.tmdbId === 'number') {
|
||||
tmdbId = parent.tmdbId;
|
||||
}
|
||||
|
||||
await startDownload({
|
||||
id: String(idForContent),
|
||||
type: inferredType,
|
||||
title: String(inferredTitle),
|
||||
providerName: String(provider),
|
||||
season: inferredType === 'series' ? (season ? Number(season) : undefined) : undefined,
|
||||
episode: inferredType === 'series' ? (episode ? Number(episode) : undefined) : undefined,
|
||||
episodeTitle: inferredType === 'series' ? (episodeTitle ? String(episodeTitle) : undefined) : undefined,
|
||||
quality: streamInfo.quality || undefined,
|
||||
posterUrl: parentPosterUrl || parent.poster || parent.backdrop || null,
|
||||
url,
|
||||
headers: (stream.headers as any) || undefined,
|
||||
// Pass metadata for progress tracking
|
||||
imdbId: parentImdbId || parent.imdbId || undefined,
|
||||
tmdbId: tmdbId,
|
||||
});
|
||||
showAlert('Download Started', 'Your download has been added to the queue.');
|
||||
} catch {}
|
||||
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
|
||||
|
||||
const isDebrid = streamInfo.isDebrid;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.streamCard,
|
||||
isLoading && styles.streamCardLoading,
|
||||
isDebrid && styles.streamCardHighlighted
|
||||
]}
|
||||
onPress={onPress}
|
||||
onLongPress={handleLongPress}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Scraper Logo */}
|
||||
{showLogos && scraperLogo && (
|
||||
<View style={styles.scraperLogoContainer}>
|
||||
<FastImage
|
||||
source={{ uri: scraperLogo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.streamDetails}>
|
||||
<View style={styles.streamNameRow}>
|
||||
<View style={styles.streamTitleContainer}>
|
||||
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
|
||||
{streamInfo.displayName}
|
||||
</Text>
|
||||
{streamInfo.subTitle && (
|
||||
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
|
||||
{streamInfo.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Show loading indicator if stream is loading */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingIndicator}>
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
|
||||
{statusMessage || "Loading..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.streamMetaRow}>
|
||||
{streamInfo.isDolby && (
|
||||
<QualityBadge type="VISION" />
|
||||
)}
|
||||
|
||||
{streamInfo.size && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{streamInfo.isDebrid && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{settings?.enableDownloads !== false && (
|
||||
<TouchableOpacity
|
||||
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
|
||||
onPress={handleDownload}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="download"
|
||||
size={20}
|
||||
color={theme.colors.highEmphasis}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
streamCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: 14,
|
||||
borderRadius: 12,
|
||||
marginBottom: 10,
|
||||
minHeight: 68,
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 0,
|
||||
width: '100%',
|
||||
zIndex: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 2,
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
elevation: 0,
|
||||
},
|
||||
scraperLogoContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
marginRight: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 6,
|
||||
},
|
||||
scraperLogo: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
},
|
||||
streamCardLoading: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
streamCardHighlighted: {
|
||||
backgroundColor: colors.elevation2,
|
||||
shadowOpacity: 0.18,
|
||||
},
|
||||
streamDetails: {
|
||||
flex: 1,
|
||||
},
|
||||
streamNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8
|
||||
},
|
||||
streamTitleContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
streamName: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
marginBottom: 2,
|
||||
lineHeight: 20,
|
||||
color: colors.highEmphasis,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
streamAddonName: {
|
||||
fontSize: 12,
|
||||
lineHeight: 18,
|
||||
color: colors.mediumEmphasis,
|
||||
marginBottom: 6,
|
||||
},
|
||||
streamMetaRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
marginBottom: 6,
|
||||
alignItems: 'center',
|
||||
},
|
||||
chip: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 12,
|
||||
marginRight: 6,
|
||||
marginBottom: 6,
|
||||
backgroundColor: colors.elevation2,
|
||||
},
|
||||
chipText: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
loadingIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 12,
|
||||
marginLeft: 8,
|
||||
},
|
||||
loadingText: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
streamAction: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: colors.primary,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default StreamCard;
|
||||
795
src/components/TabletStreamsLayout.tsx
Normal file
795
src/components/TabletStreamsLayout.tsx
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
import React, { memo, useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
Platform,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withDelay,
|
||||
Easing
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
// Lazy-safe community blur import for Android
|
||||
let AndroidBlurView: any = null;
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
AndroidBlurView = require('@react-native-community/blur').BlurView;
|
||||
} catch (_) {
|
||||
AndroidBlurView = null;
|
||||
}
|
||||
}
|
||||
|
||||
import { Stream } from '../types/metadata';
|
||||
import { RootStackNavigationProp } from '../navigation/AppNavigator';
|
||||
import ProviderFilter from './ProviderFilter';
|
||||
import PulsingChip from './PulsingChip';
|
||||
import StreamCard from './StreamCard';
|
||||
|
||||
interface TabletStreamsLayoutProps {
|
||||
// Background and content props
|
||||
episodeImage?: string | null;
|
||||
bannerImage?: string | null;
|
||||
metadata?: any;
|
||||
type: string;
|
||||
currentEpisode?: any;
|
||||
|
||||
// Movie logo props
|
||||
movieLogoError: boolean;
|
||||
setMovieLogoError: (error: boolean) => void;
|
||||
|
||||
// Stream-related props
|
||||
streamsEmpty: boolean;
|
||||
selectedProvider: string;
|
||||
filterItems: Array<{ id: string; name: string; }>;
|
||||
handleProviderChange: (provider: string) => void;
|
||||
activeFetchingScrapers: string[];
|
||||
|
||||
// Loading states
|
||||
isAutoplayWaiting: boolean;
|
||||
autoplayTriggered: boolean;
|
||||
showNoSourcesError: boolean;
|
||||
showInitialLoading: boolean;
|
||||
showStillFetching: boolean;
|
||||
|
||||
// Stream rendering props
|
||||
sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>;
|
||||
renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement;
|
||||
handleStreamPress: (stream: Stream) => void;
|
||||
openAlert: (title: string, message: string) => void;
|
||||
|
||||
// Settings and theme
|
||||
settings: any;
|
||||
currentTheme: any;
|
||||
colors: any;
|
||||
|
||||
// Other props
|
||||
navigation: RootStackNavigationProp;
|
||||
insets: any;
|
||||
streams: any;
|
||||
scraperLogos: Record<string, string>;
|
||||
id: string;
|
||||
imdbId?: string;
|
||||
loadingStreams: boolean;
|
||||
loadingEpisodeStreams: boolean;
|
||||
hasStremioStreamProviders: boolean;
|
||||
}
|
||||
|
||||
const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
||||
episodeImage,
|
||||
bannerImage,
|
||||
metadata,
|
||||
type,
|
||||
currentEpisode,
|
||||
movieLogoError,
|
||||
setMovieLogoError,
|
||||
streamsEmpty,
|
||||
selectedProvider,
|
||||
filterItems,
|
||||
handleProviderChange,
|
||||
activeFetchingScrapers,
|
||||
isAutoplayWaiting,
|
||||
autoplayTriggered,
|
||||
showNoSourcesError,
|
||||
showInitialLoading,
|
||||
showStillFetching,
|
||||
sections,
|
||||
renderSectionHeader,
|
||||
handleStreamPress,
|
||||
openAlert,
|
||||
settings,
|
||||
currentTheme,
|
||||
colors,
|
||||
navigation,
|
||||
insets,
|
||||
streams,
|
||||
scraperLogos,
|
||||
id,
|
||||
imdbId,
|
||||
loadingStreams,
|
||||
loadingEpisodeStreams,
|
||||
hasStremioStreamProviders,
|
||||
}) => {
|
||||
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
// Animation values for backdrop entrance
|
||||
const backdropOpacity = useSharedValue(0);
|
||||
const backdropScale = useSharedValue(1.05);
|
||||
const [backdropLoaded, setBackdropLoaded] = useState(false);
|
||||
const [backdropError, setBackdropError] = useState(false);
|
||||
|
||||
// Animation values for content panels
|
||||
const leftPanelOpacity = useSharedValue(0);
|
||||
const leftPanelTranslateX = useSharedValue(-30);
|
||||
const rightPanelOpacity = useSharedValue(0);
|
||||
const rightPanelTranslateX = useSharedValue(30);
|
||||
|
||||
// Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster
|
||||
// For episodes without thumbnails, use show's backdrop instead of poster
|
||||
const backdropSource = React.useMemo(() => {
|
||||
// Debug logging
|
||||
if (__DEV__) {
|
||||
console.log('[TabletStreamsLayout] Backdrop source selection:', {
|
||||
episodeImage,
|
||||
bannerImage,
|
||||
metadataPoster: metadata?.poster,
|
||||
episodeImageIsPoster: episodeImage === metadata?.poster,
|
||||
backdropError
|
||||
});
|
||||
}
|
||||
|
||||
// If episodeImage failed to load, skip it and use backdrop
|
||||
if (backdropError && episodeImage && episodeImage !== metadata?.poster) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop');
|
||||
if (bannerImage) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop (episode failed):', bannerImage);
|
||||
return { uri: bannerImage };
|
||||
}
|
||||
}
|
||||
|
||||
// If episodeImage exists and is not the same as poster, use it (real episode thumbnail)
|
||||
if (episodeImage && episodeImage !== metadata?.poster && !backdropError) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage);
|
||||
return { uri: episodeImage };
|
||||
}
|
||||
|
||||
// If episodeImage is the same as poster (fallback case), prioritize backdrop
|
||||
if (bannerImage) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage);
|
||||
return { uri: bannerImage };
|
||||
}
|
||||
|
||||
// No fallback to poster images
|
||||
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found');
|
||||
return undefined;
|
||||
}, [episodeImage, bannerImage, metadata?.poster, backdropError]);
|
||||
|
||||
// Animate backdrop when it loads, or animate content immediately if no backdrop
|
||||
useEffect(() => {
|
||||
if (backdropSource?.uri && backdropLoaded) {
|
||||
// Animate backdrop first
|
||||
backdropOpacity.value = withTiming(1, {
|
||||
duration: 800,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
backdropScale.value = withTiming(1, {
|
||||
duration: 1000,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
// Animate content panels with delay after backdrop starts loading
|
||||
leftPanelOpacity.value = withDelay(300, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
leftPanelTranslateX.value = withDelay(300, withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
|
||||
rightPanelOpacity.value = withDelay(500, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
rightPanelTranslateX.value = withDelay(500, withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
} else if (!backdropSource?.uri) {
|
||||
// No backdrop available, animate content panels immediately
|
||||
leftPanelOpacity.value = withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
leftPanelTranslateX.value = withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
rightPanelOpacity.value = withDelay(200, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
rightPanelTranslateX.value = withDelay(200, withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
}
|
||||
}, [backdropSource?.uri, backdropLoaded]);
|
||||
|
||||
// Reset animation when episode changes
|
||||
useEffect(() => {
|
||||
backdropOpacity.value = 0;
|
||||
backdropScale.value = 1.05;
|
||||
leftPanelOpacity.value = 0;
|
||||
leftPanelTranslateX.value = -30;
|
||||
rightPanelOpacity.value = 0;
|
||||
rightPanelTranslateX.value = 30;
|
||||
setBackdropLoaded(false);
|
||||
setBackdropError(false);
|
||||
}, [episodeImage]);
|
||||
|
||||
// Animated styles for backdrop
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
transform: [{ scale: backdropScale.value }],
|
||||
}));
|
||||
|
||||
// Animated styles for content panels
|
||||
const leftPanelAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: leftPanelOpacity.value,
|
||||
transform: [{ translateX: leftPanelTranslateX.value }],
|
||||
}));
|
||||
|
||||
const rightPanelAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: rightPanelOpacity.value,
|
||||
transform: [{ translateX: rightPanelTranslateX.value }],
|
||||
}));
|
||||
|
||||
const handleBackdropLoad = () => {
|
||||
setBackdropLoaded(true);
|
||||
};
|
||||
|
||||
const handleBackdropError = () => {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri);
|
||||
setBackdropError(true);
|
||||
setBackdropLoaded(false);
|
||||
};
|
||||
|
||||
const renderStreamContent = () => {
|
||||
if (showNoSourcesError) {
|
||||
return (
|
||||
<View style={[styles.noStreams, { paddingTop: 50 }]}>
|
||||
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
||||
<Text style={styles.noStreamsText}>No streaming sources available</Text>
|
||||
<Text style={styles.noStreamsSubText}>
|
||||
Please add streaming sources in settings
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addSourcesButton}
|
||||
onPress={() => navigation.navigate('Addons')}
|
||||
>
|
||||
<Text style={styles.addSourcesButtonText}>Add Sources</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (streamsEmpty) {
|
||||
if (showInitialLoading || showStillFetching) {
|
||||
return (
|
||||
<View style={[styles.loadingContainer, { paddingTop: 50 }]}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>
|
||||
{isAutoplayWaiting ? 'Finding best stream for autoplay...' :
|
||||
showStillFetching ? 'Still fetching streams…' :
|
||||
'Finding available streams...'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<View style={[styles.noStreams, { paddingTop: 50 }]}>
|
||||
<MaterialIcons name="error-outline" size={48} color={colors.textMuted} />
|
||||
<Text style={styles.noStreamsText}>No streams available</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.streamsContent}
|
||||
contentContainerStyle={[
|
||||
styles.streamsContainer,
|
||||
{ paddingBottom: insets.bottom + 100 }
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
bounces={true}
|
||||
overScrollMode="never"
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
{sections.filter(Boolean).map((section, sectionIndex) => (
|
||||
<View key={section!.addonId || sectionIndex}>
|
||||
{renderSectionHeader({ section: section! })}
|
||||
|
||||
{section!.data && section!.data.length > 0 ? (
|
||||
<FlatList
|
||||
data={section!.data}
|
||||
keyExtractor={(item, index) => {
|
||||
if (item && item.url) {
|
||||
return `${item.url}-${sectionIndex}-${index}`;
|
||||
}
|
||||
return `empty-${sectionIndex}-${index}`;
|
||||
}}
|
||||
renderItem={({ item, index }) => (
|
||||
<View>
|
||||
<StreamCard
|
||||
stream={item}
|
||||
onPress={() => handleStreamPress(item)}
|
||||
index={index}
|
||||
isLoading={false}
|
||||
statusMessage={undefined}
|
||||
theme={currentTheme}
|
||||
showLogos={settings.showScraperLogos}
|
||||
scraperLogo={(item.addonId && scraperLogos[item.addonId]) || (item as any).addon ? scraperLogos[(item.addonId || (item as any).addon) as string] || null : null}
|
||||
showAlert={(t: string, m: string) => openAlert(t, m)}
|
||||
parentTitle={metadata?.name}
|
||||
parentType={type as 'movie' | 'series'}
|
||||
parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
|
||||
parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
|
||||
parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
|
||||
parentPosterUrl={episodeImage || metadata?.poster || undefined}
|
||||
providerName={streams && Object.keys(streams).find(pid => (streams as any)[pid]?.streams?.includes?.(item))}
|
||||
parentId={id}
|
||||
parentImdbId={imdbId || undefined}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
scrollEnabled={false}
|
||||
initialNumToRender={6}
|
||||
maxToRenderPerBatch={2}
|
||||
windowSize={3}
|
||||
removeClippedSubviews={true}
|
||||
showsVerticalScrollIndicator={false}
|
||||
getItemLayout={(data, index) => ({
|
||||
length: 78,
|
||||
offset: 78 * index,
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders && (
|
||||
<View style={styles.footerLoading}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.tabletLayout}>
|
||||
{/* Full Screen Background with Entrance Animation */}
|
||||
{backdropSource?.uri ? (
|
||||
<Animated.View style={[styles.tabletFullScreenBackground, backdropAnimatedStyle]}>
|
||||
<FastImage
|
||||
source={backdropSource}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={handleBackdropLoad}
|
||||
onError={handleBackdropError}
|
||||
/>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<View style={styles.tabletFullScreenBackground}>
|
||||
<View style={styles.tabletNoBackdropBackground} />
|
||||
</View>
|
||||
)}
|
||||
<LinearGradient
|
||||
colors={['rgba(0,0,0,0.2)', 'rgba(0,0,0,0.4)', 'rgba(0,0,0,0.6)']}
|
||||
locations={[0, 0.5, 1]}
|
||||
style={styles.tabletFullScreenGradient}
|
||||
/>
|
||||
|
||||
{/* Left Panel: Movie Logo/Episode Info */}
|
||||
<Animated.View style={[styles.tabletLeftPanel, leftPanelAnimatedStyle]}>
|
||||
{type === 'movie' && metadata && (
|
||||
<View style={styles.tabletMovieLogoContainer}>
|
||||
{metadata.logo && !movieLogoError ? (
|
||||
<FastImage
|
||||
source={{ uri: metadata.logo }}
|
||||
style={styles.tabletMovieLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
onError={() => setMovieLogoError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.tabletMovieTitle}>{metadata.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{type === 'series' && currentEpisode && (
|
||||
<View style={styles.tabletEpisodeInfo}>
|
||||
<Text style={[styles.streamsHeroEpisodeNumber, styles.tabletEpisodeText, styles.tabletEpisodeNumber]}>{currentEpisode.episodeString}</Text>
|
||||
<Text style={[styles.streamsHeroTitle, styles.tabletEpisodeText, styles.tabletEpisodeTitle]} numberOfLines={2}>{currentEpisode.name}</Text>
|
||||
{currentEpisode.overview && (
|
||||
<Text style={[styles.streamsHeroOverview, styles.tabletEpisodeText, styles.tabletEpisodeOverview]} numberOfLines={4}>{currentEpisode.overview}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Right Panel: Streams List */}
|
||||
<Animated.View style={[styles.tabletRightPanel, rightPanelAnimatedStyle]}>
|
||||
{Platform.OS === 'android' && AndroidBlurView ? (
|
||||
<View style={[
|
||||
styles.streamsMainContent,
|
||||
styles.tabletStreamsContent,
|
||||
type === 'movie' && styles.streamsMainContentMovie
|
||||
]}>
|
||||
<AndroidBlurView
|
||||
blurAmount={15}
|
||||
blurRadius={8}
|
||||
style={styles.androidBlurView}
|
||||
>
|
||||
<View style={styles.tabletBlurContent}>
|
||||
{/* Always show filter container to prevent layout shift */}
|
||||
<View style={[styles.filterContainer]}>
|
||||
{!streamsEmpty && (
|
||||
<ProviderFilter
|
||||
selectedProvider={selectedProvider}
|
||||
providers={filterItems}
|
||||
onSelect={handleProviderChange}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Active Scrapers Status */}
|
||||
{activeFetchingScrapers.length > 0 && (
|
||||
<View style={styles.activeScrapersContainer}>
|
||||
<Text style={styles.activeScrapersTitle}>Fetching from:</Text>
|
||||
<View style={styles.activeScrapersRow}>
|
||||
{activeFetchingScrapers.map((scraperName, index) => (
|
||||
<PulsingChip key={scraperName} text={scraperName} delay={index * 200} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Stream content area - always show ScrollView to prevent flash */}
|
||||
<View collapsable={false} style={{ flex: 1 }}>
|
||||
{/* Show autoplay loading overlay if waiting for autoplay */}
|
||||
{isAutoplayWaiting && !autoplayTriggered && (
|
||||
<View style={styles.autoplayOverlay}>
|
||||
<View style={styles.autoplayIndicator}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.autoplayText}>Starting best stream...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{renderStreamContent()}
|
||||
</View>
|
||||
</View>
|
||||
</AndroidBlurView>
|
||||
</View>
|
||||
) : (
|
||||
<ExpoBlurView
|
||||
intensity={80}
|
||||
tint="dark"
|
||||
style={[
|
||||
styles.streamsMainContent,
|
||||
styles.tabletStreamsContent,
|
||||
type === 'movie' && styles.streamsMainContentMovie
|
||||
]}
|
||||
>
|
||||
<View style={styles.tabletBlurContent}>
|
||||
{/* Always show filter container to prevent layout shift */}
|
||||
<View style={[styles.filterContainer]}>
|
||||
{!streamsEmpty && (
|
||||
<ProviderFilter
|
||||
selectedProvider={selectedProvider}
|
||||
providers={filterItems}
|
||||
onSelect={handleProviderChange}
|
||||
theme={currentTheme}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Active Scrapers Status */}
|
||||
{activeFetchingScrapers.length > 0 && (
|
||||
<View style={styles.activeScrapersContainer}>
|
||||
<Text style={styles.activeScrapersTitle}>Fetching from:</Text>
|
||||
<View style={styles.activeScrapersRow}>
|
||||
{activeFetchingScrapers.map((scraperName, index) => (
|
||||
<PulsingChip key={scraperName} text={scraperName} delay={index * 200} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Stream content area - always show ScrollView to prevent flash */}
|
||||
<View collapsable={false} style={{ flex: 1 }}>
|
||||
{/* Show autoplay loading overlay if waiting for autoplay */}
|
||||
{isAutoplayWaiting && !autoplayTriggered && (
|
||||
<View style={styles.autoplayOverlay}>
|
||||
<View style={styles.autoplayIndicator}>
|
||||
<ActivityIndicator size="small" color={colors.primary} />
|
||||
<Text style={styles.autoplayText}>Starting best stream...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{renderStreamContent()}
|
||||
</View>
|
||||
</View>
|
||||
</ExpoBlurView>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// Create a function to generate styles with the current theme colors
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
streamsMainContent: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.darkBackground,
|
||||
paddingTop: 12,
|
||||
zIndex: 1,
|
||||
// iOS-specific fixes for navigation transition glitches
|
||||
...(Platform.OS === 'ios' && {
|
||||
// Ensure proper rendering during transitions
|
||||
opacity: 1,
|
||||
// Prevent iOS optimization that can cause glitches
|
||||
shouldRasterizeIOS: false,
|
||||
}),
|
||||
},
|
||||
streamsMainContentMovie: {
|
||||
paddingTop: Platform.OS === 'android' ? 10 : 15,
|
||||
},
|
||||
filterContainer: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
streamsContent: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
zIndex: 2,
|
||||
},
|
||||
streamsContainer: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 20,
|
||||
width: '100%',
|
||||
},
|
||||
streamsHeroEpisodeNumber: {
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 2,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
streamsHeroTitle: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 24,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 4,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 3,
|
||||
},
|
||||
streamsHeroOverview: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 2,
|
||||
textShadowColor: 'rgba(0,0,0,0.75)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
noStreams: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
noStreamsText: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
marginTop: 16,
|
||||
},
|
||||
noStreamsSubText: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 14,
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
addSourcesButton: {
|
||||
marginTop: 24,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
},
|
||||
addSourcesButtonText: {
|
||||
color: colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loadingContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 24,
|
||||
},
|
||||
loadingText: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
footerLoading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 16,
|
||||
},
|
||||
footerLoadingText: {
|
||||
color: colors.primary,
|
||||
fontSize: 12,
|
||||
marginLeft: 8,
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeScrapersContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
backgroundColor: 'transparent',
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 4,
|
||||
},
|
||||
activeScrapersTitle: {
|
||||
color: colors.mediumEmphasis,
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
marginBottom: 6,
|
||||
opacity: 0.8,
|
||||
},
|
||||
activeScrapersRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 4,
|
||||
},
|
||||
autoplayOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.8)',
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
autoplayIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.elevation2,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
autoplayText: {
|
||||
color: colors.primary,
|
||||
fontSize: 14,
|
||||
marginLeft: 8,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// Tablet-specific styles
|
||||
tabletLayout: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
position: 'relative',
|
||||
},
|
||||
tabletFullScreenBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
tabletNoBackdropBackground: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: colors.darkBackground,
|
||||
},
|
||||
tabletFullScreenGradient: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
tabletLeftPanel: {
|
||||
width: '40%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: 24,
|
||||
zIndex: 2,
|
||||
},
|
||||
tabletMovieLogoContainer: {
|
||||
width: '80%',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabletMovieLogo: {
|
||||
width: '100%',
|
||||
height: 120,
|
||||
marginBottom: 16,
|
||||
},
|
||||
tabletMovieTitle: {
|
||||
color: colors.highEmphasis,
|
||||
fontSize: 32,
|
||||
fontWeight: '900',
|
||||
textAlign: 'center',
|
||||
letterSpacing: -0.5,
|
||||
textShadowColor: 'rgba(0,0,0,0.8)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
},
|
||||
tabletEpisodeInfo: {
|
||||
width: '80%',
|
||||
},
|
||||
tabletEpisodeText: {
|
||||
textShadowColor: 'rgba(0,0,0,1)',
|
||||
textShadowOffset: { width: 0, height: 0 },
|
||||
textShadowRadius: 4,
|
||||
},
|
||||
tabletEpisodeNumber: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 8,
|
||||
},
|
||||
tabletEpisodeTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
marginBottom: 12,
|
||||
lineHeight: 34,
|
||||
},
|
||||
tabletEpisodeOverview: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
opacity: 0.95,
|
||||
},
|
||||
tabletRightPanel: {
|
||||
width: '60%',
|
||||
flex: 1,
|
||||
paddingTop: Platform.OS === 'android' ? 60 : 20,
|
||||
zIndex: 2,
|
||||
},
|
||||
tabletStreamsContent: {
|
||||
backgroundColor: 'rgba(0,0,0,0.2)',
|
||||
borderRadius: 24,
|
||||
margin: 12,
|
||||
overflow: 'hidden', // Ensures content respects rounded corners
|
||||
},
|
||||
tabletBlurContent: {
|
||||
flex: 1,
|
||||
padding: 16,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
androidBlurView: {
|
||||
flex: 1,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
});
|
||||
|
||||
export default memo(TabletStreamsLayout);
|
||||
|
|
@ -109,8 +109,18 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
|
||||
const deviceWidth = dimensions.width;
|
||||
const deviceHeight = dimensions.height;
|
||||
|
||||
// Listen for dimension changes (orientation changes)
|
||||
useEffect(() => {
|
||||
const subscription = Dimensions.addEventListener('change', ({ window }) => {
|
||||
setDimensions(window);
|
||||
});
|
||||
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
|
|
@ -729,7 +739,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
id: item.id,
|
||||
type: item.type,
|
||||
episodeId: episodeId,
|
||||
imdbId: cachedStream.metadata?.imdbId || item.imdb_id,
|
||||
imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || item.imdb_id,
|
||||
backdrop: cachedStream.metadata?.backdrop || item.banner,
|
||||
videoType: undefined, // Let player auto-detect
|
||||
} as any);
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ if (Platform.OS === 'ios') {
|
|||
liquidGlassAvailable = false;
|
||||
}
|
||||
}
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
|
|
@ -44,7 +43,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
const insets = useSafeAreaInsets();
|
||||
const { settings } = useSettings();
|
||||
|
||||
const data = useMemo(() => (items && items.length ? items.slice(0, 10) : []), [items]);
|
||||
const data = useMemo(() => (items && items.length ? items.slice(0, 5) : []), [items]);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [failedLogoIds, setFailedLogoIds] = useState<Set<string>>(new Set());
|
||||
const scrollViewRef = useRef<any>(null);
|
||||
|
|
@ -102,7 +101,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
},
|
||||
});
|
||||
|
||||
// Derive the index reactively and only set state when it changes
|
||||
// Debounced activeIndex update to reduce JS bridge crossings
|
||||
const lastIndexUpdateRef = useRef(0);
|
||||
useAnimatedReaction(
|
||||
() => {
|
||||
const idx = Math.round(scrollX.value / interval);
|
||||
|
|
@ -110,6 +110,12 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
},
|
||||
(idx, prevIdx) => {
|
||||
if (idx == null || idx === prevIdx) return;
|
||||
|
||||
// Debounce updates to reduce JS bridge crossings
|
||||
const now = Date.now();
|
||||
if (now - lastIndexUpdateRef.current < 100) return; // 100ms debounce
|
||||
lastIndexUpdateRef.current = now;
|
||||
|
||||
// Clamp to bounds to avoid out-of-range access
|
||||
const clamped = Math.max(0, Math.min(idx, data.length - 1));
|
||||
runOnJS(setActiveIndex)(clamped);
|
||||
|
|
@ -123,23 +129,20 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
navigation.navigate('Metadata', { id, type });
|
||||
}, [navigation]);
|
||||
|
||||
const handleNavigateToStreams = useCallback((id: string, type: any) => {
|
||||
navigation.navigate('Streams', { id, type });
|
||||
}, [navigation]);
|
||||
|
||||
// Container animation based on scroll - must be before early returns
|
||||
const containerAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const progress = Math.abs(translateX) / (data.length * (CARD_WIDTH + 16));
|
||||
|
||||
// Very subtle scale animation for the entire container
|
||||
const scale = 1 - progress * 0.01;
|
||||
const clampedScale = Math.max(0.99, Math.min(1, scale));
|
||||
|
||||
return {
|
||||
transform: [{ scale: clampedScale }],
|
||||
};
|
||||
});
|
||||
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
|
||||
// const containerAnimatedStyle = useAnimatedStyle(() => {
|
||||
// const translateX = scrollX.value;
|
||||
// const progress = Math.abs(translateX) / (data.length * (CARD_WIDTH + 16));
|
||||
//
|
||||
// // Very subtle scale animation for the entire container
|
||||
// const scale = 1 - progress * 0.01;
|
||||
// const clampedScale = Math.max(0.99, Math.min(1, scale));
|
||||
//
|
||||
// return {
|
||||
// transform: [{ scale: clampedScale }],
|
||||
// };
|
||||
// });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
|
|
@ -193,18 +196,6 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
item: StreamingContent;
|
||||
insets: any;
|
||||
}) => {
|
||||
const animatedOpacity = useSharedValue(1);
|
||||
|
||||
useEffect(() => {
|
||||
// Start with opacity 0 and animate to 1, but only if it's a new item
|
||||
animatedOpacity.value = 0;
|
||||
animatedOpacity.value = withTiming(1, { duration: 400 });
|
||||
}, [item.id]);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: animatedOpacity.value,
|
||||
}));
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
|
|
@ -213,9 +204,9 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
] as StyleProp<ViewStyle>}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<Animated.View
|
||||
<View
|
||||
key={item.id}
|
||||
style={[animatedStyle, { flex: 1 }] as any}
|
||||
style={{ flex: 1 } as any}
|
||||
>
|
||||
{Platform.OS === 'android' ? (
|
||||
<Image
|
||||
|
|
@ -254,7 +245,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
locations={[0.4, 1]}
|
||||
style={styles.backgroundOverlay as ViewStyle}
|
||||
/>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
|
@ -263,33 +254,8 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
|
||||
return (
|
||||
<Animated.View entering={FadeIn.duration(350).easing(Easing.out(Easing.cubic))}>
|
||||
<Animated.View style={[styles.container as ViewStyle, containerAnimatedStyle]}>
|
||||
{settings.enableHomeHeroBackground && data.length > 0 && (
|
||||
<View style={{ height: 0, width: 0, overflow: 'hidden' }}>
|
||||
{data[activeIndex + 1] && (
|
||||
<FastImage
|
||||
source={{
|
||||
uri: data[activeIndex + 1].banner || data[activeIndex + 1].poster,
|
||||
priority: FastImage.priority.low,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={{ width: 1, height: 1 }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
)}
|
||||
{activeIndex > 0 && data[activeIndex - 1] && (
|
||||
<FastImage
|
||||
source={{
|
||||
uri: data[activeIndex - 1].banner || data[activeIndex - 1].poster,
|
||||
priority: FastImage.priority.low,
|
||||
cache: FastImage.cacheControl.immutable
|
||||
}}
|
||||
style={{ width: 1, height: 1 }}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
<Animated.View style={[styles.container as ViewStyle]}>
|
||||
{/* Removed preload images for performance - let FastImage cache handle it naturally */}
|
||||
{settings.enableHomeHeroBackground && data[activeIndex] && (
|
||||
<BackgroundImage
|
||||
item={data[activeIndex]}
|
||||
|
|
@ -313,7 +279,7 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
decelerationRate="fast"
|
||||
contentContainerStyle={contentPadding}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={8}
|
||||
scrollEventThrottle={32}
|
||||
disableIntervalMomentum
|
||||
pagingEnabled={false}
|
||||
bounces={false}
|
||||
|
|
@ -327,7 +293,6 @@ const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) =
|
|||
logoFailed={failedLogoIds.has(item.id)}
|
||||
onLogoError={() => setFailedLogoIds((prev) => new Set(prev).add(item.id))}
|
||||
onPressInfo={() => handleNavigateToMetadata(item.id, item.type)}
|
||||
onPressPlay={() => handleNavigateToStreams(item.id, item.type)}
|
||||
scrollX={scrollX}
|
||||
index={index}
|
||||
/>
|
||||
|
|
@ -343,13 +308,12 @@ interface CarouselCardProps {
|
|||
colors: any;
|
||||
logoFailed: boolean;
|
||||
onLogoError: () => void;
|
||||
onPressPlay: () => void;
|
||||
onPressInfo: () => void;
|
||||
scrollX: SharedValue<number>;
|
||||
index: number;
|
||||
}
|
||||
|
||||
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressPlay, onPressInfo, scrollX, index }) => {
|
||||
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index }) => {
|
||||
const [bannerLoaded, setBannerLoaded] = useState(false);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
|
||||
|
|
@ -383,31 +347,28 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
opacity: logoOpacity.value,
|
||||
}));
|
||||
|
||||
const genresAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
const maxDistance = (CARD_WIDTH + 16) * 0.5; // Smaller threshold for smoother transition
|
||||
|
||||
// Hide genres when scrolling (not centered)
|
||||
const progress = Math.min(distance / maxDistance, 1);
|
||||
const opacity = 1 - progress; // Linear fade out
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
// ULTRA-OPTIMIZED: Only animate the center card and ±1 neighbors
|
||||
// Use a simple distance-based approach instead of reading scrollX.value during render
|
||||
const shouldAnimate = useMemo(() => {
|
||||
// For now, animate all cards but with early exit in worklets
|
||||
// This avoids reading scrollX.value during render
|
||||
return true;
|
||||
}, [index]);
|
||||
|
||||
const actionsAnimatedStyle = useAnimatedStyle(() => {
|
||||
// Combined animation for genres and actions (same calculation)
|
||||
const overlayAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
const maxDistance = (CARD_WIDTH + 16) * 0.5; // Smaller threshold for smoother transition
|
||||
|
||||
// Hide actions when scrolling (not centered)
|
||||
// AGGRESSIVE early exit for cards far from center
|
||||
if (distance > (CARD_WIDTH + 16) * 1.2) {
|
||||
return { opacity: 0 };
|
||||
}
|
||||
|
||||
const maxDistance = (CARD_WIDTH + 16) * 0.5;
|
||||
const progress = Math.min(distance / maxDistance, 1);
|
||||
const opacity = 1 - progress; // Linear fade out
|
||||
const opacity = 1 - progress;
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
return {
|
||||
|
|
@ -415,11 +376,20 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
};
|
||||
});
|
||||
|
||||
// Scroll-based animations
|
||||
// ULTRA-OPTIMIZED: Only animate center card and ±1 neighbors
|
||||
const cardAnimatedStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
|
||||
// AGGRESSIVE early exit for cards far from center
|
||||
if (distance > (CARD_WIDTH + 16) * 1.5) {
|
||||
return {
|
||||
transform: [{ scale: 0.9 }],
|
||||
opacity: 0.7
|
||||
};
|
||||
}
|
||||
|
||||
const maxDistance = CARD_WIDTH + 16;
|
||||
|
||||
// Scale animation based on distance from center
|
||||
|
|
@ -436,38 +406,40 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
};
|
||||
});
|
||||
|
||||
const bannerParallaxStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = translateX - cardOffset;
|
||||
|
||||
// Reduced parallax effect to prevent displacement
|
||||
const parallaxOffset = distance * 0.05;
|
||||
|
||||
return {
|
||||
transform: [{ translateX: parallaxOffset }],
|
||||
};
|
||||
});
|
||||
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
|
||||
// const bannerParallaxStyle = useAnimatedStyle(() => {
|
||||
// const translateX = scrollX.value;
|
||||
// const cardOffset = index * (CARD_WIDTH + 16);
|
||||
// const distance = translateX - cardOffset;
|
||||
//
|
||||
// // Reduced parallax effect to prevent displacement
|
||||
// const parallaxOffset = distance * 0.05;
|
||||
//
|
||||
// return {
|
||||
// transform: [{ translateX: parallaxOffset }],
|
||||
// };
|
||||
// });
|
||||
|
||||
const infoParallaxStyle = useAnimatedStyle(() => {
|
||||
const translateX = scrollX.value;
|
||||
const cardOffset = index * (CARD_WIDTH + 16);
|
||||
const distance = Math.abs(translateX - cardOffset);
|
||||
const maxDistance = CARD_WIDTH + 16;
|
||||
|
||||
// Hide info section when scrolling (not centered)
|
||||
const progress = distance / maxDistance;
|
||||
const opacity = 1 - progress * 2; // Fade out faster when scrolling
|
||||
const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
|
||||
// Minimal parallax for info section to prevent displacement
|
||||
const parallaxOffset = -(translateX - cardOffset) * 0.02;
|
||||
|
||||
return {
|
||||
transform: [{ translateY: parallaxOffset }],
|
||||
opacity: clampedOpacity,
|
||||
};
|
||||
});
|
||||
// TEMPORARILY DISABLED FOR PERFORMANCE TESTING
|
||||
// const infoParallaxStyle = useAnimatedStyle(() => {
|
||||
// const translateX = scrollX.value;
|
||||
// const cardOffset = index * (CARD_WIDTH + 16);
|
||||
// const distance = Math.abs(translateX - cardOffset);
|
||||
// const maxDistance = CARD_WIDTH + 16;
|
||||
//
|
||||
// // Hide info section when scrolling (not centered)
|
||||
// const progress = distance / maxDistance;
|
||||
// const opacity = 1 - progress * 2; // Fade out faster when scrolling
|
||||
// const clampedOpacity = Math.max(0, Math.min(1, opacity));
|
||||
//
|
||||
// // Minimal parallax for info section to prevent displacement
|
||||
// const parallaxOffset = -(translateX - cardOffset) * 0.02;
|
||||
//
|
||||
// return {
|
||||
// transform: [{ translateY: parallaxOffset }],
|
||||
// opacity: clampedOpacity,
|
||||
// };
|
||||
// });
|
||||
|
||||
useEffect(() => {
|
||||
if (bannerLoaded) {
|
||||
|
|
@ -488,9 +460,8 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
}, [logoLoaded]);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<View
|
||||
style={{ width: CARD_WIDTH + 16 }}
|
||||
entering={FadeIn.duration(400).delay(index * 100).easing(Easing.out(Easing.cubic))}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
|
|
@ -510,7 +481,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
{!bannerLoaded && (
|
||||
<View style={styles.skeletonBannerFull as ViewStyle} />
|
||||
)}
|
||||
<Animated.View style={[bannerAnimatedStyle, bannerParallaxStyle, { flex: 1 }]}>
|
||||
<Animated.View style={[bannerAnimatedStyle, { flex: 1 }]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.banner || item.poster,
|
||||
|
|
@ -534,7 +505,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
<View style={styles.genresOverlay as ViewStyle} pointerEvents="none">
|
||||
<Animated.View entering={FadeIn.duration(400).delay(100)}>
|
||||
<Animated.Text
|
||||
style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }, genresAnimatedStyle]}
|
||||
style={[styles.genres as TextStyle, { color: colors.mediumEmphasis, textAlign: 'center' }, overlayAnimatedStyle]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{item.genres.slice(0, 3).join(' • ')}
|
||||
|
|
@ -542,29 +513,6 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
{/* Static action buttons positioned absolutely over the card */}
|
||||
<View style={styles.actionsOverlay as ViewStyle} pointerEvents="box-none">
|
||||
<Animated.View entering={FadeIn.duration(500).delay(200)}>
|
||||
<Animated.View style={[styles.actions as ViewStyle, actionsAnimatedStyle]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.playButton as ViewStyle, { backgroundColor: colors.white }]}
|
||||
onPress={onPressPlay}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="play-arrow" size={22} color={colors.black} />
|
||||
<Text style={[styles.playText as TextStyle, { color: colors.black }]}>Play</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.secondaryButton as ViewStyle, { borderColor: 'rgba(255,255,255,0.25)' }]}
|
||||
onPress={onPressInfo}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialIcons name="info-outline" size={18} color={colors.white} />
|
||||
<Text style={[styles.secondaryText as TextStyle, { color: colors.white }]}>Info</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
{/* Static logo positioned absolutely over the card */}
|
||||
{item.logo && !logoFailed && (
|
||||
<View style={styles.logoOverlay as ViewStyle} pointerEvents="none">
|
||||
|
|
@ -594,7 +542,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
</View>
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -731,31 +679,6 @@ const styles = StyleSheet.create({
|
|||
height: 64,
|
||||
marginBottom: 6,
|
||||
},
|
||||
playButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 24,
|
||||
},
|
||||
playText: {
|
||||
fontWeight: '700',
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
secondaryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 9,
|
||||
borderRadius: 22,
|
||||
borderWidth: 1,
|
||||
},
|
||||
secondaryText: {
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
fontSize: 14,
|
||||
},
|
||||
logoOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
|
|
@ -764,7 +687,7 @@ const styles = StyleSheet.create({
|
|||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 80, // Position above genres and actions
|
||||
paddingBottom: 40, // Position above genres
|
||||
},
|
||||
titleOverlay: {
|
||||
position: 'absolute',
|
||||
|
|
@ -774,19 +697,9 @@ const styles = StyleSheet.create({
|
|||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 90, // Position above genres and actions
|
||||
paddingBottom: 50, // Position above genres
|
||||
},
|
||||
genresOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 65, // Position above actions
|
||||
},
|
||||
actionsOverlay: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
|
|
|
|||
234
src/components/metadata/CollectionSection.tsx
Normal file
234
src/components/metadata/CollectionSection.tsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { StreamingContent } from '../../services/catalogService';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { TMDBService } from '../../services/tmdbService';
|
||||
import { catalogService } from '../../services/catalogService';
|
||||
import CustomAlert from '../../components/CustomAlert';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
// Breakpoints for responsive sizing
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
tablet: 768,
|
||||
largeTablet: 1024,
|
||||
tv: 1440,
|
||||
} as const;
|
||||
|
||||
interface CollectionSectionProps {
|
||||
collectionName: string;
|
||||
collectionMovies: StreamingContent[];
|
||||
loadingCollection: boolean;
|
||||
}
|
||||
|
||||
export const CollectionSection: React.FC<CollectionSectionProps> = ({
|
||||
collectionName,
|
||||
collectionMovies,
|
||||
loadingCollection
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
||||
// Determine device type
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const getDeviceType = React.useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
if (deviceWidth >= BREAKPOINTS.largeTablet) return 'largeTablet';
|
||||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
|
||||
// Responsive spacing & sizes
|
||||
const horizontalPadding = React.useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv': return 32;
|
||||
case 'largeTablet': return 28;
|
||||
case 'tablet': return 24;
|
||||
default: return 16;
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
const itemSpacing = React.useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv': return 14;
|
||||
case 'largeTablet': return 12;
|
||||
case 'tablet': return 12;
|
||||
default: return 12;
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
const backdropWidth = React.useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv': return 240;
|
||||
case 'largeTablet': return 220;
|
||||
case 'tablet': return 200;
|
||||
default: return 180;
|
||||
}
|
||||
}, [deviceType]);
|
||||
const backdropHeight = React.useMemo(() => backdropWidth * (9/16), [backdropWidth]); // 16:9 aspect ratio
|
||||
|
||||
const [alertVisible, setAlertVisible] = React.useState(false);
|
||||
const [alertTitle, setAlertTitle] = React.useState('');
|
||||
const [alertMessage, setAlertMessage] = React.useState('');
|
||||
const [alertActions, setAlertActions] = React.useState<any[]>([]);
|
||||
|
||||
const handleItemPress = async (item: StreamingContent) => {
|
||||
try {
|
||||
// Extract TMDB ID from the tmdb:123456 format
|
||||
const tmdbId = item.id.replace('tmdb:', '');
|
||||
|
||||
// Get Stremio ID directly using catalogService
|
||||
const stremioId = await catalogService.getStremioId(item.type, tmdbId);
|
||||
|
||||
if (stremioId) {
|
||||
navigation.dispatch(
|
||||
StackActions.push('Metadata', {
|
||||
id: stremioId,
|
||||
type: item.type
|
||||
})
|
||||
);
|
||||
} else {
|
||||
throw new Error('Could not find Stremio ID');
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error navigating to collection item:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Unable to load this content. Please try again later.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertVisible(true);
|
||||
}
|
||||
};
|
||||
|
||||
const renderItem = ({ item }: { item: StreamingContent }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.itemContainer, { width: backdropWidth, marginRight: itemSpacing }]}
|
||||
onPress={() => handleItemPress(item)}
|
||||
>
|
||||
<FastImage
|
||||
source={{ uri: item.banner || item.poster }}
|
||||
style={[styles.backdrop, {
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
width: backdropWidth,
|
||||
height: backdropHeight,
|
||||
borderRadius: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 10 : 8
|
||||
}]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<Text style={[styles.title, {
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 13 : 13,
|
||||
lineHeight: isTV ? 20 : 18
|
||||
}]} numberOfLines={2}>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.year && (
|
||||
<Text style={[styles.year, {
|
||||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 11 : 11
|
||||
}]}>
|
||||
{item.year}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (loadingCollection) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!collectionMovies || collectionMovies.length === 0) {
|
||||
return null; // Don't render anything if there are no collection movies
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingLeft: 0 }] }>
|
||||
<Text style={[styles.sectionTitle, {
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20,
|
||||
paddingHorizontal: horizontalPadding
|
||||
}]}>
|
||||
{collectionName}
|
||||
</Text>
|
||||
<FlatList
|
||||
data={collectionMovies}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.listContentContainer, {
|
||||
paddingHorizontal: horizontalPadding,
|
||||
paddingRight: horizontalPadding + itemSpacing
|
||||
}]}
|
||||
/>
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
actions={alertActions}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: 16,
|
||||
marginBottom: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '800',
|
||||
marginBottom: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
listContentContainer: {
|
||||
paddingRight: 32, // Will be overridden responsively
|
||||
},
|
||||
itemContainer: {
|
||||
marginRight: 12, // will be overridden responsively
|
||||
},
|
||||
backdrop: {
|
||||
borderRadius: 8, // overridden responsively
|
||||
marginBottom: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 13, // overridden responsively
|
||||
fontWeight: '500',
|
||||
lineHeight: 18, // overridden responsively
|
||||
marginBottom: 2,
|
||||
},
|
||||
year: {
|
||||
fontSize: 11, // overridden responsively
|
||||
fontWeight: '400',
|
||||
opacity: 0.8,
|
||||
},
|
||||
loadingContainer: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default CollectionSection;
|
||||
|
|
@ -2140,7 +2140,7 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
singleRowLayout: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
|
|
@ -2163,11 +2163,11 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
singleRowPlayButtonFullWidth: {
|
||||
flex: 1,
|
||||
marginHorizontal: 4,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
singleRowSaveButtonFullWidth: {
|
||||
flex: 1,
|
||||
marginHorizontal: 4,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
primaryActionRow: {
|
||||
flexDirection: 'row',
|
||||
|
|
|
|||
|
|
@ -155,24 +155,6 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
sectionTranslateYSV.value = withDelay(500, withTiming(0, { duration: 400 }));
|
||||
}, [sectionOpacitySV, sectionTranslateYSV]);
|
||||
|
||||
// Check if trailer service backend is available
|
||||
const checkBackendAvailability = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const serverStatus = TrailerService.getServerStatus();
|
||||
const healthUrl = `${serverStatus.localUrl.replace('/trailer', '/health')}`;
|
||||
|
||||
const response = await fetch(healthUrl, {
|
||||
method: 'GET',
|
||||
signal: AbortSignal.timeout(3000), // 3 second timeout
|
||||
});
|
||||
const isAvailable = response.ok;
|
||||
logger.info('TrailersSection', `Backend availability check: ${isAvailable ? 'AVAILABLE' : 'UNAVAILABLE'}`);
|
||||
return isAvailable;
|
||||
} catch (error) {
|
||||
logger.warn('TrailersSection', 'Backend availability check failed:', error);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch trailers from TMDB
|
||||
useEffect(() => {
|
||||
|
|
@ -180,17 +162,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
|
||||
const initializeTrailers = async () => {
|
||||
resetSectionAnimation();
|
||||
// First check if backend is available
|
||||
const available = await checkBackendAvailability();
|
||||
setBackendAvailable(available);
|
||||
|
||||
if (!available) {
|
||||
logger.warn('TrailersSection', 'Trailer service backend is not available - skipping trailer loading');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend is available, proceed with fetching trailers
|
||||
setBackendAvailable(true); // Assume available, let TrailerService handle errors
|
||||
await fetchTrailers();
|
||||
};
|
||||
|
||||
|
|
@ -334,7 +306,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
};
|
||||
|
||||
initializeTrailers();
|
||||
}, [tmdbId, type, checkBackendAvailability]);
|
||||
}, [tmdbId, type]);
|
||||
|
||||
// Categorize trailers by type
|
||||
const categorizeTrailers = (videos: any[]): CategorizedTrailers => {
|
||||
|
|
|
|||
|
|
@ -107,6 +107,8 @@ interface UseMetadataReturn {
|
|||
imdbId: string | null;
|
||||
scraperStatuses: ScraperStatus[];
|
||||
activeFetchingScrapers: string[];
|
||||
collectionMovies: StreamingContent[];
|
||||
loadingCollection: boolean;
|
||||
}
|
||||
|
||||
export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadataReturn => {
|
||||
|
|
@ -132,6 +134,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
const [loadAttempts, setLoadAttempts] = useState(0);
|
||||
const [recommendations, setRecommendations] = useState<StreamingContent[]>([]);
|
||||
const [loadingRecommendations, setLoadingRecommendations] = useState(false);
|
||||
const [collectionMovies, setCollectionMovies] = useState<StreamingContent[]>([]);
|
||||
const [loadingCollection, setLoadingCollection] = useState(false);
|
||||
const [imdbId, setImdbId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [availableStreams, setAvailableStreams] = useState<{ [sourceType: string]: Stream }>({});
|
||||
|
|
@ -875,6 +879,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
}
|
||||
|
||||
// Commit final metadata once and cache it
|
||||
// Clear banner field if TMDB enrichment is enabled to prevent flash
|
||||
if (settings.enrichMetadataWithTMDB) {
|
||||
finalMetadata = {
|
||||
...finalMetadata,
|
||||
banner: undefined, // Let useMetadataAssets handle banner via TMDB
|
||||
};
|
||||
}
|
||||
setMetadata(finalMetadata);
|
||||
cacheService.setMetadata(id, type, finalMetadata);
|
||||
const isInLib = catalogService.getLibraryItems().some(item => item.id === id);
|
||||
|
|
@ -1932,6 +1943,94 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
tmdbId,
|
||||
movieDetails: movieDetailsObj
|
||||
}));
|
||||
|
||||
// Fetch collection data if movie belongs to a collection
|
||||
if (movieDetails.belongs_to_collection) {
|
||||
setLoadingCollection(true);
|
||||
try {
|
||||
const collectionDetails = await tmdbService.getCollectionDetails(
|
||||
movieDetails.belongs_to_collection.id,
|
||||
lang
|
||||
);
|
||||
|
||||
if (collectionDetails && collectionDetails.parts) {
|
||||
// Fetch individual movie images to get backdrops with embedded titles/logos
|
||||
const collectionMoviesData = await Promise.all(
|
||||
collectionDetails.parts.map(async (part: any, index: number) => {
|
||||
let movieBackdropUrl = undefined;
|
||||
|
||||
// Try to fetch movie images with language parameter
|
||||
try {
|
||||
const movieImages = await tmdbService.getMovieImagesFull(part.id);
|
||||
if (movieImages && movieImages.backdrops && movieImages.backdrops.length > 0) {
|
||||
// Filter and sort backdrops by language and quality
|
||||
const languageBackdrops = movieImages.backdrops
|
||||
.filter((backdrop: any) => backdrop.aspect_ratio > 1.0) // Landscape orientation
|
||||
.sort((a: any, b: any) => {
|
||||
// Prioritize backdrops with the requested language
|
||||
const aHasLang = a.iso_639_1 === lang;
|
||||
const bHasLang = b.iso_639_1 === lang;
|
||||
if (aHasLang && !bHasLang) return -1;
|
||||
if (!aHasLang && bHasLang) return 1;
|
||||
|
||||
// Then prioritize English if requested language not available
|
||||
const aIsEn = a.iso_639_1 === 'en';
|
||||
const bIsEn = b.iso_639_1 === 'en';
|
||||
if (aIsEn && !bIsEn) return -1;
|
||||
if (!aIsEn && bIsEn) return 1;
|
||||
|
||||
// Then sort by vote average (quality), then by resolution
|
||||
if (a.vote_average !== b.vote_average) {
|
||||
return b.vote_average - a.vote_average;
|
||||
}
|
||||
return (b.width * b.height) - (a.width * a.height);
|
||||
});
|
||||
|
||||
if (languageBackdrops.length > 0) {
|
||||
movieBackdropUrl = tmdbService.getImageUrl(languageBackdrops[0].file_path, 'original');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.warn('[useMetadata] Failed to fetch movie images for:', part.id, error);
|
||||
}
|
||||
|
||||
return {
|
||||
id: `tmdb:${part.id}`,
|
||||
type: 'movie',
|
||||
name: part.title,
|
||||
poster: part.poster_path ? tmdbService.getImageUrl(part.poster_path, 'w500') : 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Image',
|
||||
banner: movieBackdropUrl || (part.backdrop_path ? tmdbService.getImageUrl(part.backdrop_path, 'original') : undefined),
|
||||
year: part.release_date ? new Date(part.release_date).getFullYear() : undefined,
|
||||
description: part.overview,
|
||||
collection: {
|
||||
id: collectionDetails.id,
|
||||
name: collectionDetails.name,
|
||||
poster_path: collectionDetails.poster_path,
|
||||
backdrop_path: collectionDetails.backdrop_path
|
||||
}
|
||||
};
|
||||
})
|
||||
) as StreamingContent[];
|
||||
|
||||
setCollectionMovies(collectionMoviesData);
|
||||
|
||||
// Update metadata with collection info
|
||||
setMetadata((prev: any) => ({
|
||||
...prev,
|
||||
collection: {
|
||||
id: collectionDetails.id,
|
||||
name: collectionDetails.name,
|
||||
poster_path: collectionDetails.poster_path,
|
||||
backdrop_path: collectionDetails.backdrop_path
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[useMetadata] Error fetching collection:', error);
|
||||
} finally {
|
||||
setLoadingCollection(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2017,5 +2116,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
imdbId,
|
||||
scraperStatuses,
|
||||
activeFetchingScrapers,
|
||||
collectionMovies,
|
||||
loadingCollection,
|
||||
};
|
||||
};
|
||||
|
|
@ -251,16 +251,10 @@ export const useMetadataAssets = (
|
|||
|
||||
setLoadingBanner(true);
|
||||
|
||||
// Show fallback banner immediately to prevent blank state
|
||||
const fallbackBanner = metadata?.banner || metadata?.poster || null;
|
||||
if (fallbackBanner && !bannerImage) {
|
||||
setBannerImage(fallbackBanner);
|
||||
setBannerSource('default');
|
||||
}
|
||||
|
||||
// If enrichment is disabled, use addon banner and don't fetch from external sources
|
||||
if (!settings.enrichMetadataWithTMDB) {
|
||||
const addonBanner = metadata?.banner || metadata?.poster || null;
|
||||
const addonBanner = metadata?.banner || null;
|
||||
if (addonBanner && addonBanner !== bannerImage) {
|
||||
setBannerImage(addonBanner);
|
||||
setBannerSource('default');
|
||||
|
|
@ -312,15 +306,6 @@ export const useMetadataAssets = (
|
|||
finalBanner = tmdbService.getImageUrl(details.backdrop_path);
|
||||
bannerSourceType = 'tmdb';
|
||||
|
||||
// Preload the image
|
||||
if (finalBanner) {
|
||||
FastImage.preload([{ uri: finalBanner }]);
|
||||
}
|
||||
}
|
||||
else if (details?.poster_path) {
|
||||
finalBanner = tmdbService.getImageUrl(details.poster_path);
|
||||
bannerSourceType = 'tmdb';
|
||||
|
||||
// Preload the image
|
||||
if (finalBanner) {
|
||||
FastImage.preload([{ uri: finalBanner }]);
|
||||
|
|
@ -332,9 +317,9 @@ export const useMetadataAssets = (
|
|||
}
|
||||
}
|
||||
|
||||
// Final fallback to metadata
|
||||
// Final fallback to metadata banner only
|
||||
if (!finalBanner) {
|
||||
finalBanner = metadata?.banner || metadata?.poster || null;
|
||||
finalBanner = metadata?.banner || null;
|
||||
bannerSourceType = 'default';
|
||||
}
|
||||
|
||||
|
|
@ -346,8 +331,8 @@ export const useMetadataAssets = (
|
|||
|
||||
forcedBannerRefreshDone.current = true;
|
||||
} catch (error) {
|
||||
// Use default banner on error
|
||||
const defaultBanner = metadata?.banner || metadata?.poster || null;
|
||||
// Use default banner on error (only addon banner)
|
||||
const defaultBanner = metadata?.banner || null;
|
||||
if (defaultBanner !== bannerImage) {
|
||||
setBannerImage(defaultBanner);
|
||||
setBannerSource('default');
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ export interface AppSettings {
|
|||
// Continue Watching behavior
|
||||
useCachedStreams: boolean; // Enable/disable direct player navigation from Continue Watching cache
|
||||
openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen
|
||||
streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour)
|
||||
enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
|
|
@ -143,6 +145,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
// Continue Watching behavior
|
||||
useCachedStreams: false, // Enable by default
|
||||
openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled
|
||||
streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds
|
||||
enableStreamsBackdrop: true, // Enable by default (new behavior)
|
||||
};
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'app_settings';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useRef, useMemo } from 'react';
|
||||
import React, { useEffect, useRef, useMemo, useState } from 'react';
|
||||
import { NavigationContainer, DefaultTheme as NavigationDefaultTheme, DarkTheme as NavigationDarkTheme, Theme, NavigationProp } from '@react-navigation/native';
|
||||
import { createNativeStackNavigator, NativeStackNavigationOptions, NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { createBottomTabNavigator, BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
|
||||
|
|
@ -68,6 +68,7 @@ import AIChatScreen from '../screens/AIChatScreen';
|
|||
import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
|
||||
import BackupScreen from '../screens/BackupScreen';
|
||||
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
|
||||
import ContributorsScreen from '../screens/ContributorsScreen';
|
||||
|
||||
// Stack navigator types
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -175,6 +176,7 @@ export type RootStackParamList = {
|
|||
title: string;
|
||||
};
|
||||
ContinueWatchingSettings: undefined;
|
||||
Contributors: undefined;
|
||||
};
|
||||
|
||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -428,11 +430,21 @@ const TabIcon = React.memo(({ focused, color, iconName, iconLibrary = 'material'
|
|||
|
||||
// Update the TabScreenWrapper component with fixed layout dimensions
|
||||
const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) => {
|
||||
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Dimensions.addEventListener('change', ({ window }) => {
|
||||
setDimensions(window);
|
||||
});
|
||||
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
|
||||
const isTablet = useMemo(() => {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const { width, height } = dimensions;
|
||||
const smallestDimension = Math.min(width, height);
|
||||
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
|
||||
}, []);
|
||||
}, [dimensions]);
|
||||
const insets = useSafeAreaInsets();
|
||||
// Force consistent status bar settings
|
||||
useEffect(() => {
|
||||
|
|
@ -498,6 +510,15 @@ const MainTabs = () => {
|
|||
const { useSettings: useSettingsHook } = require('../hooks/useSettings');
|
||||
const { settings: appSettings } = useSettingsHook();
|
||||
const [hasUpdateBadge, setHasUpdateBadge] = React.useState(false);
|
||||
const [dimensions, setDimensions] = useState(Dimensions.get('window'));
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Dimensions.addEventListener('change', ({ window }) => {
|
||||
setDimensions(window);
|
||||
});
|
||||
|
||||
return () => subscription?.remove();
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
if (Platform.OS !== 'android') return;
|
||||
let mounted = true;
|
||||
|
|
@ -531,10 +552,10 @@ const MainTabs = () => {
|
|||
}, []);
|
||||
const { isHomeLoading } = useLoading();
|
||||
const isTablet = useMemo(() => {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const { width, height } = dimensions;
|
||||
const smallestDimension = Math.min(width, height);
|
||||
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
|
||||
}, []);
|
||||
}, [dimensions]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const isIosTablet = Platform.OS === 'ios' && isTablet;
|
||||
const [hidden, setHidden] = React.useState(HeaderVisibility.isHidden());
|
||||
|
|
@ -673,7 +694,7 @@ const MainTabs = () => {
|
|||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 85 + insets.bottom,
|
||||
height: Platform.OS === 'android' ? 70 + insets.bottom : 85 + insets.bottom,
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
|
|
@ -723,8 +744,8 @@ const MainTabs = () => {
|
|||
<View
|
||||
style={{
|
||||
height: '100%',
|
||||
paddingBottom: 20 + insets.bottom,
|
||||
paddingTop: 12,
|
||||
paddingBottom: Platform.OS === 'android' ? 15 + insets.bottom : 20 + insets.bottom,
|
||||
paddingTop: Platform.OS === 'android' ? 8 : 12,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
|
|
@ -1283,6 +1304,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="Contributors"
|
||||
component={ContributorsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'default',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="HeroCatalogs"
|
||||
component={HeroCatalogsScreen}
|
||||
|
|
|
|||
|
|
@ -18,11 +18,29 @@ import { useTheme } from '../contexts/ThemeContext';
|
|||
import { useSettings } from '../hooks/useSettings';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
// TTL options in milliseconds - organized in rows
|
||||
const TTL_OPTIONS = [
|
||||
[
|
||||
{ label: '15 min', value: 15 * 60 * 1000 },
|
||||
{ label: '30 min', value: 30 * 60 * 1000 },
|
||||
{ label: '1 hour', value: 60 * 60 * 1000 },
|
||||
],
|
||||
[
|
||||
{ label: '2 hours', value: 2 * 60 * 60 * 1000 },
|
||||
{ label: '6 hours', value: 6 * 60 * 60 * 1000 },
|
||||
{ label: '12 hours', value: 12 * 60 * 60 * 1000 },
|
||||
],
|
||||
[
|
||||
{ label: '24 hours', value: 24 * 60 * 60 * 1000 },
|
||||
],
|
||||
];
|
||||
|
||||
const ContinueWatchingSettingsScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { currentTheme } = useTheme();
|
||||
const colors = currentTheme.colors;
|
||||
const styles = createStyles(colors);
|
||||
const [showSavedIndicator, setShowSavedIndicator] = useState(false);
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
|
|
@ -96,7 +114,6 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
|
|||
styles.settingItem,
|
||||
{
|
||||
borderBottomColor: isLast ? 'transparent' : colors.border,
|
||||
backgroundColor: colors.elevation1
|
||||
}
|
||||
]}>
|
||||
<View style={styles.settingContent}>
|
||||
|
|
@ -111,31 +128,52 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
|
|||
</View>
|
||||
);
|
||||
|
||||
const SectionHeader = ({ title }: { title: string }) => (
|
||||
<View style={[styles.sectionHeader, { backgroundColor: colors.darkBackground }]}>
|
||||
<Text style={[styles.sectionTitle, { color: colors.highEmphasis }]}>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const TTLPickerItem = ({ option }: { option: { label: string; value: number } }) => {
|
||||
const isSelected = settings.streamCacheTTL === option.value;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.ttlOption,
|
||||
{
|
||||
backgroundColor: isSelected ? colors.primary : colors.elevation1,
|
||||
borderColor: isSelected ? colors.primary : colors.border,
|
||||
}
|
||||
]}
|
||||
onPress={() => handleUpdateSetting('streamCacheTTL', option.value)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[
|
||||
styles.ttlOptionText,
|
||||
{ color: isSelected ? colors.white : colors.highEmphasis }
|
||||
]}>
|
||||
{option.label}
|
||||
</Text>
|
||||
{isSelected && (
|
||||
<MaterialIcons name="check" size={20} color={colors.white} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { backgroundColor: colors.darkBackground }]}>
|
||||
<TouchableOpacity
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={handleBack}
|
||||
>
|
||||
<MaterialIcons name="chevron-left" size={28} color={colors.primary} />
|
||||
<Text style={[styles.backText, { color: colors.primary }]}>Settings</Text>
|
||||
<MaterialIcons name="chevron-left" size={28} color={colors.white} />
|
||||
<Text style={styles.backText}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: colors.highEmphasis }]}>
|
||||
Continue Watching
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.headerTitle}>
|
||||
Continue Watching
|
||||
</Text>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollView
|
||||
|
|
@ -143,9 +181,9 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
|
|||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.contentContainer}
|
||||
>
|
||||
<SectionHeader title="PLAYBACK BEHAVIOR" />
|
||||
|
||||
<View style={[styles.settingsCard, { backgroundColor: colors.elevation1 }]}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>PLAYBACK BEHAVIOR</Text>
|
||||
<View style={styles.settingsCard}>
|
||||
<SettingItem
|
||||
title="Use Cached Streams"
|
||||
description="When enabled, clicking Continue Watching items will open the player directly using previously played streams. When disabled, opens a content screen instead."
|
||||
|
|
@ -162,9 +200,52 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
|
|||
isLast={true}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={[styles.infoCard, { backgroundColor: colors.elevation1 }]}>
|
||||
{settings.useCachedStreams && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>CACHE SETTINGS</Text>
|
||||
<View style={styles.settingsCard}>
|
||||
<View style={[styles.settingItem, { borderBottomWidth: 0, flexDirection: 'column', alignItems: 'flex-start' }]}>
|
||||
<Text style={[styles.settingTitle, { color: colors.highEmphasis, marginBottom: 8 }]}>
|
||||
Stream Cache Duration
|
||||
</Text>
|
||||
<Text style={[styles.settingDescription, { color: colors.mediumEmphasis, marginBottom: 16 }]}>
|
||||
How long to keep cached stream links before they expire
|
||||
</Text>
|
||||
<View style={styles.ttlOptionsContainer}>
|
||||
{TTL_OPTIONS.map((row, rowIndex) => (
|
||||
<View key={rowIndex} style={styles.ttlRow}>
|
||||
{row.map((option) => (
|
||||
<TTLPickerItem key={option.value} option={option} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{settings.useCachedStreams && (
|
||||
<View style={styles.section}>
|
||||
<View style={[styles.warningCard, { borderColor: colors.warning }]}>
|
||||
<View style={styles.warningHeader}>
|
||||
<MaterialIcons name="warning" size={20} color={colors.warning} />
|
||||
<Text style={[styles.warningTitle, { color: colors.warning }]}>
|
||||
Important Note
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.warningText, { color: colors.mediumEmphasis }]}>
|
||||
Not all stream links may remain active for the full cache duration. Longer cache times may result in expired links. If a cached link fails, the app will fall back to fetching fresh streams.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.infoCard}>
|
||||
<View style={styles.infoHeader}>
|
||||
<MaterialIcons name="info" size={20} color={colors.primary} />
|
||||
<Text style={[styles.infoTitle, { color: colors.highEmphasis }]}>
|
||||
|
|
@ -172,12 +253,24 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
|
|||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.infoText, { color: colors.mediumEmphasis }]}>
|
||||
• Streams are cached for 1 hour after playing{'\n'}
|
||||
• Cached streams are validated before use{'\n'}
|
||||
• If cache is invalid or expired, falls back to content screen{'\n'}
|
||||
• "Use Cached Streams" controls direct player vs screen navigation{'\n'}
|
||||
• "Open Metadata Screen" appears only when cached streams are disabled
|
||||
{settings.useCachedStreams ? (
|
||||
<>
|
||||
• Streams are cached for your selected duration after playing{'\n'}
|
||||
• Cached streams are validated before use{'\n'}
|
||||
• If cache is invalid or expired, falls back to content screen{'\n'}
|
||||
• "Use Cached Streams" controls direct player vs screen navigation{'\n'}
|
||||
• "Open Metadata Screen" appears only when cached streams are disabled
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
• When cached streams are disabled, clicking Continue Watching items opens content screens{'\n'}
|
||||
• "Open Metadata Screen" option controls which screen to open{'\n'}
|
||||
• Metadata screen shows content details and allows manual stream selection{'\n'}
|
||||
• Streams screen shows available streams for immediate playback
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
|
|
@ -198,31 +291,35 @@ const ContinueWatchingSettingsScreen: React.FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Create a styles creator function that accepts the theme colors
|
||||
const createStyles = (colors: any) => StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: Platform.OS === 'ios' ? 0 : 12,
|
||||
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 8 : 8,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 16,
|
||||
padding: 8,
|
||||
},
|
||||
backText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginLeft: 4,
|
||||
fontSize: 17,
|
||||
fontWeight: '400',
|
||||
color: colors.primary,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontSize: 34,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
color: colors.white,
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
paddingTop: 8,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
|
|
@ -230,26 +327,33 @@ const styles = StyleSheet.create({
|
|||
contentContainer: {
|
||||
paddingBottom: 100,
|
||||
},
|
||||
sectionHeader: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: 24,
|
||||
section: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '700',
|
||||
fontWeight: '600',
|
||||
color: colors.mediumGray,
|
||||
marginHorizontal: 16,
|
||||
marginBottom: 8,
|
||||
letterSpacing: 0.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
settingsCard: {
|
||||
marginHorizontal: 16,
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
|
|
@ -269,8 +373,14 @@ const styles = StyleSheet.create({
|
|||
infoCard: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
padding: 16,
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
infoHeader: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -304,6 +414,58 @@ const styles = StyleSheet.create({
|
|||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
ttlOptionsContainer: {
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
},
|
||||
ttlRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
gap: 8,
|
||||
},
|
||||
ttlOption: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
ttlOptionText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
warningCard: {
|
||||
marginHorizontal: 16,
|
||||
marginTop: 16,
|
||||
backgroundColor: colors.elevation2,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
borderWidth: 1,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 2,
|
||||
},
|
||||
warningHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
warningTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginLeft: 8,
|
||||
},
|
||||
warningText: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default ContinueWatchingSettingsScreen;
|
||||
|
|
|
|||
568
src/screens/ContributorsScreen.tsx
Normal file
568
src/screens/ContributorsScreen.tsx
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
Platform,
|
||||
Dimensions,
|
||||
Linking,
|
||||
RefreshControl,
|
||||
FlatList,
|
||||
ActivityIndicator
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { Feather } from '@expo/vector-icons';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { fetchContributors, GitHubContributor } from '../services/githubReleaseService';
|
||||
import { RootStackParamList } from '../navigation/AppNavigator';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
const isLargeTablet = width >= 1024;
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
interface ContributorCardProps {
|
||||
contributor: GitHubContributor;
|
||||
currentTheme: any;
|
||||
isTablet: boolean;
|
||||
isLargeTablet: boolean;
|
||||
}
|
||||
|
||||
const ContributorCard: React.FC<ContributorCardProps> = ({ contributor, currentTheme, isTablet, isLargeTablet }) => {
|
||||
const handlePress = useCallback(() => {
|
||||
Linking.openURL(contributor.html_url);
|
||||
}, [contributor.html_url]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.contributorCard,
|
||||
{ backgroundColor: currentTheme.colors.elevation1 },
|
||||
isTablet && styles.tabletContributorCard
|
||||
]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FastImage
|
||||
source={{ uri: contributor.avatar_url }}
|
||||
style={[
|
||||
styles.avatar,
|
||||
isTablet && styles.tabletAvatar
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
<View style={styles.contributorInfo}>
|
||||
<Text style={[
|
||||
styles.username,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
isTablet && styles.tabletUsername
|
||||
]}>
|
||||
{contributor.login}
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.contributions,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
isTablet && styles.tabletContributions
|
||||
]}>
|
||||
{contributor.contributions} contributions
|
||||
</Text>
|
||||
</View>
|
||||
<Feather
|
||||
name="external-link"
|
||||
size={isTablet ? 20 : 16}
|
||||
color={currentTheme.colors.mediumEmphasis}
|
||||
style={styles.externalIcon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const ContributorsScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [contributors, setContributors] = useState<GitHubContributor[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadContributors = useCallback(async (isRefresh = false) => {
|
||||
try {
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
// Check cache first (unless refreshing)
|
||||
if (!isRefresh) {
|
||||
try {
|
||||
const cachedData = await AsyncStorage.getItem('github_contributors');
|
||||
const cacheTimestamp = await AsyncStorage.getItem('github_contributors_timestamp');
|
||||
const now = Date.now();
|
||||
const ONE_HOUR = 60 * 60 * 1000; // 1 hour cache
|
||||
|
||||
if (cachedData && cacheTimestamp) {
|
||||
const timestamp = parseInt(cacheTimestamp, 10);
|
||||
if (now - timestamp < ONE_HOUR) {
|
||||
const parsedData = JSON.parse(cachedData);
|
||||
// Only use cache if it has actual contributors data
|
||||
if (parsedData && Array.isArray(parsedData) && parsedData.length > 0) {
|
||||
setContributors(parsedData);
|
||||
setLoading(false);
|
||||
return;
|
||||
} else {
|
||||
// Remove invalid cache
|
||||
await AsyncStorage.removeItem('github_contributors');
|
||||
await AsyncStorage.removeItem('github_contributors_timestamp');
|
||||
if (__DEV__) console.log('Removed invalid contributors cache');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (cacheError) {
|
||||
if (__DEV__) console.error('Cache read error:', cacheError);
|
||||
// Remove corrupted cache
|
||||
try {
|
||||
await AsyncStorage.removeItem('github_contributors');
|
||||
await AsyncStorage.removeItem('github_contributors_timestamp');
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const data = await fetchContributors();
|
||||
if (data && Array.isArray(data) && data.length > 0) {
|
||||
setContributors(data);
|
||||
// Only cache valid data
|
||||
try {
|
||||
await AsyncStorage.setItem('github_contributors', JSON.stringify(data));
|
||||
await AsyncStorage.setItem('github_contributors_timestamp', Date.now().toString());
|
||||
} catch (cacheError) {
|
||||
if (__DEV__) console.error('Cache write error:', cacheError);
|
||||
}
|
||||
} else {
|
||||
// Clear any existing cache if we get invalid data
|
||||
try {
|
||||
await AsyncStorage.removeItem('github_contributors');
|
||||
await AsyncStorage.removeItem('github_contributors_timestamp');
|
||||
} catch {}
|
||||
setError('Unable to load contributors. This might be due to GitHub API rate limits.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to load contributors. Please check your internet connection.');
|
||||
if (__DEV__) console.error('Error loading contributors:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Clear any invalid cache on mount
|
||||
const clearInvalidCache = async () => {
|
||||
try {
|
||||
const cachedData = await AsyncStorage.getItem('github_contributors');
|
||||
if (cachedData) {
|
||||
const parsedData = JSON.parse(cachedData);
|
||||
if (!parsedData || !Array.isArray(parsedData) || parsedData.length === 0) {
|
||||
await AsyncStorage.removeItem('github_contributors');
|
||||
await AsyncStorage.removeItem('github_contributors_timestamp');
|
||||
if (__DEV__) console.log('Cleared invalid cache on mount');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error checking cache on mount:', error);
|
||||
}
|
||||
};
|
||||
|
||||
clearInvalidCache();
|
||||
loadContributors();
|
||||
}, [loadContributors]);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
loadContributors(true);
|
||||
}, [loadContributors]);
|
||||
|
||||
const renderContributor = useCallback(({ item }: { item: GitHubContributor }) => (
|
||||
<ContributorCard
|
||||
contributor={item}
|
||||
currentTheme={currentTheme}
|
||||
isTablet={isTablet}
|
||||
isLargeTablet={isLargeTablet}
|
||||
/>
|
||||
), [currentTheme]);
|
||||
|
||||
const keyExtractor = useCallback((item: GitHubContributor) => item.id.toString(), []);
|
||||
|
||||
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top);
|
||||
|
||||
if (loading && !refreshing) {
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
{ backgroundColor: currentTheme.colors.darkBackground }
|
||||
]}>
|
||||
<StatusBar barStyle={'light-content'} />
|
||||
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Feather name="chevron-left" size={24} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.headerTitle,
|
||||
{ color: currentTheme.colors.text },
|
||||
isTablet && styles.tabletHeaderTitle
|
||||
]}>
|
||||
Contributors
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Loading contributors...
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.container,
|
||||
{ backgroundColor: currentTheme.colors.darkBackground }
|
||||
]}>
|
||||
<StatusBar barStyle={'light-content'} />
|
||||
|
||||
<View style={[styles.headerContainer, { paddingTop: topSpacing }]}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={() => navigation.goBack()}
|
||||
>
|
||||
<Feather name="chevron-left" size={24} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.backText, { color: currentTheme.colors.primary }]}>Settings</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.headerTitle,
|
||||
{ color: currentTheme.colors.text },
|
||||
isTablet && styles.tabletHeaderTitle
|
||||
]}>
|
||||
Contributors
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<View style={[styles.contentContainer, isTablet && styles.tabletContentContainer]}>
|
||||
{error ? (
|
||||
<View style={styles.errorContainer}>
|
||||
<Feather name="alert-circle" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.errorText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{error}
|
||||
</Text>
|
||||
<Text style={[styles.errorSubtext, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
GitHub API rate limit exceeded. Please try again later or pull to refresh.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => loadContributors()}
|
||||
>
|
||||
<Text style={[styles.retryText, { color: currentTheme.colors.white }]}>
|
||||
Try Again
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : contributors.length === 0 ? (
|
||||
<View style={styles.emptyContainer}>
|
||||
<Feather name="users" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
No contributors found
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
isTablet && styles.tabletListContent
|
||||
]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={currentTheme.colors.primary}
|
||||
colors={[currentTheme.colors.primary]}
|
||||
/>
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<View style={[
|
||||
styles.gratitudeCard,
|
||||
{ backgroundColor: currentTheme.colors.elevation1 },
|
||||
isTablet && styles.tabletGratitudeCard
|
||||
]}>
|
||||
<View style={styles.gratitudeContent}>
|
||||
<Feather name="heart" size={isTablet ? 32 : 24} color={currentTheme.colors.primary} />
|
||||
<Text style={[
|
||||
styles.gratitudeText,
|
||||
{ color: currentTheme.colors.highEmphasis },
|
||||
isTablet && styles.tabletGratitudeText
|
||||
]}>
|
||||
We're grateful for every contribution
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.gratitudeSubtext,
|
||||
{ color: currentTheme.colors.mediumEmphasis },
|
||||
isTablet && styles.tabletGratitudeSubtext
|
||||
]}>
|
||||
Each line of code, bug report, and suggestion helps make Nuvio better for everyone
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<FlatList
|
||||
data={contributors}
|
||||
renderItem={renderContributor}
|
||||
keyExtractor={keyExtractor}
|
||||
numColumns={isTablet ? 2 : 1}
|
||||
key={isTablet ? 'tablet' : 'mobile'}
|
||||
scrollEnabled={false}
|
||||
showsVerticalScrollIndicator={false}
|
||||
columnWrapperStyle={isTablet ? styles.tabletRow : undefined}
|
||||
/>
|
||||
</ScrollView>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
headerContainer: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 8,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 2,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
},
|
||||
backButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
backText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginLeft: 4,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.3,
|
||||
paddingLeft: 4,
|
||||
},
|
||||
tabletHeaderTitle: {
|
||||
fontSize: 40,
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
tabletContentContainer: {
|
||||
maxWidth: 1000,
|
||||
width: '100%',
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
gratitudeCard: {
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
borderRadius: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
tabletGratitudeCard: {
|
||||
padding: 32,
|
||||
marginBottom: 32,
|
||||
borderRadius: 24,
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
},
|
||||
gratitudeContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
gratitudeText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginTop: 12,
|
||||
marginBottom: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
tabletGratitudeText: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
marginTop: 16,
|
||||
marginBottom: 12,
|
||||
},
|
||||
gratitudeSubtext: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
opacity: 0.8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
tabletGratitudeSubtext: {
|
||||
fontSize: 17,
|
||||
lineHeight: 26,
|
||||
maxWidth: 600,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 16,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
errorSubtext: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
opacity: 0.7,
|
||||
marginBottom: 24,
|
||||
},
|
||||
retryButton: {
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 8,
|
||||
},
|
||||
retryText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
marginTop: 16,
|
||||
},
|
||||
listContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 20,
|
||||
},
|
||||
tabletListContent: {
|
||||
paddingHorizontal: 32,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
tabletRow: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
contributorCard: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 16,
|
||||
marginBottom: 12,
|
||||
borderRadius: 16,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
tabletContributorCard: {
|
||||
padding: 20,
|
||||
marginBottom: 16,
|
||||
marginHorizontal: 6,
|
||||
borderRadius: 20,
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
width: '48%',
|
||||
},
|
||||
avatar: {
|
||||
width: 60,
|
||||
height: 60,
|
||||
borderRadius: 30,
|
||||
marginRight: 16,
|
||||
},
|
||||
tabletAvatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
marginRight: 20,
|
||||
},
|
||||
contributorInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
username: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
tabletUsername: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
contributions: {
|
||||
fontSize: 14,
|
||||
opacity: 0.8,
|
||||
},
|
||||
tabletContributions: {
|
||||
fontSize: 16,
|
||||
},
|
||||
externalIcon: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default ContributorsScreen;
|
||||
|
|
@ -9,6 +9,7 @@ import {
|
|||
StatusBar,
|
||||
useColorScheme,
|
||||
Dimensions,
|
||||
useWindowDimensions,
|
||||
ImageBackground,
|
||||
ScrollView,
|
||||
Platform,
|
||||
|
|
@ -389,7 +390,10 @@ const HomeScreen = () => {
|
|||
|
||||
// Allow free rotation on tablets; lock portrait on phones
|
||||
try {
|
||||
const isTabletDevice = Platform.OS === 'ios' ? (Platform as any).isPad === true : isTablet;
|
||||
// Use device physical characteristics, not current orientation
|
||||
const isTabletDevice = Platform.OS === 'ios'
|
||||
? (Platform as any).isPad === true
|
||||
: Math.min(windowWidth, Dimensions.get('screen').height) >= 768;
|
||||
if (isTabletDevice) {
|
||||
ScreenOrientation.unlockAsync();
|
||||
} else {
|
||||
|
|
@ -616,11 +620,11 @@ const HomeScreen = () => {
|
|||
// Stable keyExtractor for FlashList
|
||||
const keyExtractor = useCallback((item: HomeScreenListItem) => item.key, []);
|
||||
|
||||
// Memoize device check to avoid repeated Dimensions.get calls
|
||||
// Use reactive window dimensions that update on orientation changes
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
const isTablet = useMemo(() => {
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
return deviceWidth >= 768;
|
||||
}, []);
|
||||
return windowWidth >= 768;
|
||||
}, [windowWidth]);
|
||||
|
||||
// Memoize individual section components to prevent re-renders
|
||||
const memoizedFeaturedContent = useMemo(() => {
|
||||
|
|
@ -640,7 +644,7 @@ const HomeScreen = () => {
|
|||
loading={featuredLoading}
|
||||
/>
|
||||
);
|
||||
}, [isTablet, settings.heroStyle, showHeroSection, featuredContentSource, featuredContent, allFeaturedContent, isSaved, handleSaveToLibrary, featuredLoading]);
|
||||
}, [isTablet, settings.heroStyle, showHeroSection, featuredContentSource, featuredLoading]);
|
||||
|
||||
const memoizedThisWeekSection = useMemo(() => <ThisWeekSection />, []);
|
||||
const memoizedContinueWatchingSection = useMemo(() => <ContinueWatchingSection ref={continueWatchingRef} />, []);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection'
|
|||
import { RatingsSection } from '../components/metadata/RatingsSection';
|
||||
import { CommentsSection, CommentBottomSheet } from '../components/metadata/CommentsSection';
|
||||
import TrailersSection from '../components/metadata/TrailersSection';
|
||||
import CollectionSection from '../components/metadata/CollectionSection';
|
||||
import { RouteParams, Episode } from '../types/metadata';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
|
|
@ -182,6 +183,8 @@ const MetadataScreen: React.FC = () => {
|
|||
setMetadata,
|
||||
imdbId,
|
||||
tmdbId,
|
||||
collectionMovies,
|
||||
loadingCollection,
|
||||
} = useMetadata({ id, type, addonId });
|
||||
|
||||
|
||||
|
|
@ -1245,6 +1248,18 @@ const MetadataScreen: React.FC = () => {
|
|||
</View>
|
||||
)}
|
||||
|
||||
{/* Collection Section - Lazy loaded */}
|
||||
{shouldLoadSecondaryData &&
|
||||
Object.keys(groupedEpisodes).length === 0 &&
|
||||
metadata?.collection &&
|
||||
settings.enrichMetadataWithTMDB && (
|
||||
<CollectionSection
|
||||
collectionName={metadata.collection.name}
|
||||
collectionMovies={collectionMovies}
|
||||
loadingCollection={loadingCollection}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Recommendations Section with skeleton when loading - Lazy loaded */}
|
||||
{type === 'movie' && shouldLoadSecondaryData && (
|
||||
<MemoizedMoreLikeThisSection
|
||||
|
|
|
|||
|
|
@ -1273,6 +1273,27 @@ const PluginsScreen: React.FC = () => {
|
|||
|
||||
const handleToggleLocalScrapers = async (enabled: boolean) => {
|
||||
await updateSetting('enableLocalScrapers', enabled);
|
||||
|
||||
// If enabling local scrapers, refresh repository and reload scrapers
|
||||
if (enabled) {
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
logger.log('[PluginsScreen] Enabling local scrapers - refreshing repository...');
|
||||
|
||||
// Refresh repository to ensure scrapers are available
|
||||
await localScraperService.refreshRepository();
|
||||
|
||||
// Reload scrapers to get the latest state
|
||||
await loadScrapers();
|
||||
|
||||
logger.log('[PluginsScreen] Local scrapers enabled and repository refreshed');
|
||||
} catch (error) {
|
||||
logger.error('[PluginsScreen] Failed to refresh repository when enabling local scrapers:', error);
|
||||
// Don't show error to user as the toggle still succeeded
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleUrlValidation = async (enabled: boolean) => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import { useCatalogContext } from '../contexts/CatalogContext';
|
|||
import { useTraktContext } from '../contexts/TraktContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { catalogService } from '../services/catalogService';
|
||||
import { fetchTotalDownloads } from '../services/githubReleaseService';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import * as Sentry from '@sentry/react-native';
|
||||
import { getDisplayedAppVersion } from '../utils/version';
|
||||
|
|
@ -291,6 +292,9 @@ const SettingsScreen: React.FC = () => {
|
|||
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
|
||||
const [openRouterKeySet, setOpenRouterKeySet] = useState<boolean>(false);
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState<boolean>(false);
|
||||
const [totalDownloads, setTotalDownloads] = useState<number | null>(null);
|
||||
const [displayDownloads, setDisplayDownloads] = useState<number | null>(null);
|
||||
const [isCountingUp, setIsCountingUp] = useState<boolean>(false);
|
||||
|
||||
// Add a useEffect to check Trakt authentication status on focus
|
||||
useEffect(() => {
|
||||
|
|
@ -346,6 +350,13 @@ const SettingsScreen: React.FC = () => {
|
|||
// Check OpenRouter API key status
|
||||
const openRouterKey = await AsyncStorage.getItem('openrouter_api_key');
|
||||
setOpenRouterKeySet(!!openRouterKey);
|
||||
|
||||
// Load GitHub total downloads (initial load only, polling happens in useEffect)
|
||||
const downloads = await fetchTotalDownloads();
|
||||
if (downloads !== null) {
|
||||
setTotalDownloads(downloads);
|
||||
setDisplayDownloads(downloads);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error loading settings data:', error);
|
||||
|
|
@ -366,6 +377,60 @@ const SettingsScreen: React.FC = () => {
|
|||
return unsubscribe;
|
||||
}, [navigation, loadData]);
|
||||
|
||||
// Poll GitHub downloads every 10 seconds when on the About section
|
||||
useEffect(() => {
|
||||
// Only poll when viewing the About section (where downloads counter is shown)
|
||||
const shouldPoll = isTablet ? selectedCategory === 'about' : true;
|
||||
|
||||
if (!shouldPoll) return;
|
||||
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const downloads = await fetchTotalDownloads();
|
||||
if (downloads !== null && downloads !== totalDownloads) {
|
||||
setTotalDownloads(downloads);
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error polling downloads:', error);
|
||||
}
|
||||
}, 3600000); // 3600000 milliseconds (1 hour)
|
||||
|
||||
return () => clearInterval(pollInterval);
|
||||
}, [selectedCategory, isTablet, totalDownloads]);
|
||||
|
||||
// Animate counting up when totalDownloads changes
|
||||
useEffect(() => {
|
||||
if (totalDownloads === null || displayDownloads === null) return;
|
||||
if (totalDownloads === displayDownloads) return;
|
||||
|
||||
setIsCountingUp(true);
|
||||
const start = displayDownloads;
|
||||
const end = totalDownloads;
|
||||
const duration = 2000; // 2 seconds animation
|
||||
const startTime = Date.now();
|
||||
|
||||
const animate = () => {
|
||||
const now = Date.now();
|
||||
const elapsed = now - startTime;
|
||||
const progress = Math.min(elapsed / duration, 1);
|
||||
|
||||
// Ease out quad for smooth deceleration
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 2);
|
||||
const current = Math.floor(start + (end - start) * easeProgress);
|
||||
|
||||
setDisplayDownloads(current);
|
||||
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(animate);
|
||||
} else {
|
||||
setDisplayDownloads(end);
|
||||
setIsCountingUp(false);
|
||||
}
|
||||
};
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
}, [totalDownloads]);
|
||||
|
||||
const handleResetSettings = useCallback(() => {
|
||||
openAlert(
|
||||
'Reset Settings',
|
||||
|
|
@ -518,9 +583,24 @@ const SettingsScreen: React.FC = () => {
|
|||
onValueChange={(value) => updateSetting('episodeLayoutStyle', value ? 'horizontal' : 'vertical')}
|
||||
/>
|
||||
)}
|
||||
isLast={true}
|
||||
isLast={isTablet}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
{!isTablet && (
|
||||
<SettingItem
|
||||
title="Streams Backdrop"
|
||||
description="Show blurred backdrop on mobile streams"
|
||||
icon="image"
|
||||
renderControl={() => (
|
||||
<CustomSwitch
|
||||
value={settings?.enableStreamsBackdrop ?? true}
|
||||
onValueChange={(value) => updateSetting('enableStreamsBackdrop', value)}
|
||||
/>
|
||||
)}
|
||||
isLast={true}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
)}
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
|
|
@ -637,6 +717,14 @@ const SettingsScreen: React.FC = () => {
|
|||
title="Version"
|
||||
description={getDisplayedAppVersion()}
|
||||
icon="info"
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
<SettingItem
|
||||
title="Contributors"
|
||||
description="View all contributors"
|
||||
icon="users"
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('Contributors')}
|
||||
isLast={true}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
|
|
@ -788,6 +876,17 @@ const SettingsScreen: React.FC = () => {
|
|||
|
||||
{selectedCategory === 'about' && (
|
||||
<>
|
||||
{displayDownloads !== null && (
|
||||
<View style={styles.downloadsContainer}>
|
||||
<Text style={[styles.downloadsNumber, { color: currentTheme.colors.primary }]}>
|
||||
{displayDownloads.toLocaleString()}
|
||||
</Text>
|
||||
<Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
downloads and counting
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Made with ❤️ by Tapframe and Friends
|
||||
|
|
@ -873,6 +972,17 @@ const SettingsScreen: React.FC = () => {
|
|||
{renderCategoryContent('developer')}
|
||||
{renderCategoryContent('cache')}
|
||||
|
||||
{displayDownloads !== null && (
|
||||
<View style={styles.downloadsContainer}>
|
||||
<Text style={[styles.downloadsNumber, { color: currentTheme.colors.primary }]}>
|
||||
{displayDownloads.toLocaleString()}
|
||||
</Text>
|
||||
<Text style={[styles.downloadsLabel, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
downloads and counting
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.footer}>
|
||||
<Text style={[styles.footerText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Made with ❤️ by Tapframe and friends
|
||||
|
|
@ -1194,6 +1304,24 @@ const styles = StyleSheet.create({
|
|||
height: 32,
|
||||
width: 150,
|
||||
},
|
||||
downloadsContainer: {
|
||||
marginTop: 20,
|
||||
marginBottom: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
downloadsNumber: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
},
|
||||
downloadsLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
opacity: 0.6,
|
||||
letterSpacing: 1.2,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
loadingSpinner: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -125,6 +125,12 @@ export interface StreamingContent {
|
|||
originCountry?: string[];
|
||||
tagline?: string;
|
||||
};
|
||||
collection?: {
|
||||
id: number;
|
||||
name: string;
|
||||
poster_path?: string;
|
||||
backdrop_path?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CatalogContent {
|
||||
|
|
|
|||
|
|
@ -59,4 +59,61 @@ export function isAnyUpgrade(current: string, latest: string): boolean {
|
|||
return b[2] > a[2];
|
||||
}
|
||||
|
||||
export async function fetchTotalDownloads(): Promise<number | null> {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/tapframe/NuvioStreaming/releases', {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': `Nuvio/${Platform.OS}`,
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
const releases = await res.json();
|
||||
|
||||
let total = 0;
|
||||
releases.forEach((release: any) => {
|
||||
if (release.assets && Array.isArray(release.assets)) {
|
||||
release.assets.forEach((asset: any) => {
|
||||
total += asset.download_count || 0;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return total;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export interface GitHubContributor {
|
||||
login: string;
|
||||
id: number;
|
||||
avatar_url: string;
|
||||
html_url: string;
|
||||
contributions: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export async function fetchContributors(): Promise<GitHubContributor[] | null> {
|
||||
try {
|
||||
const res = await fetch('https://api.github.com/repos/tapframe/NuvioStreaming/contributors', {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'User-Agent': `Nuvio/${Platform.OS}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (__DEV__) console.error('GitHub API error:', res.status, res.statusText);
|
||||
return null;
|
||||
}
|
||||
|
||||
const contributors = await res.json();
|
||||
return contributors;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error fetching contributors:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -864,6 +864,30 @@ class LocalScraperService {
|
|||
async getStreams(type: string, tmdbId: string, season?: number, episode?: number, callback?: ScraperCallback): Promise<void> {
|
||||
await this.ensureInitialized();
|
||||
|
||||
// Check if local scrapers are enabled
|
||||
const userSettings = await this.getUserScraperSettings();
|
||||
if (!userSettings.enableLocalScrapers) {
|
||||
logger.log('[LocalScraperService] Local scrapers are disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
// If no repository is configured, return early
|
||||
if (!this.repositoryUrl) {
|
||||
logger.log('[LocalScraperService] No repository URL configured');
|
||||
return;
|
||||
}
|
||||
|
||||
// If no scrapers are installed, try to refresh repository
|
||||
if (this.installedScrapers.size === 0) {
|
||||
logger.log('[LocalScraperService] No scrapers installed, attempting to refresh repository');
|
||||
try {
|
||||
await this.performRepositoryRefresh();
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperService] Failed to refresh repository for getStreams:', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get available scrapers from manifest (respects manifestEnabled)
|
||||
const availableScrapers = await this.getAvailableScrapers();
|
||||
const enabledScrapers = availableScrapers
|
||||
|
|
@ -1299,6 +1323,23 @@ class LocalScraperService {
|
|||
return false;
|
||||
}
|
||||
|
||||
// If no repository is configured, return false
|
||||
if (!this.repositoryUrl) {
|
||||
logger.log('[LocalScraperService] No repository URL configured');
|
||||
return false;
|
||||
}
|
||||
|
||||
// If no scrapers are installed, try to refresh repository
|
||||
if (this.installedScrapers.size === 0) {
|
||||
logger.log('[LocalScraperService] No scrapers installed, attempting to refresh repository');
|
||||
try {
|
||||
await this.performRepositoryRefresh();
|
||||
} catch (error) {
|
||||
logger.error('[LocalScraperService] Failed to refresh repository for hasScrapers check:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if there are any enabled scrapers based on user settings
|
||||
if (userSettings.enabledScrapers && userSettings.enabledScrapers.size > 0) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ export interface CachedStream {
|
|||
season?: number;
|
||||
episode?: number;
|
||||
episodeTitle?: string;
|
||||
imdbId?: string; // IMDb ID for subtitle fetching
|
||||
timestamp: number; // When it was cached
|
||||
url: string; // Stream URL for quick validation
|
||||
}
|
||||
|
|
@ -17,7 +18,7 @@ export interface StreamCacheEntry {
|
|||
expiresAt: number; // Timestamp when cache expires
|
||||
}
|
||||
|
||||
const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds
|
||||
const DEFAULT_CACHE_DURATION = 60 * 60 * 1000; // 1 hour in milliseconds (fallback)
|
||||
const CACHE_KEY_PREFIX = 'stream_cache_';
|
||||
|
||||
class StreamCacheService {
|
||||
|
|
@ -32,7 +33,9 @@ class StreamCacheService {
|
|||
episodeId?: string,
|
||||
season?: number,
|
||||
episode?: number,
|
||||
episodeTitle?: string
|
||||
episodeTitle?: string,
|
||||
imdbId?: string,
|
||||
cacheDuration?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cacheKey = this.getCacheKey(id, type, episodeId);
|
||||
|
|
@ -45,20 +48,23 @@ class StreamCacheService {
|
|||
season,
|
||||
episode,
|
||||
episodeTitle,
|
||||
imdbId,
|
||||
timestamp: now,
|
||||
url: stream.url
|
||||
};
|
||||
|
||||
const ttl = cacheDuration || DEFAULT_CACHE_DURATION;
|
||||
const cacheEntry: StreamCacheEntry = {
|
||||
cachedStream,
|
||||
expiresAt: now + CACHE_DURATION
|
||||
expiresAt: now + ttl
|
||||
};
|
||||
|
||||
await AsyncStorage.setItem(cacheKey, JSON.stringify(cacheEntry));
|
||||
logger.log(`💾 [StreamCache] Saved stream cache for ${type}:${id}${episodeId ? `:${episodeId}` : ''}`);
|
||||
logger.log(`💾 [StreamCache] Cache key: ${cacheKey}`);
|
||||
logger.log(`💾 [StreamCache] Stream URL: ${stream.url}`);
|
||||
logger.log(`💾 [StreamCache] Expires at: ${new Date(now + CACHE_DURATION).toISOString()}`);
|
||||
logger.log(`💾 [StreamCache] TTL: ${ttl / 1000 / 60} minutes`);
|
||||
logger.log(`💾 [StreamCache] Expires at: ${new Date(now + ttl).toISOString()}`);
|
||||
} catch (error) {
|
||||
logger.warn('[StreamCache] Failed to save stream to cache:', error);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,32 @@ export interface TMDBTrendingResult {
|
|||
};
|
||||
}
|
||||
|
||||
export interface TMDBCollection {
|
||||
id: number;
|
||||
name: string;
|
||||
overview: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
parts: TMDBCollectionPart[];
|
||||
}
|
||||
|
||||
export interface TMDBCollectionPart {
|
||||
id: number;
|
||||
title: string;
|
||||
overview: string;
|
||||
poster_path: string | null;
|
||||
backdrop_path: string | null;
|
||||
release_date: string;
|
||||
adult: boolean;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
genre_ids: number[];
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
popularity: number;
|
||||
}
|
||||
|
||||
export class TMDBService {
|
||||
private static instance: TMDBService;
|
||||
private static ratingCache: Map<string, number | null> = new Map();
|
||||
|
|
@ -604,6 +630,41 @@ export class TMDBService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection details by collection ID
|
||||
*/
|
||||
async getCollectionDetails(collectionId: number, language: string = 'en'): Promise<TMDBCollection | null> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/collection/${collectionId}`, {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language,
|
||||
}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get collection images by collection ID
|
||||
*/
|
||||
async getCollectionImages(collectionId: number, language: string = 'en'): Promise<any> {
|
||||
try {
|
||||
const response = await axios.get(`${BASE_URL}/collection/${collectionId}/images`, {
|
||||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language,
|
||||
include_image_language: `${language},en,null`
|
||||
}),
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get movie images (logos, posters, backdrops) by TMDB ID - returns full images object
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -53,29 +53,31 @@ export class TrailerService {
|
|||
* @returns Promise<string | null> - The trailer URL or null if not found
|
||||
*/
|
||||
private static async getTrailerFromLocalServer(title: string, year: number, tmdbId?: string, type?: 'movie' | 'tv'): Promise<string | null> {
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
||||
|
||||
// Build URL with parameters
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Always send title and year for logging and fallback
|
||||
params.append('title', title);
|
||||
params.append('year', year.toString());
|
||||
|
||||
if (tmdbId) {
|
||||
params.append('tmdbId', tmdbId);
|
||||
params.append('type', type || 'movie');
|
||||
logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`);
|
||||
} else {
|
||||
logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
|
||||
}
|
||||
|
||||
const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`;
|
||||
logger.info('TrailerService', `Local server request URL: ${url}`);
|
||||
logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`);
|
||||
logger.info('TrailerService', `Making fetch request to: ${url}`);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.TIMEOUT);
|
||||
|
||||
// Build URL with parameters
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Always send title and year for logging and fallback
|
||||
params.append('title', title);
|
||||
params.append('year', year.toString());
|
||||
|
||||
if (tmdbId) {
|
||||
params.append('tmdbId', tmdbId);
|
||||
params.append('type', type || 'movie');
|
||||
logger.info('TrailerService', `Using TMDB API for: ${title} (TMDB ID: ${tmdbId})`);
|
||||
} else {
|
||||
logger.info('TrailerService', `Auto-searching trailer for: ${title} (${year})`);
|
||||
}
|
||||
|
||||
const url = `${this.AUTO_SEARCH_URL}?${params.toString()}`;
|
||||
logger.info('TrailerService', `Local server request URL: ${url}`);
|
||||
logger.info('TrailerService', `Local server timeout set to ${this.TIMEOUT}ms`);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
|
|
@ -85,6 +87,8 @@ export class TrailerService {
|
|||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
logger.info('TrailerService', `Fetch request completed. Response status: ${response.status}`);
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
|
|
@ -137,6 +141,12 @@ export class TrailerService {
|
|||
} else {
|
||||
const msg = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
logger.error('TrailerService', `Error in auto-search: ${msg}`);
|
||||
logger.error('TrailerService', `Error details:`, {
|
||||
name: (error as any)?.name,
|
||||
message: (error as any)?.message,
|
||||
stack: (error as any)?.stack,
|
||||
url: url
|
||||
});
|
||||
}
|
||||
return null; // Return null to trigger XPrime fallback
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue