mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +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-immersive-mode": "^2.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-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@
|
|||
"react-native-image-colors": "^2.5.0",
|
||||
"react-native-immersive-mode": "^2.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-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import Animated, {
|
|||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { Portal, Dialog, Button } from 'react-native-paper';
|
||||
|
||||
interface CustomAlertProps {
|
||||
visible: boolean;
|
||||
|
|
@ -75,27 +76,29 @@ export const CustomAlert = ({
|
|||
}
|
||||
}, [onClose]);
|
||||
|
||||
// Don't render anything if not visible
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Use different rendering approach for Android to avoid Modal issues
|
||||
if (Platform.OS === 'android') {
|
||||
return (
|
||||
// Use Portal with Modal for proper rendering and animations
|
||||
return (
|
||||
<Portal>
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent={false}
|
||||
statusBarTranslucent={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} />
|
||||
<View style={styles.centered}>
|
||||
<View style={[
|
||||
<Animated.View style={[
|
||||
styles.alertContainer,
|
||||
alertStyle,
|
||||
{
|
||||
backgroundColor: themeColors.darkBackground,
|
||||
borderColor: themeColors.primary,
|
||||
|
|
@ -105,12 +108,12 @@ export const CustomAlert = ({
|
|||
<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) => {
|
||||
|
|
@ -120,7 +123,7 @@ export const CustomAlert = ({
|
|||
key={action.label}
|
||||
style={[
|
||||
styles.actionButton,
|
||||
isPrimary
|
||||
isPrimary
|
||||
? { ...styles.primaryButton, backgroundColor: themeColors.primary }
|
||||
: styles.secondaryButton,
|
||||
action.style
|
||||
|
|
@ -130,7 +133,7 @@ export const CustomAlert = ({
|
|||
>
|
||||
<Text style={[
|
||||
styles.actionText,
|
||||
isPrimary
|
||||
isPrimary
|
||||
? { color: themeColors.white }
|
||||
: { color: themeColors.primary }
|
||||
]}>
|
||||
|
|
@ -140,82 +143,17 @@ export const CustomAlert = ({
|
|||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// 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>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -640,7 +640,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
<View style={styles.emptyContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={48} 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 style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
|
||||
{error
|
||||
|
|
@ -729,13 +729,13 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
</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()
|
||||
)}
|
||||
|
||||
{comments.length > 0 && (
|
||||
{Array.isArray(comments) && comments.length > 0 && (
|
||||
<Animated.FlatList
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
|
|
|
|||
|
|
@ -1501,10 +1501,10 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
pointerEvents="none"
|
||||
/>
|
||||
<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.titleLogoContainer, logoAnimatedStyle]}>
|
||||
{shouldLoadSecondaryData && logoUri && !logoLoadError ? (
|
||||
{logoUri && !logoLoadError ? (
|
||||
<Image
|
||||
source={{ uri: logoUri }}
|
||||
style={isTablet ? styles.tabletTitleLogo : styles.titleLogo}
|
||||
|
|
@ -2127,4 +2127,4 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
});
|
||||
|
||||
export default HeroSection;
|
||||
export default HeroSection;
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ type DownloadsContextValue = {
|
|||
resumeDownload: (id: string) => Promise<void>;
|
||||
cancelDownload: (id: string) => Promise<void>;
|
||||
removeDownload: (id: string) => Promise<void>;
|
||||
isDownloadingUrl: (url: string) => boolean;
|
||||
};
|
||||
|
||||
const DownloadsContext = createContext<DownloadsContextValue | undefined>(undefined);
|
||||
|
|
@ -116,6 +117,17 @@ function isDownloadableUrl(url: string): boolean {
|
|||
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 }) => {
|
||||
const [downloads, setDownloads] = useState<DownloadItem[]>([]);
|
||||
const downloadsRef = useRef(downloads);
|
||||
|
|
@ -351,13 +363,15 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
}
|
||||
|
||||
const contentId = input.id;
|
||||
// Compose per-episode id for series
|
||||
const compoundId = input.type === 'series' && input.season && input.episode
|
||||
// Create unique ID per URL - allows same episode/movie from different sources
|
||||
const urlHash = hashString(input.url);
|
||||
const baseId = input.type === 'series' && input.season && input.episode
|
||||
? `${contentId}:S${input.season}E${input.episode}`
|
||||
: contentId;
|
||||
const compoundId = `${baseId}:${urlHash}`;
|
||||
|
||||
// If already exists, handle based on status
|
||||
const existing = downloadsRef.current.find(d => d.id === compoundId);
|
||||
// Check if this exact URL is already being downloaded
|
||||
const existing = downloadsRef.current.find(d => d.sourceUrl === input.url);
|
||||
if (existing) {
|
||||
if (existing.status === 'completed') {
|
||||
return; // Already completed, do nothing
|
||||
|
|
@ -365,7 +379,7 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
return; // Already downloading, do nothing
|
||||
} else if (existing.status === 'paused' || existing.status === 'error') {
|
||||
// Resume the paused or errored download instead of starting new one
|
||||
await resumeDownload(compoundId);
|
||||
await resumeDownload(existing.id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -555,6 +569,9 @@ export const DownloadsProvider: React.FC<{ children: React.ReactNode }> = ({ chi
|
|||
resumeDownload,
|
||||
cancelDownload,
|
||||
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]);
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -81,19 +81,10 @@ export const useMetadataAssets = (
|
|||
forcedLogoRefreshDone.current = false;
|
||||
logoRefreshCounter.current = 0;
|
||||
|
||||
// Force logo refresh on preference change
|
||||
if (metadata?.logo) {
|
||||
const currentLogoIsExternal = isTmdbUrl(metadata.logo);
|
||||
const currentLogoIsTmdb = isTmdbUrl(metadata.logo);
|
||||
const preferenceIsMetahub = settings.logoSourcePreference === 'metahub';
|
||||
|
||||
// Always clear logo on preference change to force proper refresh
|
||||
setMetadata((prevMetadata: any) => ({
|
||||
...prevMetadata!,
|
||||
logo: undefined
|
||||
}));
|
||||
}
|
||||
}, [settings.logoSourcePreference, setMetadata]);
|
||||
// Mark that we need to refetch logo but DON'T clear it yet
|
||||
// This prevents text from showing during the transition
|
||||
logoFetchInProgress.current = false;
|
||||
}, [settings.logoSourcePreference]);
|
||||
|
||||
// Original reset logo load error effect
|
||||
useEffect(() => {
|
||||
|
|
@ -119,8 +110,10 @@ export const useMetadataAssets = (
|
|||
|
||||
// If enrichment is disabled, use addon logo and don't fetch from external sources
|
||||
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)) {
|
||||
// Preload addon logo for instant display
|
||||
FastImage.preload([{ uri: metadata.logo }]);
|
||||
// This is an addon logo, keep it
|
||||
return;
|
||||
}
|
||||
|
|
@ -128,11 +121,17 @@ export const useMetadataAssets = (
|
|||
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)
|
||||
if (!currentLogoUrl) {
|
||||
else if (!currentLogoUrl) {
|
||||
shouldFetchLogo = true;
|
||||
} else {
|
||||
const isCurrentLogoExternal = isTmdbUrl(currentLogoUrl);
|
||||
const isCurrentLogoTmdb = isTmdbUrl(currentLogoUrl);
|
||||
|
||||
if (logoPreference === 'tmdb' && !isCurrentLogoTmdb) {
|
||||
|
|
@ -145,10 +144,8 @@ export const useMetadataAssets = (
|
|||
logoFetchInProgress.current = true;
|
||||
|
||||
const fetchLogo = async () => {
|
||||
// Clear existing logo before fetching new one to avoid briefly showing wrong logo
|
||||
if (shouldFetchLogo) {
|
||||
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: undefined }));
|
||||
}
|
||||
// Store the original logo to restore if needed
|
||||
const originalLogoUrl = currentLogoUrl;
|
||||
|
||||
try {
|
||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||
|
|
@ -198,32 +195,37 @@ export const useMetadataAssets = (
|
|||
}
|
||||
|
||||
if (logoUrl) {
|
||||
// Preload the image
|
||||
FastImage.preload([{ uri: logoUrl }]);
|
||||
// Preload the image before setting it
|
||||
await FastImage.preload([{ uri: logoUrl }]);
|
||||
|
||||
// Only update if we got a valid logo
|
||||
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: logoUrl }));
|
||||
} else {
|
||||
// TMDB logo not found, try to restore addon logo if it exists
|
||||
if (currentLogoUrl && !isTmdbUrl(currentLogoUrl)) {
|
||||
if (__DEV__) console.log('[useMetadataAssets] Restoring addon logo after TMDB logo not found');
|
||||
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: currentLogoUrl }));
|
||||
} else if (__DEV__) {
|
||||
console.log('[useMetadataAssets] No logo found for TMDB ID:', tmdbId);
|
||||
// TMDB logo not found
|
||||
// When enrichment is ON, don't fallback to addon logos - show text instead
|
||||
if (__DEV__) {
|
||||
console.log('[useMetadataAssets] No TMDB logo found for ID:', tmdbId);
|
||||
}
|
||||
// Keep logo as undefined to show text title
|
||||
}
|
||||
} catch (error) {
|
||||
// TMDB logo fetch failed, try to restore addon logo if it exists
|
||||
if (currentLogoUrl && !isTmdbUrl(currentLogoUrl)) {
|
||||
if (__DEV__) console.log('[useMetadataAssets] Restoring addon logo after TMDB fetch error');
|
||||
setMetadata((prevMetadata: any) => ({ ...prevMetadata!, logo: currentLogoUrl }));
|
||||
} else if (__DEV__) {
|
||||
// TMDB logo fetch failed
|
||||
// When enrichment is ON, don't fallback to addon logos - show text instead
|
||||
if (__DEV__) {
|
||||
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) {
|
||||
// Handle error silently
|
||||
// Handle error silently, keep existing logo
|
||||
if (__DEV__) console.error('[useMetadataAssets] Unexpected error in logo fetch:', error);
|
||||
} finally {
|
||||
logoFetchInProgress.current = false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ const DownloadItemComponent: React.FC<{
|
|||
item: DownloadItem;
|
||||
onPress: (item: DownloadItem) => 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 [posterUrl, setPosterUrl] = useState<string | null>(item.posterUrl || null);
|
||||
|
||||
|
|
@ -323,16 +324,7 @@ const DownloadItemComponent: React.FC<{
|
|||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={() => {
|
||||
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') },
|
||||
]
|
||||
);
|
||||
}}
|
||||
onPress={() => onRequestRemove(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
|
|
@ -355,6 +347,8 @@ const DownloadsScreen: React.FC = () => {
|
|||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [selectedFilter, setSelectedFilter] = useState<'all' | 'downloading' | 'completed' | 'paused'>('all');
|
||||
const [showHelpAlert, setShowHelpAlert] = useState(false);
|
||||
const [showRemoveAlert, setShowRemoveAlert] = useState(false);
|
||||
const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null);
|
||||
|
||||
// Animation values
|
||||
const headerOpacity = useSharedValue(1);
|
||||
|
|
@ -449,6 +443,11 @@ const DownloadsScreen: React.FC = () => {
|
|||
if (action === 'cancel') cancelDownload(item.id);
|
||||
}, [pauseDownload, resumeDownload, cancelDownload]);
|
||||
|
||||
const handleRequestRemove = useCallback((item: DownloadItem) => {
|
||||
setPendingRemoveItem(item);
|
||||
setShowRemoveAlert(true);
|
||||
}, []);
|
||||
|
||||
const handleFilterPress = useCallback((filter: 'all' | 'downloading' | 'completed' | 'paused') => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
setSelectedFilter(filter);
|
||||
|
|
@ -533,7 +532,9 @@ const DownloadsScreen: React.FC = () => {
|
|||
styles.header,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
paddingTop: safeAreaTop + 16,
|
||||
paddingTop: (Platform.OS === 'android'
|
||||
? (StatusBar.currentHeight || 0) + 26
|
||||
: safeAreaTop + 15) + (isTablet ? 64 : 0),
|
||||
borderBottomColor: currentTheme.colors.border,
|
||||
},
|
||||
headerStyle,
|
||||
|
|
@ -577,6 +578,7 @@ const DownloadsScreen: React.FC = () => {
|
|||
item={item}
|
||||
onPress={handleDownloadPress}
|
||||
onAction={handleDownloadAction}
|
||||
onRequestRemove={handleRequestRemove}
|
||||
/>
|
||||
)}
|
||||
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."
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -624,19 +638,21 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: isTablet ? 24 : 20,
|
||||
paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05),
|
||||
paddingBottom: isTablet ? 20 : 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
headerTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: isTablet ? 20 : 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: isTablet ? 36 : 32,
|
||||
fontWeight: '700',
|
||||
fontSize: isTablet ? 36 : Math.min(32, width * 0.08),
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
helpButton: {
|
||||
padding: 8,
|
||||
|
|
@ -671,7 +687,7 @@ const styles = StyleSheet.create({
|
|||
fontWeight: '700',
|
||||
},
|
||||
listContainer: {
|
||||
padding: isTablet ? 24 : 20,
|
||||
paddingHorizontal: 0,
|
||||
paddingTop: 8,
|
||||
paddingBottom: isTablet ? 120 : 100, // Extra padding for tablet bottom nav
|
||||
},
|
||||
|
|
@ -687,6 +703,11 @@ const styles = StyleSheet.create({
|
|||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
marginHorizontal: 0,
|
||||
borderTopLeftRadius: 0,
|
||||
borderTopRightRadius: 0,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
},
|
||||
posterContainer: {
|
||||
width: POSTER_WIDTH,
|
||||
|
|
|
|||
|
|
@ -924,24 +924,26 @@ const MetadataScreen: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Production info row — shown after cast for movies */}
|
||||
{shouldLoadSecondaryData && Object.keys(groupedEpisodes).length === 0 && metadata?.networks && metadata.networks.length > 0 && (
|
||||
{/* Production info row — only render companies with logos */}
|
||||
{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]}>
|
||||
<Text style={styles.productionHeader}>Production</Text>
|
||||
<View style={styles.productionRow}>
|
||||
{metadata.networks.slice(0, 6).map((net) => (
|
||||
<View key={String(net.id || net.name)} style={styles.productionChip}>
|
||||
{net.logo ? (
|
||||
{metadata.networks
|
||||
.filter((net: any) => !!net?.logo)
|
||||
.slice(0, 6)
|
||||
.map((net: any) => (
|
||||
<View key={String(net.id || net.name)} style={styles.productionChip}>
|
||||
<FastImage
|
||||
source={{ uri: net.logo }}
|
||||
style={styles.productionLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
) : (
|
||||
<Text style={styles.productionText}>{net.name}</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import { isMkvStream } from '../utils/mkvDetection';
|
|||
import CustomAlert from '../components/CustomAlert';
|
||||
import { Toast } from 'toastify-react-native';
|
||||
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 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 {
|
||||
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');
|
||||
|
|
@ -1951,6 +1962,7 @@ export const StreamsScreen = () => {
|
|||
|
||||
|
||||
return (
|
||||
<PaperProvider>
|
||||
<View style={styles.container}>
|
||||
<StatusBar
|
||||
translucent
|
||||
|
|
@ -2248,16 +2260,15 @@ export const StreamsScreen = () => {
|
|||
</View>
|
||||
)}
|
||||
</View>
|
||||
{Platform.OS === 'ios' && (
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
actions={alertActions}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
/>
|
||||
)}
|
||||
<CustomAlert
|
||||
visible={alertVisible}
|
||||
title={alertTitle}
|
||||
message={alertMessage}
|
||||
actions={alertActions}
|
||||
onClose={() => setAlertVisible(false)}
|
||||
/>
|
||||
</View>
|
||||
</PaperProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue