mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-28 03:43:02 +00:00
UI changes
This commit is contained in:
parent
2a5798c107
commit
ab8f870e73
10 changed files with 160 additions and 169 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -69,7 +69,7 @@
|
||||||
"react-native-image-colors": "^2.5.0",
|
"react-native-image-colors": "^2.5.0",
|
||||||
"react-native-immersive-mode": "^2.0.2",
|
"react-native-immersive-mode": "^2.0.2",
|
||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-paper": "^5.13.1",
|
"react-native-paper": "^5.14.5",
|
||||||
"react-native-reanimated": "^3.17.4",
|
"react-native-reanimated": "^3.17.4",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@
|
||||||
"react-native-image-colors": "^2.5.0",
|
"react-native-image-colors": "^2.5.0",
|
||||||
"react-native-immersive-mode": "^2.0.2",
|
"react-native-immersive-mode": "^2.0.2",
|
||||||
"react-native-markdown-display": "^7.0.2",
|
"react-native-markdown-display": "^7.0.2",
|
||||||
"react-native-paper": "^5.13.1",
|
"react-native-paper": "^5.14.5",
|
||||||
"react-native-reanimated": "^3.17.4",
|
"react-native-reanimated": "^3.17.4",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import Animated, {
|
||||||
withTiming,
|
withTiming,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
import { Portal, Dialog, Button } from 'react-native-paper';
|
||||||
|
|
||||||
interface CustomAlertProps {
|
interface CustomAlertProps {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
|
|
@ -75,27 +76,29 @@ export const CustomAlert = ({
|
||||||
}
|
}
|
||||||
}, [onClose]);
|
}, [onClose]);
|
||||||
|
|
||||||
// Don't render anything if not visible
|
// Use Portal with Modal for proper rendering and animations
|
||||||
if (!visible) {
|
return (
|
||||||
return null;
|
<Portal>
|
||||||
}
|
|
||||||
|
|
||||||
// Use different rendering approach for Android to avoid Modal issues
|
|
||||||
if (Platform.OS === 'android') {
|
|
||||||
return (
|
|
||||||
<Modal
|
<Modal
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="fade"
|
animationType="none"
|
||||||
onRequestClose={onClose}
|
onRequestClose={onClose}
|
||||||
statusBarTranslucent={false}
|
statusBarTranslucent={true}
|
||||||
hardwareAccelerated={true}
|
hardwareAccelerated={true}
|
||||||
>
|
>
|
||||||
<View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.6)' }]}>
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.overlay,
|
||||||
|
{ backgroundColor: 'rgba(0,0,0,0.6)' },
|
||||||
|
overlayStyle
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Pressable style={styles.overlayPressable} onPress={onClose} />
|
<Pressable style={styles.overlayPressable} onPress={onClose} />
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered}>
|
||||||
<View style={[
|
<Animated.View style={[
|
||||||
styles.alertContainer,
|
styles.alertContainer,
|
||||||
|
alertStyle,
|
||||||
{
|
{
|
||||||
backgroundColor: themeColors.darkBackground,
|
backgroundColor: themeColors.darkBackground,
|
||||||
borderColor: themeColors.primary,
|
borderColor: themeColors.primary,
|
||||||
|
|
@ -140,82 +143,17 @@ export const CustomAlert = ({
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</Animated.View>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
</Portal>
|
||||||
}
|
|
||||||
|
|
||||||
// iOS version with animations
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={onClose}
|
|
||||||
presentationStyle="overFullScreen"
|
|
||||||
>
|
|
||||||
<Animated.View style={[styles.overlay, { backgroundColor: 'rgba(0,0,0,0.6)' }, overlayStyle]}>
|
|
||||||
<Pressable style={styles.overlayPressable} onPress={onClose} />
|
|
||||||
<View style={styles.centered}>
|
|
||||||
<Animated.View style={[
|
|
||||||
styles.alertContainer,
|
|
||||||
alertStyle,
|
|
||||||
{
|
|
||||||
backgroundColor: themeColors.darkBackground,
|
|
||||||
borderColor: themeColors.primary,
|
|
||||||
}
|
|
||||||
]}>
|
|
||||||
{/* Title */}
|
|
||||||
<Text style={[styles.title, { color: themeColors.highEmphasis }]}>
|
|
||||||
{title}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Message */}
|
|
||||||
<Text style={[styles.message, { color: themeColors.mediumEmphasis }]}>
|
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<View style={styles.actionsRow}>
|
|
||||||
{actions.map((action, idx) => {
|
|
||||||
const isPrimary = idx === actions.length - 1;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={action.label}
|
|
||||||
style={[
|
|
||||||
styles.actionButton,
|
|
||||||
isPrimary
|
|
||||||
? { ...styles.primaryButton, backgroundColor: themeColors.primary }
|
|
||||||
: styles.secondaryButton,
|
|
||||||
action.style
|
|
||||||
]}
|
|
||||||
onPress={() => handleActionPress(action)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={[
|
|
||||||
styles.actionText,
|
|
||||||
isPrimary
|
|
||||||
? { color: themeColors.white }
|
|
||||||
: { color: themeColors.primary }
|
|
||||||
]}>
|
|
||||||
{action.label}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
overlay: {
|
overlay: {
|
||||||
flex: 1,
|
...StyleSheet.absoluteFillObject,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -640,7 +640,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
||||||
<View style={styles.emptyContainer}>
|
<View style={styles.emptyContainer}>
|
||||||
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
|
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||||
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
|
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||||
{error ? 'Comments unavailable' : 'No comments yet'}
|
{error ? 'Comments unavailable' : 'No comments on Trakt yet'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
|
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
|
||||||
{error
|
{error
|
||||||
|
|
@ -729,13 +729,13 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading && comments.length === 0 && renderSkeletons()}
|
{loading && Array.isArray(comments) && comments.length === 0 && renderSkeletons()}
|
||||||
|
|
||||||
{(!loading && comments.length === 0 && hasLoadedOnce && !error) && (
|
{(!loading && Array.isArray(comments) && comments.length === 0 && hasLoadedOnce && !error) && (
|
||||||
renderEmpty()
|
renderEmpty()
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{comments.length > 0 && (
|
{Array.isArray(comments) && comments.length > 0 && (
|
||||||
<Animated.FlatList
|
<Animated.FlatList
|
||||||
horizontal
|
horizontal
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
|
|
|
||||||
|
|
@ -1501,10 +1501,10 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
||||||
pointerEvents="none"
|
pointerEvents="none"
|
||||||
/>
|
/>
|
||||||
<View style={[styles.heroContent, isTablet && { maxWidth: 800, alignSelf: 'center' }]}>
|
<View style={[styles.heroContent, isTablet && { maxWidth: 800, alignSelf: 'center' }]}>
|
||||||
{/* Optimized Title/Logo */}
|
{/* Optimized Title/Logo - Show logo immediately when available */}
|
||||||
<Animated.View style={[styles.logoContainer, titleCardAnimatedStyle]}>
|
<Animated.View style={[styles.logoContainer, titleCardAnimatedStyle]}>
|
||||||
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
|
<Animated.View style={[styles.titleLogoContainer, logoAnimatedStyle]}>
|
||||||
{shouldLoadSecondaryData && logoUri && !logoLoadError ? (
|
{logoUri && !logoLoadError ? (
|
||||||
<Image
|
<Image
|
||||||
source={{ uri: logoUri }}
|
source={{ uri: logoUri }}
|
||||||
style={isTablet ? styles.tabletTitleLogo : styles.titleLogo}
|
style={isTablet ? styles.tabletTitleLogo : styles.titleLogo}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@ type DownloadsContextValue = {
|
||||||
resumeDownload: (id: string) => Promise<void>;
|
resumeDownload: (id: string) => Promise<void>;
|
||||||
cancelDownload: (id: string) => Promise<void>;
|
cancelDownload: (id: string) => Promise<void>;
|
||||||
removeDownload: (id: string) => Promise<void>;
|
removeDownload: (id: string) => Promise<void>;
|
||||||
|
isDownloadingUrl: (url: string) => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DownloadsContext = createContext<DownloadsContextValue | undefined>(undefined);
|
const DownloadsContext = createContext<DownloadsContextValue | undefined>(undefined);
|
||||||
|
|
@ -116,6 +117,17 @@ function isDownloadableUrl(url: string): boolean {
|
||||||
return !isStreamingFormat;
|
return !isStreamingFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hashString(input: string): string {
|
||||||
|
let hash = 0;
|
||||||
|
for (let i = 0; i < input.length; i++) {
|
||||||
|
const chr = input.charCodeAt(i);
|
||||||
|
hash = (hash << 5) - hash + chr;
|
||||||
|
hash |= 0; // Convert to 32bit integer
|
||||||
|
}
|
||||||
|
// Convert to unsigned and hex
|
||||||
|
return (hash >>> 0).toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
||||||
const downloadsRef = useRef(downloads);
|
const downloadsRef = useRef(downloads);
|
||||||
|
|
@ -351,13 +363,15 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
}
|
}
|
||||||
|
|
||||||
const contentId = input.id;
|
const contentId = input.id;
|
||||||
// Compose per-episode id for series
|
// Create unique ID per URL - allows same episode/movie from different sources
|
||||||
const compoundId = input.type === 'series' && input.season && input.episode
|
const urlHash = hashString(input.url);
|
||||||
|
const baseId = input.type === 'series' && input.season && input.episode
|
||||||
? `${contentId}:S${input.season}E${input.episode}`
|
? `${contentId}:S${input.season}E${input.episode}`
|
||||||
: contentId;
|
: contentId;
|
||||||
|
const compoundId = `${baseId}:${urlHash}`;
|
||||||
|
|
||||||
// If already exists, handle based on status
|
// Check if this exact URL is already being downloaded
|
||||||
const existing = downloadsRef.current.find(d => d.id === compoundId);
|
const existing = downloadsRef.current.find(d => d.sourceUrl === input.url);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.status === 'completed') {
|
if (existing.status === 'completed') {
|
||||||
return; // Already completed, do nothing
|
return; // Already completed, do nothing
|
||||||
|
|
@ -365,7 +379,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
return; // Already downloading, do nothing
|
return; // Already downloading, do nothing
|
||||||
} else if (existing.status === 'paused' || existing.status === 'error') {
|
} else if (existing.status === 'paused' || existing.status === 'error') {
|
||||||
// Resume the paused or errored download instead of starting new one
|
// Resume the paused or errored download instead of starting new one
|
||||||
await resumeDownload(compoundId);
|
await resumeDownload(existing.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -555,6 +569,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
||||||
resumeDownload,
|
resumeDownload,
|
||||||
cancelDownload,
|
cancelDownload,
|
||||||
removeDownload,
|
removeDownload,
|
||||||
|
isDownloadingUrl: (url: string) => {
|
||||||
|
return downloadsRef.current.some(d => d.sourceUrl === url && (d.status === 'queued' || d.status === 'downloading' || d.status === 'paused'));
|
||||||
|
},
|
||||||
}), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]);
|
}), [downloads, startDownload, pauseDownload, resumeDownload, cancelDownload, removeDownload]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -81,19 +81,10 @@ export const useMetadataAssets = (
|
||||||
forcedLogoRefreshDone.current = false;
|
forcedLogoRefreshDone.current = false;
|
||||||
logoRefreshCounter.current = 0;
|
logoRefreshCounter.current = 0;
|
||||||
|
|
||||||
// Force logo refresh on preference change
|
// Mark that we need to refetch logo but DON'T clear it yet
|
||||||
if (metadata?.logo) {
|
// This prevents text from showing during the transition
|
||||||
const currentLogoIsExternal = isTmdbUrl(metadata.logo);
|
logoFetchInProgress.current = false;
|
||||||
const currentLogoIsTmdb = isTmdbUrl(metadata.logo);
|
}, [settings.logoSourcePreference]);
|
||||||
const preferenceIsMetahub = settings.logoSourcePreference === 'metahub';
|
|
||||||
|
|
||||||
// Always clear logo on preference change to force proper refresh
|
|
||||||
setMetadata((prevMetadata: any) => ({
|
|
||||||
...prevMetadata!,
|
|
||||||
logo: undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [settings.logoSourcePreference, setMetadata]);
|
|
||||||
|
|
||||||
// Original reset logo load error effect
|
// Original reset logo load error effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -119,8 +110,10 @@ export const useMetadataAssets = (
|
||||||
|
|
||||||
// If enrichment is disabled, use addon logo and don't fetch from external sources
|
// If enrichment is disabled, use addon logo and don't fetch from external sources
|
||||||
if (!settings.enrichMetadataWithTMDB) {
|
if (!settings.enrichMetadataWithTMDB) {
|
||||||
// If we have an addon logo, use it and don't fetch external logos
|
// If we have an addon logo, preload it immediately for instant display
|
||||||
if (metadata?.logo && !isTmdbUrl(metadata.logo)) {
|
if (metadata?.logo && !isTmdbUrl(metadata.logo)) {
|
||||||
|
// Preload addon logo for instant display
|
||||||
|
FastImage.preload([{ uri: metadata.logo }]);
|
||||||
// This is an addon logo, keep it
|
// This is an addon logo, keep it
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -128,11 +121,17 @@ export const useMetadataAssets = (
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When TMDB enrichment is ON, remove addon logos immediately
|
||||||
|
// We don't want to show addon logos briefly before TMDB logos load
|
||||||
|
if (settings.enrichMetadataWithTMDB && currentLogoUrl && !isTmdbUrl(currentLogoUrl)) {
|
||||||
|
// Clear addon logo when enrichment is enabled
|
||||||
|
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined }));
|
||||||
|
shouldFetchLogo = true;
|
||||||
|
}
|
||||||
// Determine if we need to fetch a new logo (only when enrichment is enabled)
|
// Determine if we need to fetch a new logo (only when enrichment is enabled)
|
||||||
if (!currentLogoUrl) {
|
else if (!currentLogoUrl) {
|
||||||
shouldFetchLogo = true;
|
shouldFetchLogo = true;
|
||||||
} else {
|
} else {
|
||||||
const isCurrentLogoExternal = isTmdbUrl(currentLogoUrl);
|
|
||||||
const isCurrentLogoTmdb = isTmdbUrl(currentLogoUrl);
|
const isCurrentLogoTmdb = isTmdbUrl(currentLogoUrl);
|
||||||
|
|
||||||
if (logoPreference === 'tmdb' && !isCurrentLogoTmdb) {
|
if (logoPreference === 'tmdb' && !isCurrentLogoTmdb) {
|
||||||
|
|
@ -145,10 +144,8 @@ export const useMetadataAssets = (
|
||||||
logoFetchInProgress.current = true;
|
logoFetchInProgress.current = true;
|
||||||
|
|
||||||
const fetchLogo = async () => {
|
const fetchLogo = async () => {
|
||||||
// Clear existing logo before fetching new one to avoid briefly showing wrong logo
|
// Store the original logo to restore if needed
|
||||||
if (shouldFetchLogo) {
|
const originalLogoUrl = currentLogoUrl;
|
||||||
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined }));
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||||
|
|
@ -198,32 +195,37 @@ export const useMetadataAssets = (
|
||||||
}
|
}
|
||||||
|
|
||||||
if (logoUrl) {
|
if (logoUrl) {
|
||||||
// Preload the image
|
// Preload the image before setting it
|
||||||
FastImage.preload([{ uri: logoUrl }]);
|
await FastImage.preload([{ uri: logoUrl }]);
|
||||||
|
|
||||||
|
// Only update if we got a valid logo
|
||||||
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: logoUrl }));
|
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: logoUrl }));
|
||||||
} else {
|
} else {
|
||||||
// TMDB logo not found, try to restore addon logo if it exists
|
// TMDB logo not found
|
||||||
if (currentLogoUrl && !isTmdbUrl(currentLogoUrl)) {
|
// When enrichment is ON, don't fallback to addon logos - show text instead
|
||||||
if (__DEV__) console.log('[useMetadataAssets] Restoring addon logo after TMDB logo not found');
|
if (__DEV__) {
|
||||||
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: currentLogoUrl }));
|
console.log('[useMetadataAssets] No TMDB logo found for ID:', tmdbId);
|
||||||
} else if (__DEV__) {
|
|
||||||
console.log('[useMetadataAssets] No logo found for TMDB ID:', tmdbId);
|
|
||||||
}
|
}
|
||||||
|
// Keep logo as undefined to show text title
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// TMDB logo fetch failed, try to restore addon logo if it exists
|
// TMDB logo fetch failed
|
||||||
if (currentLogoUrl && !isTmdbUrl(currentLogoUrl)) {
|
// When enrichment is ON, don't fallback to addon logos - show text instead
|
||||||
if (__DEV__) console.log('[useMetadataAssets] Restoring addon logo after TMDB fetch error');
|
if (__DEV__) {
|
||||||
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: currentLogoUrl }));
|
|
||||||
} else if (__DEV__) {
|
|
||||||
console.error('[useMetadataAssets] Logo fetch error:', error);
|
console.error('[useMetadataAssets] Logo fetch error:', error);
|
||||||
}
|
}
|
||||||
|
// Keep logo as undefined to show text title
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// No TMDB ID found
|
||||||
|
// When enrichment is ON, don't use addon logos - show text instead
|
||||||
|
if (__DEV__) console.log('[useMetadataAssets] No TMDB ID found, will show text title');
|
||||||
|
// Keep logo as undefined to show text title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Handle error silently
|
// Handle error silently, keep existing logo
|
||||||
|
if (__DEV__) console.error('[useMetadataAssets] Unexpected error in logo fetch:', error);
|
||||||
} finally {
|
} finally {
|
||||||
logoFetchInProgress.current = false;
|
logoFetchInProgress.current = false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,8 @@ const DownloadItemComponent: React.FC<{
|
||||||
item: DownloadItem;
|
item: DownloadItem;
|
||||||
onPress: (item: DownloadItem) => void;
|
onPress: (item: DownloadItem) => void;
|
||||||
onAction: (item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => void;
|
onAction: (item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => void;
|
||||||
}> = React.memo(({ item, onPress, onAction }) => {
|
onRequestRemove: (item: DownloadItem) => void;
|
||||||
|
}> = React.memo(({ item, onPress, onAction, onRequestRemove }) => {
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
|
const [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
|
||||||
|
|
||||||
|
|
@ -323,16 +324,7 @@ const DownloadItemComponent: React.FC<{
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||||
onPress={() => {
|
onPress={() => onRequestRemove(item)}
|
||||||
Alert.alert(
|
|
||||||
'Remove Download',
|
|
||||||
'Are you sure you want to remove this download?',
|
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
|
||||||
{ text: 'Remove', style: 'destructive', onPress: () => onAction(item, 'cancel') },
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
|
|
@ -355,6 +347,8 @@ const DownloadsScreen: React.FC = () => {
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
|
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
|
||||||
const [showHelpAlert, setShowHelpAlert] = useState(false);
|
const [showHelpAlert, setShowHelpAlert] = useState(false);
|
||||||
|
const [showRemoveAlert, setShowRemoveAlert] = useState(false);
|
||||||
|
const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null);
|
||||||
|
|
||||||
// Animation values
|
// Animation values
|
||||||
const headerOpacity = useSharedValue(1);
|
const headerOpacity = useSharedValue(1);
|
||||||
|
|
@ -449,6 +443,11 @@ const DownloadsScreen: React.FC = () => {
|
||||||
if (action === 'cancel') cancelDownload(item.id);
|
if (action === 'cancel') cancelDownload(item.id);
|
||||||
}, [pauseDownload, resumeDownload, cancelDownload]);
|
}, [pauseDownload, resumeDownload, cancelDownload]);
|
||||||
|
|
||||||
|
const handleRequestRemove = useCallback((item: DownloadItem) => {
|
||||||
|
setPendingRemoveItem(item);
|
||||||
|
setShowRemoveAlert(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed' | 'paused') => {
|
const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed' | 'paused') => {
|
||||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
setSelectedFilter(filter);
|
setSelectedFilter(filter);
|
||||||
|
|
@ -533,7 +532,9 @@ const DownloadsScreen: React.FC = () => {
|
||||||
styles.header,
|
styles.header,
|
||||||
{
|
{
|
||||||
backgroundColor: currentTheme.colors.darkBackground,
|
backgroundColor: currentTheme.colors.darkBackground,
|
||||||
paddingTop: safeAreaTop + 16,
|
paddingTop: (Platform.OS === 'android'
|
||||||
|
? (StatusBar.currentHeight || 0) + 26
|
||||||
|
: safeAreaTop + 15) + (isTablet ? 64 : 0),
|
||||||
borderBottomColor: currentTheme.colors.border,
|
borderBottomColor: currentTheme.colors.border,
|
||||||
},
|
},
|
||||||
headerStyle,
|
headerStyle,
|
||||||
|
|
@ -577,6 +578,7 @@ const DownloadsScreen: React.FC = () => {
|
||||||
item={item}
|
item={item}
|
||||||
onPress={handleDownloadPress}
|
onPress={handleDownloadPress}
|
||||||
onAction={handleDownloadAction}
|
onAction={handleDownloadAction}
|
||||||
|
onRequestRemove={handleRequestRemove}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
style={{ backgroundColor: currentTheme.colors.darkBackground }}
|
style={{ backgroundColor: currentTheme.colors.darkBackground }}
|
||||||
|
|
@ -615,6 +617,18 @@ const DownloadsScreen: React.FC = () => {
|
||||||
message="• Files smaller than 1MB are typically M3U8 streaming playlists and cannot be downloaded for offline viewing. These only work with online streaming and contain links to video segments, not the actual video content."
|
message="• Files smaller than 1MB are typically M3U8 streaming playlists and cannot be downloaded for offline viewing. These only work with online streaming and contain links to video segments, not the actual video content."
|
||||||
onClose={() => setShowHelpAlert(false)}
|
onClose={() => setShowHelpAlert(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Remove Download Confirmation */}
|
||||||
|
<CustomAlert
|
||||||
|
visible={showRemoveAlert}
|
||||||
|
title="Remove Download"
|
||||||
|
message={pendingRemoveItem ? `Remove \"${pendingRemoveItem.title}\"${pendingRemoveItem.type === 'series' && pendingRemoveItem.season && pendingRemoveItem.episode ? ` S${String(pendingRemoveItem.season).padStart(2,'0')}E${String(pendingRemoveItem.episode).padStart(2,'0')}` : ''}?` : 'Remove this download?'}
|
||||||
|
actions={[
|
||||||
|
{ label: 'Cancel', onPress: () => setShowRemoveAlert(false) },
|
||||||
|
{ label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: { } },
|
||||||
|
]}
|
||||||
|
onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
@ -624,19 +638,21 @@ const styles = StyleSheet.create({
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingHorizontal: isTablet ? 24 : 20,
|
paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05),
|
||||||
paddingBottom: isTablet ? 20 : 16,
|
paddingBottom: isTablet ? 20 : 16,
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||||
},
|
},
|
||||||
headerTitleRow: {
|
headerTitleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'flex-end',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: isTablet ? 20 : 16,
|
marginBottom: isTablet ? 20 : 16,
|
||||||
|
paddingBottom: 8,
|
||||||
},
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
fontSize: isTablet ? 36 : 32,
|
fontSize: isTablet ? 36 : Math.min(32, width * 0.08),
|
||||||
fontWeight: '700',
|
fontWeight: '800',
|
||||||
|
letterSpacing: 0.3,
|
||||||
},
|
},
|
||||||
helpButton: {
|
helpButton: {
|
||||||
padding: 8,
|
padding: 8,
|
||||||
|
|
@ -671,7 +687,7 @@ const styles = StyleSheet.create({
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
listContainer: {
|
listContainer: {
|
||||||
padding: isTablet ? 24 : 20,
|
paddingHorizontal: 0,
|
||||||
paddingTop: 8,
|
paddingTop: 8,
|
||||||
paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav
|
paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav
|
||||||
},
|
},
|
||||||
|
|
@ -687,6 +703,11 @@ const styles = StyleSheet.create({
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 3,
|
elevation: 3,
|
||||||
|
marginHorizontal: 0,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
},
|
},
|
||||||
posterContainer: {
|
posterContainer: {
|
||||||
width: POSTER_WIDTH,
|
width: POSTER_WIDTH,
|
||||||
|
|
|
||||||
|
|
@ -924,24 +924,26 @@ const MetadataScreen: React.FC = () => {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Production info row — shown after cast for movies */}
|
{/* Production info row — only render companies with logos */}
|
||||||
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.networks && metadata.networks.length > 0 && (
|
{shouldLoadSecondaryData &&
|
||||||
|
Object.keys(groupedEpisodes).length === 0 &&
|
||||||
|
metadata?.networks && Array.isArray(metadata.networks) &&
|
||||||
|
metadata.networks.some((n: any) => !!n?.logo) && (
|
||||||
<Animated.View style={[styles.productionContainer, productionSectionAnimatedStyle]}>
|
<Animated.View style={[styles.productionContainer, productionSectionAnimatedStyle]}>
|
||||||
<Text style={styles.productionHeader}>Production</Text>
|
<Text style={styles.productionHeader}>Production</Text>
|
||||||
<View style={styles.productionRow}>
|
<View style={styles.productionRow}>
|
||||||
{metadata.networks.slice(0, 6).map((net) => (
|
{metadata.networks
|
||||||
<View key={String(net.id || net.name)} style={styles.productionChip}>
|
.filter((net: any) => !!net?.logo)
|
||||||
{net.logo ? (
|
.slice(0, 6)
|
||||||
|
.map((net: any) => (
|
||||||
|
<View key={String(net.id || net.name)} style={styles.productionChip}>
|
||||||
<FastImage
|
<FastImage
|
||||||
source={{ uri: net.logo }}
|
source={{ uri: net.logo }}
|
||||||
style={styles.productionLogo}
|
style={styles.productionLogo}
|
||||||
resizeMode={FastImage.resizeMode.contain}
|
resizeMode={FastImage.resizeMode.contain}
|
||||||
/>
|
/>
|
||||||
) : (
|
</View>
|
||||||
<Text style={styles.productionText}>{net.name}</Text>
|
))}
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
))}
|
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@ import { isMkvStream } from '../utils/mkvDetection';
|
||||||
import CustomAlert from '../components/CustomAlert';
|
import CustomAlert from '../components/CustomAlert';
|
||||||
import { Toast } from 'toastify-react-native';
|
import { Toast } from 'toastify-react-native';
|
||||||
import { useDownloads } from '../contexts/DownloadsContext';
|
import { useDownloads } from '../contexts/DownloadsContext';
|
||||||
|
import { PaperProvider } from 'react-native-paper';
|
||||||
|
|
||||||
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
||||||
const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
|
const HDR_ICON = 'https://uxwing.com/wp-content/themes/uxwing/download/video-photography-multimedia/hdr-icon.png';
|
||||||
|
|
@ -291,6 +292,16 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
|
||||||
try {
|
try {
|
||||||
const url = stream.url;
|
const url = stream.url;
|
||||||
if (!url) return;
|
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 parent: any = stream as any;
|
||||||
const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
|
const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
|
||||||
const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
|
const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
|
||||||
|
|
@ -1951,6 +1962,7 @@ export const StreamsScreen = () => {
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<PaperProvider>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusBar
|
<StatusBar
|
||||||
translucent
|
translucent
|
||||||
|
|
@ -2248,16 +2260,15 @@ export const StreamsScreen = () => {
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{Platform.OS === 'ios' && (
|
<CustomAlert
|
||||||
<CustomAlert
|
visible={alertVisible}
|
||||||
visible={alertVisible}
|
title={alertTitle}
|
||||||
title={alertTitle}
|
message={alertMessage}
|
||||||
message={alertMessage}
|
actions={alertActions}
|
||||||
actions={alertActions}
|
onClose={() => setAlertVisible(false)}
|
||||||
onClose={() => setAlertVisible(false)}
|
/>
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
|
</PaperProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue