mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
Merge branch 'main' into patch-5
This commit is contained in:
commit
53dd480231
20 changed files with 2751 additions and 2001 deletions
|
|
@ -66,6 +66,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
|
|||
|
||||
### iOS
|
||||
|
||||
#### TestFlight (Recommended)
|
||||
<img src="https://upload.wikimedia.org/wikipedia/fr/b/bc/TestFlight-icon.png" width="24" height="24" align="left"> [](https://testflight.apple.com/join/QkKMGRqp)
|
||||
|
||||
#### AltStore
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/2/20/AltStore_logo.png" width="24" height="24" align="left"> [](https://tinyurl.com/NuvioAltstore)
|
||||
|
||||
|
|
|
|||
837
index.html
837
index.html
File diff suppressed because it is too large
Load diff
|
|
@ -477,7 +477,7 @@
|
|||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
PRODUCT_NAME = Nuvio;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
|
|
@ -494,7 +494,7 @@
|
|||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Nuvio/NuvioRelease.entitlements;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = NLXTHANK2N;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
INFOPLIST_FILE = Nuvio/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
|
|
@ -508,8 +508,8 @@
|
|||
"-lc++",
|
||||
);
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app";
|
||||
PRODUCT_NAME = "Nuvio";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
|
||||
PRODUCT_NAME = Nuvio;
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
|
|
|
|||
|
|
@ -1,103 +1,99 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Nuvio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.10</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>nuvio</string>
|
||||
<string>com.nuvio.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>exp+nuvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>25</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_http._tcp</string>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app does not require microphone access.</string>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
<integer>4278322180</integer>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Nuvio</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.2.10</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>nuvio</string>
|
||||
<string>com.nuvio.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>exp+nuvio</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>25</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_http._tcp</string>
|
||||
<string>_googlecast._tcp</string>
|
||||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
<integer>4278322180</integer>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
</array>
|
||||
<key>UIRequiresFullScreen</key>
|
||||
<true/>
|
||||
<key>UIStatusBarStyle</key>
|
||||
<string>UIStatusBarStyleDefault</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Dark</string>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
|
|||
241
src/components/common/ScreenHeader.tsx
Normal file
241
src/components/common/ScreenHeader.tsx
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Feather, MaterialIcons } from '@expo/vector-icons';
|
||||
|
||||
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
/**
|
||||
* The main title displayed in the header
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Optional right action button (icon name from Feather icons)
|
||||
*/
|
||||
rightActionIcon?: string;
|
||||
/**
|
||||
* Optional callback for right action button press
|
||||
*/
|
||||
onRightActionPress?: () => void;
|
||||
/**
|
||||
* Optional custom right action component (overrides rightActionIcon)
|
||||
*/
|
||||
rightActionComponent?: React.ReactNode;
|
||||
/**
|
||||
* Optional back button (shows arrow back icon)
|
||||
*/
|
||||
showBackButton?: boolean;
|
||||
/**
|
||||
* Optional callback for back button press
|
||||
*/
|
||||
onBackPress?: () => void;
|
||||
/**
|
||||
* Whether this screen is displayed on a tablet layout
|
||||
*/
|
||||
isTablet?: boolean;
|
||||
/**
|
||||
* Optional extra top padding for tablet navigation offset
|
||||
*/
|
||||
tabletNavOffset?: number;
|
||||
/**
|
||||
* Optional custom title component (overrides title text)
|
||||
*/
|
||||
titleComponent?: React.ReactNode;
|
||||
/**
|
||||
* Optional children to render below the title row (e.g., filters, search bar)
|
||||
*/
|
||||
children?: React.ReactNode;
|
||||
/**
|
||||
* Whether to hide the header title row (useful when showing only children)
|
||||
*/
|
||||
hideTitleRow?: boolean;
|
||||
/**
|
||||
* Use MaterialIcons instead of Feather for icons
|
||||
*/
|
||||
useMaterialIcons?: boolean;
|
||||
/**
|
||||
* Optional custom style for title
|
||||
*/
|
||||
titleStyle?: object;
|
||||
}
|
||||
|
||||
const ScreenHeader: React.FC<ScreenHeaderProps> = ({
|
||||
title,
|
||||
rightActionIcon,
|
||||
onRightActionPress,
|
||||
rightActionComponent,
|
||||
showBackButton = false,
|
||||
onBackPress,
|
||||
isTablet = false,
|
||||
tabletNavOffset = 64,
|
||||
titleComponent,
|
||||
children,
|
||||
hideTitleRow = false,
|
||||
useMaterialIcons = false,
|
||||
titleStyle,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// Calculate header spacing
|
||||
const topSpacing =
|
||||
(Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT : insets.top) +
|
||||
(isTablet ? tabletNavOffset : 0);
|
||||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
const titleRowHeight = headerBaseHeight + topSpacing;
|
||||
|
||||
const IconComponent = useMaterialIcons ? MaterialIcons : Feather;
|
||||
const backIconName = useMaterialIcons ? 'arrow-back' : 'arrow-left';
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Fixed position header background to prevent shifts */}
|
||||
<View
|
||||
style={[
|
||||
styles.headerBackground,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Header Section */}
|
||||
<View
|
||||
style={[
|
||||
styles.header,
|
||||
{
|
||||
paddingTop: topSpacing,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Title Row */}
|
||||
{!hideTitleRow && (
|
||||
<View
|
||||
style={[
|
||||
styles.titleRow,
|
||||
{
|
||||
height: headerBaseHeight,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.headerContent}>
|
||||
{showBackButton ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBackPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<IconComponent
|
||||
name={backIconName as any}
|
||||
size={24}
|
||||
color={currentTheme.colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
{titleComponent ? (
|
||||
titleComponent
|
||||
) : (
|
||||
<Text
|
||||
style={[
|
||||
styles.headerTitle,
|
||||
{ color: currentTheme.colors.text },
|
||||
isTablet && { fontSize: 48 }, // Increase font size for tablet
|
||||
showBackButton && styles.headerTitleWithBack,
|
||||
titleStyle,
|
||||
]}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Right Action */}
|
||||
{rightActionComponent ? (
|
||||
<View style={styles.rightActionContainer}>{rightActionComponent}</View>
|
||||
) : rightActionIcon && onRightActionPress ? (
|
||||
<TouchableOpacity
|
||||
style={styles.rightActionButton}
|
||||
onPress={onRightActionPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<IconComponent
|
||||
name={rightActionIcon as any}
|
||||
size={24}
|
||||
color={currentTheme.colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.rightActionPlaceholder} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Children (filters, search bar, etc.) */}
|
||||
{children}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
zIndex: 11,
|
||||
},
|
||||
titleRow: {
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 8,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
marginLeft: -8,
|
||||
marginRight: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
flex: 1,
|
||||
},
|
||||
headerTitleWithBack: {
|
||||
fontSize: 24,
|
||||
flex: 0,
|
||||
},
|
||||
rightActionContainer: {
|
||||
minWidth: 40,
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
rightActionButton: {
|
||||
padding: 8,
|
||||
marginRight: -8,
|
||||
},
|
||||
rightActionPlaceholder: {
|
||||
width: 40,
|
||||
},
|
||||
});
|
||||
|
||||
export default ScreenHeader;
|
||||
|
|
@ -39,6 +39,12 @@ import { useSettings } from '../../hooks/useSettings';
|
|||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import TrailerService from '../../services/trailerService';
|
||||
import TrailerPlayer from '../video/TrailerPlayer';
|
||||
import { useLibrary } from '../../hooks/useLibrary';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { useTraktContext } from '../../contexts/TraktContext';
|
||||
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||
import { useWatchProgress } from '../../hooks/useWatchProgress';
|
||||
import { streamCacheService } from '../../services/streamCacheService';
|
||||
|
||||
interface AppleTVHeroProps {
|
||||
featuredContent: StreamingContent | null;
|
||||
|
|
@ -144,6 +150,16 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const insets = useSafeAreaInsets();
|
||||
const { settings, updateSetting } = useSettings();
|
||||
const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer();
|
||||
const { toggleLibrary, isInLibrary: checkIsInLibrary } = useLibrary();
|
||||
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved } = useToast();
|
||||
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
|
||||
|
||||
// Library and watch state
|
||||
const [inLibrary, setInLibrary] = useState(false);
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
const [isWatched, setIsWatched] = useState(false);
|
||||
const [playButtonText, setPlayButtonText] = useState('Play');
|
||||
const [type, setType] = useState<'movie' | 'series'>('movie');
|
||||
|
||||
// Create internal scrollY if not provided externally
|
||||
const internalScrollY = useSharedValue(0);
|
||||
|
|
@ -185,6 +201,18 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
|
||||
const currentItem = items[currentIndex] || null;
|
||||
|
||||
// Use watch progress hook
|
||||
const {
|
||||
watchProgress,
|
||||
getPlayButtonText: getProgressPlayButtonText,
|
||||
loadWatchProgress
|
||||
} = useWatchProgress(
|
||||
currentItem?.id || '',
|
||||
type,
|
||||
undefined,
|
||||
[] // Pass episodes if you have them for series
|
||||
);
|
||||
|
||||
// Animation values
|
||||
const dragProgress = useSharedValue(0);
|
||||
const dragDirection = useSharedValue(0); // -1 for left, 1 for right
|
||||
|
|
@ -196,6 +224,15 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const trailerMuted = settings?.trailerMuted ?? true;
|
||||
const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
|
||||
|
||||
// Handler for trailer end
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
logger.info('[AppleTVHero] Trailer ended');
|
||||
setTrailerPlaying(false);
|
||||
// Fade back to thumbnail
|
||||
trailerOpacity.value = withTiming(0, { duration: 300 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 300 });
|
||||
}, [setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
|
||||
|
||||
// Animated style for trailer container - 60% height with zoom
|
||||
const trailerContainerStyle = useAnimatedStyle(() => {
|
||||
// Faster fade out during drag - complete fade by 0.3 progress instead of 1.0
|
||||
|
|
@ -480,19 +517,196 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
logger.error('[AppleTVHero] Trailer playback error');
|
||||
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
|
||||
|
||||
// Handle trailer end
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
logger.info('[AppleTVHero] Trailer ended');
|
||||
setTrailerPlaying(false);
|
||||
// Update state when current item changes and load watch progress
|
||||
useEffect(() => {
|
||||
if (currentItem) {
|
||||
setType(currentItem.type as 'movie' | 'series');
|
||||
checkItemStatus(currentItem.id);
|
||||
loadWatchProgress();
|
||||
}
|
||||
}, [currentItem, loadWatchProgress]);
|
||||
|
||||
// Reset trailer state
|
||||
setTrailerReady(false);
|
||||
setTrailerPreloaded(false);
|
||||
// Update play button text and watched state when watch progress changes
|
||||
useEffect(() => {
|
||||
if (currentItem) {
|
||||
const buttonText = getProgressPlayButtonText();
|
||||
setPlayButtonText(buttonText);
|
||||
|
||||
// Smooth fade back to thumbnail
|
||||
trailerOpacity.value = withTiming(0, { duration: 500 });
|
||||
thumbnailOpacity.value = withTiming(1, { duration: 500 });
|
||||
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
|
||||
// Update watched state based on progress
|
||||
if (watchProgress) {
|
||||
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
|
||||
setIsWatched(progressPercent >= 85); // Consider watched if 85% or more completed
|
||||
} else {
|
||||
setIsWatched(false);
|
||||
}
|
||||
}
|
||||
}, [watchProgress, getProgressPlayButtonText, currentItem]);
|
||||
|
||||
// Function to check item status
|
||||
const checkItemStatus = useCallback(async (itemId: string) => {
|
||||
try {
|
||||
// Check if item is in library
|
||||
const libraryStatus = checkIsInLibrary(itemId);
|
||||
setInLibrary(libraryStatus);
|
||||
|
||||
// TODO: Check Trakt watchlist status if authenticated
|
||||
if (isTraktAuthenticated) {
|
||||
// await traktService.isInWatchlist(itemId);
|
||||
setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error checking item status:', error);
|
||||
}
|
||||
}, [checkIsInLibrary, isTraktAuthenticated]);
|
||||
|
||||
// Update the handleSaveAction function:
|
||||
const handleSaveAction = useCallback(async (e?: any) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (!currentItem) return;
|
||||
|
||||
const wasInLibrary = inLibrary;
|
||||
const wasInWatchlist = isInWatchlist;
|
||||
|
||||
// Update local state immediately for responsiveness
|
||||
setInLibrary(!wasInLibrary);
|
||||
|
||||
try {
|
||||
// Toggle library using the useLibrary hook
|
||||
const success = await toggleLibrary(currentItem);
|
||||
|
||||
if (success) {
|
||||
logger.info('[AppleTVHero] Successfully toggled library:', currentItem.name);
|
||||
} else {
|
||||
logger.warn('[AppleTVHero] Library toggle returned false');
|
||||
}
|
||||
|
||||
// If authenticated with Trakt, also toggle Trakt watchlist
|
||||
if (isTraktAuthenticated) {
|
||||
setIsInWatchlist(!wasInWatchlist);
|
||||
|
||||
// TODO: Replace with your actual Trakt service call
|
||||
// await traktService.toggleWatchlist(currentItem.id, !wasInWatchlist);
|
||||
logger.info('[AppleTVHero] Toggled Trakt watchlist');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error toggling library:', error);
|
||||
// Revert state on error
|
||||
setInLibrary(wasInLibrary);
|
||||
if (isTraktAuthenticated) {
|
||||
setIsInWatchlist(wasInWatchlist);
|
||||
}
|
||||
}
|
||||
}, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]);
|
||||
|
||||
// Play button handler - navigates to Streams screen with progress data if available
|
||||
const handlePlayAction = useCallback(async () => {
|
||||
logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name);
|
||||
if (!currentItem) return;
|
||||
|
||||
// Stop any playing trailer
|
||||
try {
|
||||
setTrailerPlaying(false);
|
||||
} catch {}
|
||||
|
||||
// Check if we should resume based on watch progress
|
||||
const shouldResume = watchProgress &&
|
||||
watchProgress.currentTime > 0 &&
|
||||
(watchProgress.currentTime / watchProgress.duration) < 0.85;
|
||||
|
||||
logger.info('[AppleTVHero] Should resume:', shouldResume, watchProgress);
|
||||
|
||||
try {
|
||||
// Check if we have a cached stream for this content
|
||||
const episodeId = currentItem.type === 'series' && watchProgress?.episodeId
|
||||
? watchProgress.episodeId
|
||||
: undefined;
|
||||
|
||||
logger.info('[AppleTVHero] Looking for cached stream with episodeId:', episodeId);
|
||||
|
||||
const cachedStream = await streamCacheService.getCachedStream(currentItem.id, currentItem.type, episodeId);
|
||||
|
||||
if (cachedStream && cachedStream.stream?.url) {
|
||||
// We have a valid cached stream, navigate directly to player
|
||||
logger.info('[AppleTVHero] Using cached stream for:', currentItem.name);
|
||||
|
||||
// Determine the player route based on platform
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
|
||||
// Navigate directly to player with cached stream data AND RESUME DATA
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri: cachedStream.stream.url,
|
||||
title: cachedStream.metadata?.name || currentItem.name,
|
||||
episodeTitle: cachedStream.episodeTitle,
|
||||
season: cachedStream.season,
|
||||
episode: cachedStream.episode,
|
||||
quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined,
|
||||
year: cachedStream.metadata?.year || currentItem.year,
|
||||
streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
|
||||
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
|
||||
headers: cachedStream.stream.headers || undefined,
|
||||
forceVlc: false,
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
episodeId: episodeId,
|
||||
imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || currentItem.imdb_id,
|
||||
backdrop: cachedStream.metadata?.backdrop || currentItem.banner,
|
||||
videoType: undefined, // Let player auto-detect
|
||||
// ADD RESUME DATA if we should resume
|
||||
...(shouldResume && watchProgress && {
|
||||
resumeTime: watchProgress.currentTime,
|
||||
duration: watchProgress.duration
|
||||
})
|
||||
} as any);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// No cached stream, navigate to Streams screen with resume data
|
||||
logger.info('[AppleTVHero] No cached stream, navigating to StreamsScreen for:', currentItem.name);
|
||||
|
||||
const navigationParams: any = {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
title: currentItem.name,
|
||||
metadata: {
|
||||
poster: currentItem.poster,
|
||||
banner: currentItem.banner,
|
||||
releaseInfo: currentItem.releaseInfo,
|
||||
genres: currentItem.genres
|
||||
}
|
||||
};
|
||||
|
||||
// Add resume data if we have progress that's not near completion
|
||||
if (shouldResume && watchProgress) {
|
||||
navigationParams.resumeTime = watchProgress.currentTime;
|
||||
navigationParams.duration = watchProgress.duration;
|
||||
navigationParams.episodeId = watchProgress.episodeId;
|
||||
logger.info('[AppleTVHero] Passing resume data to Streams:', watchProgress.currentTime, watchProgress.duration);
|
||||
}
|
||||
|
||||
navigation.navigate('Streams', navigationParams);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[AppleTVHero] Error handling play action:', error);
|
||||
// Fallback to StreamsScreen on any error
|
||||
navigation.navigate('Streams', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
title: currentItem.name,
|
||||
metadata: {
|
||||
poster: currentItem.poster,
|
||||
banner: currentItem.banner,
|
||||
releaseInfo: currentItem.releaseInfo,
|
||||
genres: currentItem.genres
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [currentItem, navigation, setTrailerPlaying, watchProgress]);
|
||||
|
||||
// Handle fullscreen toggle
|
||||
const handleFullscreenToggle = useCallback(async () => {
|
||||
|
|
@ -569,33 +783,6 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
);
|
||||
}, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
|
||||
|
||||
// Preload next and previous images for instant swiping
|
||||
useEffect(() => {
|
||||
if (items.length <= 1) return;
|
||||
|
||||
const prevIdx = (currentIndex - 1 + items.length) % items.length;
|
||||
const nextIdx = (currentIndex + 1) % items.length;
|
||||
|
||||
const prevItem = items[prevIdx];
|
||||
const nextItem = items[nextIdx];
|
||||
|
||||
const urlsToPreload: { uri: string }[] = [];
|
||||
|
||||
if (prevItem) {
|
||||
const url = prevItem.banner || prevItem.poster;
|
||||
if (url) urlsToPreload.push({ uri: url });
|
||||
}
|
||||
|
||||
if (nextItem) {
|
||||
const url = nextItem.banner || nextItem.poster;
|
||||
if (url) urlsToPreload.push({ uri: url });
|
||||
}
|
||||
|
||||
if (urlsToPreload.length > 0) {
|
||||
FastImage.preload(urlsToPreload);
|
||||
}
|
||||
}, [currentIndex, items]);
|
||||
|
||||
// Callback for updating interaction time
|
||||
const updateInteractionTime = useCallback(() => {
|
||||
lastInteractionRef.current = Date.now();
|
||||
|
|
@ -972,37 +1159,61 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
style={logoAnimatedStyle}
|
||||
>
|
||||
{currentItem.logo && !logoError[currentIndex] ? (
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: currentItem.logo }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
|
||||
onError={() => {
|
||||
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
|
||||
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{currentItem.name}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
>
|
||||
<View
|
||||
style={[
|
||||
styles.logoContainer,
|
||||
logoHeights[currentIndex] && logoHeights[currentIndex] < 80
|
||||
? { marginBottom: 4 } // Minimal spacing for small logos
|
||||
: { marginBottom: 8 } // Small spacing for normal logos
|
||||
]}
|
||||
onLayout={(event) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setLogoHeights((prev) => ({ ...prev, [currentIndex]: height }));
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
source={{ uri: currentItem.logo }}
|
||||
style={styles.logo}
|
||||
resizeMode="contain"
|
||||
onLoad={() => setLogoLoaded((prev) => ({ ...prev, [currentIndex]: true }))}
|
||||
onError={() => {
|
||||
setLogoError((prev) => ({ ...prev, [currentIndex]: true }));
|
||||
logger.warn('[AppleTVHero] Logo load failed:', currentItem.logo);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => {
|
||||
if (currentItem) {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={styles.title} numberOfLines={2}>
|
||||
{currentItem.name}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
|
||||
{/* Metadata Badge - Always Visible */}
|
||||
<View style={styles.metadataContainer}>
|
||||
|
|
@ -1020,21 +1231,33 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Action Buttons - Always Visible */}
|
||||
{/* Action Buttons - Play and Save buttons */}
|
||||
<View style={styles.buttonsContainer}>
|
||||
{/* Info Button */}
|
||||
{/* Play Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.playButton}
|
||||
onPress={() => {
|
||||
navigation.navigate('Metadata', {
|
||||
id: currentItem.id,
|
||||
type: currentItem.type,
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.8}
|
||||
style={[styles.playButton]}
|
||||
onPress={handlePlayAction}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="info-outline" size={28} color="#000" />
|
||||
<Text style={styles.playButtonText}>Info</Text>
|
||||
<MaterialIcons
|
||||
name={playButtonText === 'Resume' ? "replay" : "play-arrow"}
|
||||
size={24}
|
||||
color="#000"
|
||||
/>
|
||||
<Text style={styles.playButtonText}>{playButtonText}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Save Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.saveButton}
|
||||
onPress={handleSaveAction}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={inLibrary ? "bookmark" : "bookmark-outline"}
|
||||
size={24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
|
@ -1171,25 +1394,25 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#fff',
|
||||
paddingVertical: 14,
|
||||
paddingVertical: 11,
|
||||
paddingHorizontal: 32,
|
||||
borderRadius: 24,
|
||||
borderRadius: 40,
|
||||
gap: 8,
|
||||
minWidth: 140,
|
||||
minWidth: 130,
|
||||
},
|
||||
playButtonText: {
|
||||
color: '#000',
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
secondaryButton: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
saveButton: {
|
||||
width: 52,
|
||||
height: 52,
|
||||
borderRadius: 30,
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderWidth: 1,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.3)',
|
||||
},
|
||||
paginationContainer: {
|
||||
|
|
|
|||
|
|
@ -240,6 +240,44 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Helper function to find the next episode
|
||||
const findNextEpisode = useCallback((currentSeason: number, currentEpisode: number, videos: any[]) => {
|
||||
if (!videos || !Array.isArray(videos)) return null;
|
||||
|
||||
// Sort videos to ensure correct order
|
||||
const sortedVideos = [...videos].sort((a, b) => {
|
||||
if (a.season !== b.season) return a.season - b.season;
|
||||
return a.episode - b.episode;
|
||||
});
|
||||
|
||||
// Strategy 1: Look for next episode in the same season
|
||||
let nextEp = sortedVideos.find(v => v.season === currentSeason && v.episode === currentEpisode + 1);
|
||||
|
||||
// Strategy 2: If not found, look for the first episode of the next season
|
||||
if (!nextEp) {
|
||||
nextEp = sortedVideos.find(v => v.season === currentSeason + 1 && v.episode === 1);
|
||||
}
|
||||
|
||||
// Strategy 3: Just find the very next video in the list after the current one
|
||||
// This handles cases where episode numbering isn't sequential or S+1 E1 isn't the standard start
|
||||
if (!nextEp) {
|
||||
const currentIndex = sortedVideos.findIndex(v => v.season === currentSeason && v.episode === currentEpisode);
|
||||
if (currentIndex !== -1 && currentIndex + 1 < sortedVideos.length) {
|
||||
const candidate = sortedVideos[currentIndex + 1];
|
||||
// Ensure we didn't just jump to a random special; check reasonable bounds if needed,
|
||||
// but generally taking the next sorted item is correct for sequential viewing.
|
||||
nextEp = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify the found episode is released
|
||||
if (nextEp && isEpisodeReleased(nextEp)) {
|
||||
return nextEp;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
// Modified loadContinueWatching to render incrementally
|
||||
const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
|
||||
if (isRefreshingRef.current) {
|
||||
|
|
@ -432,42 +470,42 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const { episodeId, progress, progressPercent } = episode;
|
||||
|
||||
if (group.type === 'series' && progressPercent >= 85) {
|
||||
let nextSeason: number | undefined;
|
||||
let nextEpisode: number | undefined;
|
||||
// Local progress completion check
|
||||
if (episodeId) {
|
||||
let currentSeason: number | undefined;
|
||||
let currentEpisode: number | undefined;
|
||||
|
||||
const match = episodeId.match(/s(\d+)e(\d+)/i);
|
||||
if (match) {
|
||||
const currentSeason = parseInt(match[1], 10);
|
||||
const currentEpisode = parseInt(match[2], 10);
|
||||
nextSeason = currentSeason;
|
||||
nextEpisode = currentEpisode + 1;
|
||||
currentSeason = parseInt(match[1], 10);
|
||||
currentEpisode = parseInt(match[2], 10);
|
||||
} else {
|
||||
const parts = episodeId.split(':');
|
||||
if (parts.length >= 2) {
|
||||
const seasonNum = parseInt(parts[parts.length - 2], 10);
|
||||
const episodeNum = parseInt(parts[parts.length - 1], 10);
|
||||
if (!isNaN(seasonNum) && !isNaN(episodeNum)) {
|
||||
nextSeason = seasonNum;
|
||||
nextEpisode = episodeNum + 1;
|
||||
currentSeason = seasonNum;
|
||||
currentEpisode = episodeNum;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) {
|
||||
const nextEpisodeVideo = metadata.videos.find((video: any) =>
|
||||
video.season === nextSeason && video.episode === nextEpisode
|
||||
);
|
||||
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
|
||||
batch.push({
|
||||
...basicContent,
|
||||
id: group.id,
|
||||
type: group.type,
|
||||
progress: 0,
|
||||
lastUpdated: progress.lastUpdated,
|
||||
season: nextSeason,
|
||||
episode: nextEpisode,
|
||||
episodeTitle: `Episode ${nextEpisode}`,
|
||||
} as ContinueWatchingItem);
|
||||
|
||||
if (currentSeason !== undefined && currentEpisode !== undefined && metadata?.videos) {
|
||||
const nextEpisodeVideo = findNextEpisode(currentSeason, currentEpisode, metadata.videos);
|
||||
|
||||
if (nextEpisodeVideo) {
|
||||
batch.push({
|
||||
...basicContent,
|
||||
id: group.id,
|
||||
type: group.type,
|
||||
progress: 0,
|
||||
lastUpdated: progress.lastUpdated,
|
||||
season: nextEpisodeVideo.season,
|
||||
episode: nextEpisodeVideo.episode,
|
||||
episodeTitle: `Episode ${nextEpisodeVideo.episode}`,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
|
|
@ -532,23 +570,18 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
|
||||
// If watched on Trakt, treat it as completed (try to find next episode)
|
||||
if (isWatchedOnTrakt) {
|
||||
let nextSeason = season;
|
||||
let nextEpisode = (episodeNumber || 0) + 1;
|
||||
|
||||
if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) {
|
||||
const nextEpisodeVideo = metadata.videos.find((video: any) =>
|
||||
video.season === nextSeason && video.episode === nextEpisode
|
||||
);
|
||||
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
|
||||
if (season !== undefined && episodeNumber !== undefined && metadata?.videos) {
|
||||
const nextEpisodeVideo = findNextEpisode(season, episodeNumber, metadata.videos);
|
||||
if (nextEpisodeVideo) {
|
||||
batch.push({
|
||||
...basicContent,
|
||||
id: group.id,
|
||||
type: group.type,
|
||||
progress: 0,
|
||||
lastUpdated: progress.lastUpdated,
|
||||
season: nextSeason,
|
||||
episode: nextEpisode,
|
||||
episodeTitle: `Episode ${nextEpisode}`,
|
||||
season: nextEpisodeVideo.season,
|
||||
episode: nextEpisodeVideo.episode,
|
||||
episodeTitle: `Episode ${nextEpisodeVideo.episode}`,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
|
|
@ -614,28 +647,25 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
continue;
|
||||
}
|
||||
|
||||
const nextEpisode = info.episode + 1;
|
||||
const cachedData = await getCachedMetadata('series', showId);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
const { metadata, basicContent } = cachedData;
|
||||
let nextEpisodeVideo = null;
|
||||
if (metadata?.videos && Array.isArray(metadata.videos)) {
|
||||
nextEpisodeVideo = metadata.videos.find((video: any) =>
|
||||
video.season === info.season && video.episode === nextEpisode
|
||||
);
|
||||
}
|
||||
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
|
||||
logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`);
|
||||
traktBatch.push({
|
||||
...basicContent,
|
||||
id: showId,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: info.watchedAt,
|
||||
season: info.season,
|
||||
episode: nextEpisode,
|
||||
episodeTitle: `Episode ${nextEpisode}`,
|
||||
} as ContinueWatchingItem);
|
||||
|
||||
if (metadata?.videos) {
|
||||
const nextEpisodeVideo = findNextEpisode(info.season, info.episode, metadata.videos);
|
||||
if (nextEpisodeVideo) {
|
||||
logger.log(`➕ [TraktSync] Adding next episode for ${showId}: S${nextEpisodeVideo.season}E${nextEpisodeVideo.episode}`);
|
||||
traktBatch.push({
|
||||
...basicContent,
|
||||
id: showId,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: info.watchedAt,
|
||||
season: nextEpisodeVideo.season,
|
||||
episode: nextEpisodeVideo.episode,
|
||||
episodeTitle: `Episode ${nextEpisodeVideo.episode}`,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
|
||||
|
|
|
|||
|
|
@ -52,11 +52,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
const { settings } = useSettings();
|
||||
const { width } = useWindowDimensions();
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
|
||||
|
||||
// Enhanced responsive sizing for tablets and TV screens
|
||||
const deviceWidth = Dimensions.get('window').width;
|
||||
const deviceHeight = Dimensions.get('window').height;
|
||||
|
||||
|
||||
// Determine device type based on width
|
||||
const getDeviceType = useCallback(() => {
|
||||
if (deviceWidth >= BREAKPOINTS.tv) return 'tv';
|
||||
|
|
@ -64,13 +64,13 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
if (deviceWidth >= BREAKPOINTS.tablet) return 'tablet';
|
||||
return 'phone';
|
||||
}, [deviceWidth]);
|
||||
|
||||
|
||||
const deviceType = getDeviceType();
|
||||
const isTablet = deviceType === 'tablet';
|
||||
const isLargeTablet = deviceType === 'largeTablet';
|
||||
const isTV = deviceType === 'tv';
|
||||
const isLargeScreen = isTablet || isLargeTablet || isTV;
|
||||
|
||||
|
||||
// Enhanced spacing and padding for seasons section
|
||||
const horizontalPadding = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -124,7 +124,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return 16;
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
// Enhanced season poster sizing
|
||||
const seasonPosterWidth = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
|
|
@ -138,7 +138,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return 100; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const seasonPosterHeight = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -151,7 +151,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return 150; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const seasonButtonSpacing = useMemo(() => {
|
||||
switch (deviceType) {
|
||||
case 'tv':
|
||||
|
|
@ -164,7 +164,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return 16; // phone
|
||||
}
|
||||
}, [deviceType]);
|
||||
|
||||
|
||||
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: { currentTime: number; duration: number; lastUpdated: number } }>({});
|
||||
// Delay item entering animations to avoid FlashList initial layout glitches
|
||||
const [enableItemAnimations, setEnableItemAnimations] = useState(false);
|
||||
|
|
@ -172,14 +172,14 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
const [tmdbEpisodeOverrides, setTmdbEpisodeOverrides] = useState<{ [epKey: string]: { vote_average?: number; runtime?: number; still_path?: string } }>({});
|
||||
// IMDb ratings for episodes - using a map for O(1) lookups instead of array searches
|
||||
const [imdbRatingsMap, setImdbRatingsMap] = useState<{ [key: string]: number }>({});
|
||||
|
||||
|
||||
// Add state for season view mode (persists for current show across navigation)
|
||||
const [seasonViewMode, setSeasonViewMode] = useState<'posters' | 'text'>('posters');
|
||||
|
||||
|
||||
// View mode state (no animations)
|
||||
const [posterViewVisible, setPosterViewVisible] = useState(true);
|
||||
const [textViewVisible, setTextViewVisible] = useState(false);
|
||||
|
||||
|
||||
// Add refs for the scroll views
|
||||
const seasonScrollViewRef = useRef<ScrollView | null>(null);
|
||||
const episodeScrollViewRef = useRef<FlashListRef<Episode>>(null);
|
||||
|
|
@ -198,7 +198,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
if (__DEV__) console.log('[SeriesContent] Error loading global view mode preference:', error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadViewModePreference();
|
||||
}, []);
|
||||
|
||||
|
|
@ -222,17 +222,17 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
if (__DEV__) console.log('[SeriesContent] Error saving global view mode preference:', error);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Add refs for the scroll views
|
||||
|
||||
|
||||
|
||||
|
||||
const loadEpisodesProgress = async () => {
|
||||
if (!metadata?.id) return;
|
||||
|
||||
|
||||
const allProgress = await storageService.getAllWatchProgress();
|
||||
const progress: { [key: string]: { currentTime: number; duration: number; lastUpdated: number } } = {};
|
||||
|
||||
|
||||
episodes.forEach(episode => {
|
||||
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
||||
const key = `series:${metadata.id}:${episodeId}`;
|
||||
|
|
@ -244,7 +244,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ---------------- Trakt watched-history integration ----------------
|
||||
try {
|
||||
const traktService = TraktService.getInstance();
|
||||
|
|
@ -254,7 +254,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
// Each page has up to 100 items by default, fetch enough to cover ~12+ seasons
|
||||
let allHistoryItems: any[] = [];
|
||||
const pageLimit = 10; // Fetch up to 10 pages (max 1000 items) to cover extensive libraries
|
||||
|
||||
|
||||
for (let page = 1; page <= pageLimit; page++) {
|
||||
const historyItems = await traktService.getWatchedEpisodesHistory(page, 100);
|
||||
if (!historyItems || historyItems.length === 0) {
|
||||
|
|
@ -295,7 +295,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
} catch (err) {
|
||||
logger.error('[SeriesContent] Failed to merge Trakt history:', err);
|
||||
}
|
||||
|
||||
|
||||
setEpisodeProgress(progress);
|
||||
};
|
||||
|
||||
|
|
@ -304,28 +304,28 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
if (!metadata?.id || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
|
||||
if (currentSeasonEpisodes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Find the most recently watched episode in the current season
|
||||
let mostRecentEpisodeIndex = -1;
|
||||
let mostRecentTimestamp = 0;
|
||||
let mostRecentEpisodeName = '';
|
||||
|
||||
|
||||
currentSeasonEpisodes.forEach((episode, index) => {
|
||||
const episodeId = episode.stremioId || `${metadata.id}:${episode.season_number}:${episode.episode_number}`;
|
||||
const progress = episodeProgress[episodeId];
|
||||
|
||||
|
||||
if (progress && progress.lastUpdated > mostRecentTimestamp && progress.currentTime > 0) {
|
||||
mostRecentTimestamp = progress.lastUpdated;
|
||||
mostRecentEpisodeIndex = index;
|
||||
mostRecentEpisodeName = episode.name;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Scroll to the most recently watched episode if found
|
||||
if (mostRecentEpisodeIndex >= 0) {
|
||||
setTimeout(() => {
|
||||
|
|
@ -369,7 +369,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
} else {
|
||||
logger.log('[SeriesContent] metadata.id does not start with tmdb: or tt:', metadata.id);
|
||||
}
|
||||
|
||||
|
||||
if (!tmdbShowId) {
|
||||
logger.warn('[SeriesContent] Could not resolve TMDB show ID, skipping IMDb ratings fetch');
|
||||
return;
|
||||
|
|
@ -378,10 +378,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
logger.log('[SeriesContent] Fetching IMDb ratings for TMDB ID:', tmdbShowId);
|
||||
// Fetch IMDb ratings for all seasons
|
||||
const ratings = await tmdbService.getIMDbRatings(tmdbShowId);
|
||||
|
||||
|
||||
if (ratings) {
|
||||
logger.log('[SeriesContent] IMDb ratings fetched successfully. Seasons:', ratings.length);
|
||||
|
||||
|
||||
// Create a lookup map for O(1) access: key format "season:episode" -> rating
|
||||
const ratingsMap: { [key: string]: number } = {};
|
||||
ratings.forEach(season => {
|
||||
|
|
@ -394,7 +394,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
logger.log('[SeriesContent] IMDb ratings map created with', Object.keys(ratingsMap).length, 'episodes');
|
||||
setImdbRatingsMap(ratingsMap);
|
||||
} else {
|
||||
|
|
@ -472,7 +472,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return () => {
|
||||
// Clear any pending timeouts
|
||||
if (__DEV__) console.log('[SeriesContent] Component unmounted, cleaning up memory');
|
||||
|
||||
|
||||
// Force garbage collection if available (development only)
|
||||
if (__DEV__ && global.gc) {
|
||||
global.gc();
|
||||
|
|
@ -486,7 +486,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
// Find the index of the selected season
|
||||
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
||||
const selectedIndex = seasons.findIndex(season => season === selectedSeason);
|
||||
|
||||
|
||||
if (selectedIndex !== -1) {
|
||||
// Wait a small amount of time for layout to be ready
|
||||
setTimeout(() => {
|
||||
|
|
@ -540,11 +540,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (__DEV__) console.log('[SeriesContent] renderSeasonSelector called, current view mode:', seasonViewMode);
|
||||
|
||||
|
||||
|
||||
|
||||
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
||||
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.seasonSelectorWrapper,
|
||||
|
|
@ -558,22 +558,22 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
]}>
|
||||
<Text style={[
|
||||
styles.seasonSelectorTitle,
|
||||
{
|
||||
{
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 18
|
||||
}
|
||||
]}>Seasons</Text>
|
||||
|
||||
|
||||
{/* Dropdown Toggle Button */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.seasonViewToggle,
|
||||
{
|
||||
backgroundColor: seasonViewMode === 'posters'
|
||||
? currentTheme.colors.elevation2
|
||||
styles.seasonViewToggle,
|
||||
{
|
||||
backgroundColor: seasonViewMode === 'posters'
|
||||
? currentTheme.colors.elevation2
|
||||
: currentTheme.colors.elevation3,
|
||||
borderColor: seasonViewMode === 'posters'
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
borderColor: seasonViewMode === 'posters'
|
||||
? 'rgba(255,255,255,0.2)'
|
||||
: 'rgba(255,255,255,0.3)',
|
||||
paddingHorizontal: isTV ? 12 : isLargeTablet ? 10 : isTablet ? 8 : 8,
|
||||
paddingVertical: isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4,
|
||||
|
|
@ -588,10 +588,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[
|
||||
styles.seasonViewToggleText,
|
||||
{
|
||||
color: seasonViewMode === 'posters'
|
||||
? currentTheme.colors.mediumEmphasis
|
||||
styles.seasonViewToggleText,
|
||||
{
|
||||
color: seasonViewMode === 'posters'
|
||||
? currentTheme.colors.mediumEmphasis
|
||||
: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12
|
||||
}
|
||||
|
|
@ -600,7 +600,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
|
||||
<FlatList
|
||||
ref={seasonScrollViewRef as React.RefObject<FlatList<any>>}
|
||||
data={seasons}
|
||||
|
|
@ -618,7 +618,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
windowSize={3}
|
||||
renderItem={({ item: season }) => {
|
||||
const seasonEpisodes = groupedEpisodes[season] || [];
|
||||
|
||||
|
||||
// Get season poster URL (needed for both views)
|
||||
let seasonPoster = DEFAULT_PLACEHOLDER;
|
||||
if (seasonEpisodes[0]?.season_poster_path) {
|
||||
|
|
@ -627,12 +627,12 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
} else if (metadata?.poster) {
|
||||
seasonPoster = metadata.poster;
|
||||
}
|
||||
|
||||
|
||||
if (seasonViewMode === 'text') {
|
||||
// Text-only view
|
||||
if (__DEV__) console.log('[SeriesContent] Rendering text view for season:', season, 'View mode ref:', seasonViewMode);
|
||||
|
||||
return (
|
||||
<View
|
||||
<View
|
||||
key={season}
|
||||
style={{ opacity: textViewVisible ? 1 : 0 }}
|
||||
>
|
||||
|
|
@ -666,11 +666,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Poster view (current implementation)
|
||||
if (__DEV__) console.log('[SeriesContent] Rendering poster view for season:', season, 'View mode ref:', seasonViewMode);
|
||||
|
||||
return (
|
||||
<View
|
||||
<View
|
||||
key={season}
|
||||
style={{ opacity: posterViewVisible ? 1 : 0 }}
|
||||
>
|
||||
|
|
@ -710,10 +710,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
)}
|
||||
|
||||
</View>
|
||||
<Text
|
||||
<Text
|
||||
style={[
|
||||
styles.seasonButtonText,
|
||||
{
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14
|
||||
},
|
||||
|
|
@ -726,9 +726,9 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
Season {season}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
</View>
|
||||
);
|
||||
}}
|
||||
keyExtractor={season => season.toString()}
|
||||
/>
|
||||
</View>
|
||||
|
|
@ -763,11 +763,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
};
|
||||
|
||||
let episodeImage = resolveEpisodeImage();
|
||||
|
||||
|
||||
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
|
||||
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
|
||||
const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : '';
|
||||
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
|
|
@ -795,9 +795,9 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
const tmdbRating = tmdbOverride?.vote_average ?? episode.vote_average;
|
||||
const effectiveVote = imdbRating ?? tmdbRating ?? 0;
|
||||
const isImdbRating = imdbRating !== null;
|
||||
|
||||
logger.log(`[SeriesContent] Vertical card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`);
|
||||
|
||||
|
||||
|
||||
|
||||
const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime;
|
||||
if (!episode.still_path && tmdbOverride?.still_path) {
|
||||
const tmdbUrl = tmdbService.getImageUrl(tmdbOverride.still_path, 'original');
|
||||
|
|
@ -805,7 +805,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
}
|
||||
const progress = episodeProgress[episodeId];
|
||||
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
||||
|
||||
|
||||
// Don't show progress bar if episode is complete (>= 85%)
|
||||
const showProgress = progress && progressPercent < 85;
|
||||
|
||||
|
|
@ -813,8 +813,8 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
<TouchableOpacity
|
||||
key={episode.id}
|
||||
style={[
|
||||
styles.episodeCardVertical,
|
||||
{
|
||||
styles.episodeCardVertical,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation2,
|
||||
borderRadius: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
|
||||
marginBottom: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
|
||||
|
|
@ -854,11 +854,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
</View>
|
||||
{showProgress && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary }
|
||||
]}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -907,7 +907,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
]}>
|
||||
<Text style={[
|
||||
styles.episodeTitle,
|
||||
{
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15,
|
||||
lineHeight: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
|
||||
|
|
@ -1002,7 +1002,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
)}
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[
|
||||
<Text style={[
|
||||
styles.episodeOverview,
|
||||
{
|
||||
color: currentTheme.colors.mediumEmphasis,
|
||||
|
|
@ -1042,11 +1042,11 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
};
|
||||
|
||||
let episodeImage = resolveEpisodeImage();
|
||||
|
||||
|
||||
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
|
||||
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
|
||||
const episodeString = seasonNumber && episodeNumber ? `EPISODE ${episodeNumber}` : '';
|
||||
|
||||
|
||||
const formatRuntime = (runtime: number) => {
|
||||
if (!runtime) return null;
|
||||
const hours = Math.floor(runtime / 60);
|
||||
|
|
@ -1066,9 +1066,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
const effectiveVote = imdbRating ?? tmdbRating ?? 0;
|
||||
const isImdbRating = imdbRating !== null;
|
||||
const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime;
|
||||
|
||||
logger.log(`[SeriesContent] Horizontal card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`);
|
||||
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
|
|
@ -1077,10 +1075,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
const progress = episodeProgress[episodeId];
|
||||
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
||||
|
||||
|
||||
// Don't show progress bar if episode is complete (>= 85%)
|
||||
const showProgress = progress && progressPercent < 85;
|
||||
|
||||
|
|
@ -1097,7 +1095,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
shadowRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 8
|
||||
},
|
||||
// Gradient border styling
|
||||
{
|
||||
{
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255,255,255,0.12)',
|
||||
shadowColor: '#000',
|
||||
|
|
@ -1115,12 +1113,12 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
style={styles.episodeBackgroundImage}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
|
||||
|
||||
{/* Standard Gradient Overlay */}
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.05)',
|
||||
'rgba(0,0,0,0.2)',
|
||||
'rgba(0,0,0,0.2)',
|
||||
'rgba(0,0,0,0.6)',
|
||||
'rgba(0,0,0,0.85)',
|
||||
'rgba(0,0,0,0.95)'
|
||||
|
|
@ -1146,15 +1144,15 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
marginBottom: isTV ? 10 : isLargeTablet ? 8 : isTablet ? 6 : 6
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.episodeNumberHorizontal,
|
||||
{
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10,
|
||||
fontWeight: isTV ? '700' : isLargeTablet ? '700' : isTablet ? '600' : '600'
|
||||
}
|
||||
]}>{episodeString}</Text>
|
||||
<Text style={[
|
||||
styles.episodeNumberHorizontal,
|
||||
{
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 10,
|
||||
fontWeight: isTV ? '700' : isLargeTablet ? '700' : isTablet ? '600' : '600'
|
||||
}
|
||||
]}>{episodeString}</Text>
|
||||
</View>
|
||||
|
||||
|
||||
{/* Episode Title */}
|
||||
<Text style={[
|
||||
styles.episodeTitleHorizontal,
|
||||
|
|
@ -1167,9 +1165,9 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
]} numberOfLines={2}>
|
||||
{episode.name}
|
||||
</Text>
|
||||
|
||||
|
||||
{/* Episode Description */}
|
||||
<Text style={[
|
||||
<Text style={[
|
||||
styles.episodeDescriptionHorizontal,
|
||||
{
|
||||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 12,
|
||||
|
|
@ -1180,7 +1178,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
]} numberOfLines={isLargeScreen ? 4 : 3}>
|
||||
{(episode.overview || (episode as any).description || (episode as any).plot || (episode as any).synopsis || 'No description available')}
|
||||
</Text>
|
||||
|
||||
|
||||
{/* Metadata Row */}
|
||||
<View style={[
|
||||
styles.episodeMetadataRowHorizontal,
|
||||
|
|
@ -1258,27 +1256,27 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{/* Progress Bar */}
|
||||
{showProgress && (
|
||||
<View style={styles.progressBarContainerHorizontal}>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
styles.progressBarHorizontal,
|
||||
{
|
||||
width: `${progressPercent}%`,
|
||||
{
|
||||
width: `${progressPercent}%`,
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
}
|
||||
]}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{/* Completed Badge */}
|
||||
{progressPercent >= 85 && (
|
||||
<View style={[
|
||||
styles.completedBadgeHorizontal,
|
||||
{
|
||||
{
|
||||
backgroundColor: currentTheme.colors.primary,
|
||||
width: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
|
||||
height: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 24 : 24,
|
||||
|
|
@ -1304,7 +1302,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
opacity: 0.9,
|
||||
}} />
|
||||
)}
|
||||
|
||||
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
|
@ -1314,13 +1312,13 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(50)}
|
||||
>
|
||||
{renderSeasonSelector()}
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(300).delay(100)}
|
||||
>
|
||||
<Text style={[
|
||||
|
|
@ -1334,7 +1332,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
]}>
|
||||
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
|
||||
</Text>
|
||||
|
||||
|
||||
{/* Show message when no episodes are available for selected season */}
|
||||
{currentSeasonEpisodes.length === 0 && (
|
||||
<View style={styles.centeredContainer}>
|
||||
|
|
@ -1347,7 +1345,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
||||
{/* Only render episode list if there are episodes */}
|
||||
{currentSeasonEpisodes.length > 0 && (
|
||||
(settings?.episodeLayoutStyle === 'horizontal') ? (
|
||||
|
|
@ -1417,7 +1415,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
ref={episodeScrollViewRef}
|
||||
data={currentSeasonEpisodes}
|
||||
renderItem={({ item: episode, index }) => (
|
||||
<Animated.View
|
||||
<Animated.View
|
||||
entering={enableItemAnimations ? FadeIn.duration(300).delay(100 + index * 30) : undefined as any}
|
||||
>
|
||||
{renderVerticalEpisodeCard(episode)}
|
||||
|
|
@ -1474,7 +1472,7 @@ const styles = StyleSheet.create({
|
|||
episodeList: {
|
||||
flex: 1,
|
||||
},
|
||||
|
||||
|
||||
// Vertical Layout Styles
|
||||
episodeListContentVertical: {
|
||||
paddingBottom: 8,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image } from 'react-native';
|
||||
import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image, InteractionManager } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType, ViewType } from 'react-native-video';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
|
@ -641,43 +641,51 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
// Prefetch backdrop and title logo for faster loading screen appearance
|
||||
useEffect(() => {
|
||||
if (backdrop && typeof backdrop === 'string') {
|
||||
// Reset loading state
|
||||
setIsBackdropLoaded(false);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
// Defer prefetching until after navigation animation completes
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
if (backdrop && typeof backdrop === 'string') {
|
||||
// Reset loading state
|
||||
setIsBackdropLoaded(false);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
|
||||
// Prefetch the image
|
||||
try {
|
||||
FastImage.preload([{ uri: backdrop }]);
|
||||
// Image prefetch initiated, fade it in smoothly
|
||||
// Prefetch the image
|
||||
try {
|
||||
FastImage.preload([{ uri: backdrop }]);
|
||||
// Image prefetch initiated, fade it in smoothly
|
||||
setIsBackdropLoaded(true);
|
||||
Animated.timing(backdropImageOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} catch (error) {
|
||||
// If prefetch fails, still show the image but without animation
|
||||
if (__DEV__) logger.warn('[AndroidVideoPlayer] Backdrop prefetch failed, showing anyway:', error);
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(1);
|
||||
}
|
||||
} else {
|
||||
// No backdrop provided, consider it "loaded"
|
||||
setIsBackdropLoaded(true);
|
||||
Animated.timing(backdropImageOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} catch (error) {
|
||||
// If prefetch fails, still show the image but without animation
|
||||
if (__DEV__) logger.warn('[AndroidVideoPlayer] Backdrop prefetch failed, showing anyway:', error);
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(1);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
}
|
||||
} else {
|
||||
// No backdrop provided, consider it "loaded"
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
}
|
||||
});
|
||||
return () => task.cancel();
|
||||
}, [backdrop]);
|
||||
|
||||
useEffect(() => {
|
||||
const logoUrl = (metadata && (metadata as any).logo) as string | undefined;
|
||||
if (logoUrl && typeof logoUrl === 'string') {
|
||||
try {
|
||||
FastImage.preload([{ uri: logoUrl }]);
|
||||
} catch (error) {
|
||||
// Silently ignore logo prefetch errors
|
||||
// Defer logo prefetch until after navigation animation
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
const logoUrl = (metadata && (metadata as any).logo) as string | undefined;
|
||||
if (logoUrl && typeof logoUrl === 'string') {
|
||||
try {
|
||||
FastImage.preload([{ uri: logoUrl }]);
|
||||
} catch (error) {
|
||||
// Silently ignore logo prefetch errors
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => task.cancel();
|
||||
}, [metadata]);
|
||||
|
||||
// Resolve current episode description for series
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState } from 'react-native';
|
||||
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, InteractionManager } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
|
@ -342,43 +342,51 @@ const KSPlayerCore: React.FC = () => {
|
|||
// Load custom backdrop on mount
|
||||
// Prefetch backdrop and title logo for faster loading screen appearance
|
||||
useEffect(() => {
|
||||
if (backdrop && typeof backdrop === 'string') {
|
||||
// Reset loading state
|
||||
setIsBackdropLoaded(false);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
// Defer prefetching until after navigation animation completes
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
if (backdrop && typeof backdrop === 'string') {
|
||||
// Reset loading state
|
||||
setIsBackdropLoaded(false);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
|
||||
// Prefetch the image
|
||||
try {
|
||||
FastImage.preload([{ uri: backdrop }]);
|
||||
// Image prefetch initiated, fade it in smoothly
|
||||
// Prefetch the image
|
||||
try {
|
||||
FastImage.preload([{ uri: backdrop }]);
|
||||
// Image prefetch initiated, fade it in smoothly
|
||||
setIsBackdropLoaded(true);
|
||||
Animated.timing(backdropImageOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} catch (error) {
|
||||
// If prefetch fails, still show the image but without animation
|
||||
if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error);
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(1);
|
||||
}
|
||||
} else {
|
||||
// No backdrop provided, consider it "loaded"
|
||||
setIsBackdropLoaded(true);
|
||||
Animated.timing(backdropImageOpacityAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
} catch (error) {
|
||||
// If prefetch fails, still show the image but without animation
|
||||
if (__DEV__) logger.warn('[VideoPlayer] Backdrop prefetch failed, showing anyway:', error);
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(1);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
}
|
||||
} else {
|
||||
// No backdrop provided, consider it "loaded"
|
||||
setIsBackdropLoaded(true);
|
||||
backdropImageOpacityAnim.setValue(0);
|
||||
}
|
||||
});
|
||||
return () => task.cancel();
|
||||
}, [backdrop]);
|
||||
|
||||
useEffect(() => {
|
||||
const logoUrl = (metadata && (metadata as any).logo) as string | undefined;
|
||||
if (logoUrl && typeof logoUrl === 'string') {
|
||||
try {
|
||||
FastImage.preload([{ uri: logoUrl }]);
|
||||
} catch (error) {
|
||||
// Silently ignore logo prefetch errors
|
||||
// Defer logo prefetch until after navigation animation
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
const logoUrl = (metadata && (metadata as any).logo) as string | undefined;
|
||||
if (logoUrl && typeof logoUrl === 'string') {
|
||||
try {
|
||||
FastImage.preload([{ uri: logoUrl }]);
|
||||
} catch (error) {
|
||||
// Silently ignore logo prefetch errors
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
return () => task.cancel();
|
||||
}, [metadata]);
|
||||
|
||||
// Log video source configuration with headers
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ export interface AppSettings {
|
|||
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
|
||||
useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: AppSettings = {
|
||||
|
|
@ -122,6 +123,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
alwaysResume: true,
|
||||
// Downloads
|
||||
enableDownloads: false,
|
||||
useExternalPlayerForDownloads: false,
|
||||
// Theme defaults
|
||||
themeId: 'default',
|
||||
customThemes: [],
|
||||
|
|
@ -162,12 +164,12 @@ export const useSettings = () => {
|
|||
|
||||
useEffect(() => {
|
||||
loadSettings();
|
||||
|
||||
|
||||
// Subscribe to settings changes
|
||||
const unsubscribe = settingsEmitter.addListener(() => {
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
|
||||
return unsubscribe;
|
||||
}, []);
|
||||
|
||||
|
|
@ -183,13 +185,13 @@ export const useSettings = () => {
|
|||
|
||||
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
||||
const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`;
|
||||
|
||||
|
||||
// Use synchronous MMKV reads for better performance
|
||||
const [scopedJson, legacyJson] = await Promise.all([
|
||||
mmkvStorage.getItem(scopedKey),
|
||||
mmkvStorage.getItem(SETTINGS_STORAGE_KEY),
|
||||
]);
|
||||
|
||||
|
||||
const parsedScoped = scopedJson ? JSON.parse(scopedJson) : null;
|
||||
const parsedLegacy = legacyJson ? JSON.parse(legacyJson) : null;
|
||||
|
||||
|
|
@ -202,16 +204,16 @@ export const useSettings = () => {
|
|||
if (scoped) {
|
||||
try {
|
||||
merged = JSON.parse(scoped);
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
}
|
||||
|
||||
const finalSettings = merged ? { ...DEFAULT_SETTINGS, ...merged } : DEFAULT_SETTINGS;
|
||||
|
||||
|
||||
// Update cache
|
||||
cachedSettings = finalSettings;
|
||||
settingsCacheTimestamp = now;
|
||||
|
||||
|
||||
setSettings(finalSettings);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to load settings:', error);
|
||||
|
|
@ -231,23 +233,23 @@ export const useSettings = () => {
|
|||
) => {
|
||||
const newSettings = { ...settings, [key]: value };
|
||||
try {
|
||||
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
||||
const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`;
|
||||
// Write to both scoped key (multi-user aware) and legacy key for backward compatibility
|
||||
await Promise.all([
|
||||
mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)),
|
||||
mmkvStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)),
|
||||
]);
|
||||
// Ensure a current scope exists to avoid future loads missing the chosen scope
|
||||
await mmkvStorage.setItem('@user:current', scope);
|
||||
|
||||
const scope = (await mmkvStorage.getItem('@user:current')) || 'local';
|
||||
const scopedKey = `@user:${scope}:${SETTINGS_STORAGE_KEY}`;
|
||||
// Write to both scoped key (multi-user aware) and legacy key for backward compatibility
|
||||
await Promise.all([
|
||||
mmkvStorage.setItem(scopedKey, JSON.stringify(newSettings)),
|
||||
mmkvStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(newSettings)),
|
||||
]);
|
||||
// Ensure a current scope exists to avoid future loads missing the chosen scope
|
||||
await mmkvStorage.setItem('@user:current', scope);
|
||||
|
||||
// Update cache
|
||||
cachedSettings = newSettings;
|
||||
settingsCacheTimestamp = Date.now();
|
||||
|
||||
|
||||
setSettings(newSettings);
|
||||
if (__DEV__) console.log(`Setting updated: ${key}`, value);
|
||||
|
||||
|
||||
// Notify all subscribers that settings have changed (if requested)
|
||||
if (emitEvent) {
|
||||
if (__DEV__) console.log('Emitting settings change event');
|
||||
|
|
|
|||
|
|
@ -1210,7 +1210,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
options={{
|
||||
animation: 'default',
|
||||
animationDuration: 0,
|
||||
// Force fullscreen presentation on iPad
|
||||
// fullScreenModal required for proper video rendering on iOS
|
||||
presentation: 'fullScreenModal',
|
||||
// Disable gestures during video playback
|
||||
gestureEnabled: false,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
Alert,
|
||||
Platform,
|
||||
Clipboard,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useFocusEffect } from '@react-navigation/native';
|
||||
|
|
@ -28,9 +29,12 @@ import { RootStackParamList } from '../navigation/AppNavigator';
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useDownloads } from '../contexts/DownloadsContext';
|
||||
import { useSettings } from '../hooks/useSettings';
|
||||
import { VideoPlayerService } from '../services/videoPlayerService';
|
||||
import type { DownloadItem } from '../contexts/DownloadsContext';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
import ScreenHeader from '../components/common/ScreenHeader';
|
||||
|
||||
const { height, width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
|
@ -60,7 +64,7 @@ const optimizePosterUrl = (poster: string | undefined | null): string => {
|
|||
// Empty state component
|
||||
const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamList> }> = ({ navigation }) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.emptyContainer}>
|
||||
<View style={[styles.emptyIconContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
|
|
@ -76,7 +80,7 @@ const EmptyDownloadsState: React.FC<{ navigation: NavigationProp<RootStackParamL
|
|||
<Text style={[styles.emptySubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Downloaded content will appear here for offline viewing
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={[styles.exploreButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => {
|
||||
navigation.navigate('Search');
|
||||
|
|
@ -129,12 +133,12 @@ const DownloadItemComponent: React.FC<{
|
|||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
if (!bytes || bytes <= 0) return '0 B';
|
||||
const sizes = ['B','KB','MB','GB','TB'];
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
||||
const v = bytes / Math.pow(1024, i);
|
||||
return `${v.toFixed(v >= 100 ? 0 : v >= 10 ? 1 : 2)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
|
||||
const getStatusColor = () => {
|
||||
switch (item.status) {
|
||||
case 'downloading':
|
||||
|
|
@ -218,10 +222,10 @@ const DownloadItemComponent: React.FC<{
|
|||
<MaterialCommunityIcons
|
||||
name={
|
||||
item.status === 'completed' ? 'check' :
|
||||
item.status === 'downloading' ? 'download' :
|
||||
item.status === 'paused' ? 'pause' :
|
||||
item.status === 'error' ? 'alert-circle' :
|
||||
'clock'
|
||||
item.status === 'downloading' ? 'download' :
|
||||
item.status === 'paused' ? 'pause' :
|
||||
item.status === 'error' ? 'alert-circle' :
|
||||
'clock'
|
||||
}
|
||||
size={12}
|
||||
color="white"
|
||||
|
|
@ -234,10 +238,10 @@ const DownloadItemComponent: React.FC<{
|
|||
<View style={styles.downloadHeader}>
|
||||
<View style={styles.titleContainer}>
|
||||
<Text style={[styles.downloadTitle, { color: currentTheme.colors.text }]} numberOfLines={1}>
|
||||
{item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2,'0')}E${String(item.episode).padStart(2,'0')}` : ''}
|
||||
{item.title}{item.type === 'series' && item.season && item.episode ? ` S${String(item.season).padStart(2, '0')}E${String(item.episode).padStart(2, '0')}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
||||
{item.type === 'series' && (
|
||||
<Text style={[styles.episodeInfo, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={1}>
|
||||
S{item.season?.toString().padStart(2, '0')}E{item.episode?.toString().padStart(2, '0')} • {item.episodeTitle}
|
||||
|
|
@ -293,7 +297,7 @@ const DownloadItemComponent: React.FC<{
|
|||
]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.progressDetails}>
|
||||
<Text style={[styles.progressPercentage, { color: currentTheme.colors.text }]}>
|
||||
{item.progress || 0}%
|
||||
|
|
@ -322,7 +326,7 @@ const DownloadItemComponent: React.FC<{
|
|||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={() => onRequestRemove(item)}
|
||||
|
|
@ -342,7 +346,7 @@ const DownloadItemComponent: React.FC<{
|
|||
const DownloadsScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const { top: safeAreaTop } = useSafeAreaInsets();
|
||||
const { settings } = useSettings();
|
||||
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
|
||||
const { showSuccess, showInfo } = useToast();
|
||||
|
||||
|
|
@ -352,9 +356,6 @@ const DownloadsScreen: React.FC = () => {
|
|||
const [showRemoveAlert, setShowRemoveAlert] = useState(false);
|
||||
const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null);
|
||||
|
||||
// Animation values
|
||||
const headerOpacity = useSharedValue(1);
|
||||
|
||||
// Filter downloads based on selected filter
|
||||
const filteredDownloads = useMemo(() => {
|
||||
if (selectedFilter === 'all') return downloads;
|
||||
|
|
@ -394,7 +395,7 @@ const DownloadsScreen: React.FC = () => {
|
|||
setIsRefreshing(false);
|
||||
}, []);
|
||||
|
||||
const handleDownloadPress = useCallback((item: DownloadItem) => {
|
||||
const handleDownloadPress = useCallback(async (item: DownloadItem) => {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
if (item.status !== 'completed') {
|
||||
Alert.alert('Download not ready', 'Please wait until the download completes.');
|
||||
|
|
@ -411,33 +412,132 @@ const DownloadsScreen: React.FC = () => {
|
|||
const isMp4 = /\.mp4(\?|$)/i.test(lower);
|
||||
const videoType = isM3u8 ? 'm3u8' : isMpd ? 'mpd' : isMp4 ? 'mp4' : undefined;
|
||||
|
||||
// Build episodeId for series progress tracking (format: contentId:season:episode)
|
||||
const episodeId = item.type === 'series' && item.season && item.episode
|
||||
? `${item.contentId}:${item.season}:${item.episode}`
|
||||
: undefined;
|
||||
// Use external player if enabled in settings
|
||||
if (settings.useExternalPlayerForDownloads) {
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
// Use VideoPlayerService for Android external playback
|
||||
const success = await VideoPlayerService.playVideo(uri, {
|
||||
useExternalPlayer: true,
|
||||
title: item.title,
|
||||
episodeTitle: item.type === 'series' ? item.episodeTitle : undefined,
|
||||
episodeNumber: item.type === 'series' && item.season && item.episode ? `S${item.season}E${item.episode}` : undefined,
|
||||
});
|
||||
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri,
|
||||
title: item.title,
|
||||
episodeTitle: item.type === 'series' ? item.episodeTitle : undefined,
|
||||
season: item.type === 'series' ? item.season : undefined,
|
||||
episode: item.type === 'series' ? item.episode : undefined,
|
||||
quality: item.quality,
|
||||
year: undefined,
|
||||
streamProvider: 'Downloads',
|
||||
streamName: item.providerName || 'Offline',
|
||||
headers: undefined,
|
||||
forceVlc: Platform.OS === 'android' ? isMkv : false,
|
||||
id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking
|
||||
type: item.type,
|
||||
episodeId: episodeId, // Pass episodeId for series progress tracking
|
||||
imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId
|
||||
availableStreams: {},
|
||||
backdrop: undefined,
|
||||
videoType,
|
||||
} as any);
|
||||
}, [navigation]);
|
||||
if (success) return;
|
||||
// Fall through to internal player if external fails
|
||||
} catch (error) {
|
||||
console.error('External player failed:', error);
|
||||
// Fall through to internal player
|
||||
}
|
||||
} else if (Platform.OS === 'ios') {
|
||||
const streamUrl = encodeURIComponent(uri);
|
||||
let externalPlayerUrls: string[] = [];
|
||||
|
||||
switch (settings.preferredPlayer) {
|
||||
case 'vlc':
|
||||
externalPlayerUrls = [
|
||||
`vlc://${uri}`,
|
||||
`vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
|
||||
`vlc://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'outplayer':
|
||||
externalPlayerUrls = [
|
||||
`outplayer://${uri}`,
|
||||
`outplayer://${streamUrl}`,
|
||||
`outplayer://play?url=${streamUrl}`,
|
||||
`outplayer://stream?url=${streamUrl}`,
|
||||
`outplayer://play/browser?url=${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'infuse':
|
||||
externalPlayerUrls = [
|
||||
`infuse://x-callback-url/play?url=${streamUrl}`,
|
||||
`infuse://play?url=${streamUrl}`,
|
||||
`infuse://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'vidhub':
|
||||
externalPlayerUrls = [
|
||||
`vidhub://play?url=${streamUrl}`,
|
||||
`vidhub://${streamUrl}`
|
||||
];
|
||||
break;
|
||||
|
||||
case 'infuse_livecontainer':
|
||||
const infuseUrls = [
|
||||
`infuse://x-callback-url/play?url=${streamUrl}`,
|
||||
`infuse://play?url=${streamUrl}`,
|
||||
`infuse://${streamUrl}`
|
||||
];
|
||||
externalPlayerUrls = infuseUrls.map(infuseUrl => {
|
||||
const encoded = Buffer.from(infuseUrl).toString('base64');
|
||||
return `livecontainer://open-url?url=${encoded}`;
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
// Internal logic will handle 'internal' choice
|
||||
break;
|
||||
}
|
||||
|
||||
if (settings.preferredPlayer !== 'internal') {
|
||||
// Try each URL format in sequence
|
||||
const tryNextUrl = (index: number) => {
|
||||
if (index >= externalPlayerUrls.length) {
|
||||
// Fallback to internal player if all external attempts fail
|
||||
openInternalPlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
const url = externalPlayerUrls[index];
|
||||
Linking.openURL(url)
|
||||
.catch(() => tryNextUrl(index + 1));
|
||||
};
|
||||
|
||||
if (externalPlayerUrls.length > 0) {
|
||||
tryNextUrl(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openInternalPlayer = () => {
|
||||
// Build episodeId for series progress tracking (format: contentId:season:episode)
|
||||
const episodeId = item.type === 'series' && item.season && item.episode
|
||||
? `${item.contentId}:${item.season}:${item.episode}`
|
||||
: undefined;
|
||||
|
||||
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
|
||||
navigation.navigate(playerRoute as any, {
|
||||
uri,
|
||||
title: item.title,
|
||||
episodeTitle: item.type === 'series' ? item.episodeTitle : undefined,
|
||||
season: item.type === 'series' ? item.season : undefined,
|
||||
episode: item.type === 'series' ? item.episode : undefined,
|
||||
quality: item.quality,
|
||||
year: undefined,
|
||||
streamProvider: 'Downloads',
|
||||
streamName: item.providerName || 'Offline',
|
||||
headers: undefined,
|
||||
forceVlc: Platform.OS === 'android' ? isMkv : false,
|
||||
id: item.contentId, // Use contentId (base ID) instead of compound id for progress tracking
|
||||
type: item.type,
|
||||
episodeId: episodeId, // Pass episodeId for series progress tracking
|
||||
imdbId: (item as any).imdbId || item.contentId, // Use imdbId if available, fallback to contentId
|
||||
availableStreams: {},
|
||||
backdrop: undefined,
|
||||
videoType,
|
||||
} as any);
|
||||
};
|
||||
|
||||
openInternalPlayer();
|
||||
}, [navigation, settings]);
|
||||
|
||||
const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => {
|
||||
if (action === 'pause') pauseDownload(item.id);
|
||||
|
|
@ -468,19 +568,14 @@ const DownloadsScreen: React.FC = () => {
|
|||
}, [])
|
||||
);
|
||||
|
||||
// Animated styles
|
||||
const headerStyle = useAnimatedStyle(() => ({
|
||||
opacity: headerOpacity.value,
|
||||
}));
|
||||
|
||||
const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => (
|
||||
<TouchableOpacity
|
||||
key={filter}
|
||||
style={[
|
||||
styles.filterButton,
|
||||
{
|
||||
backgroundColor: selectedFilter === filter
|
||||
? currentTheme.colors.primary
|
||||
backgroundColor: selectedFilter === filter
|
||||
? currentTheme.colors.primary
|
||||
: currentTheme.colors.elevation1,
|
||||
}
|
||||
]}
|
||||
|
|
@ -490,8 +585,8 @@ const DownloadsScreen: React.FC = () => {
|
|||
<Text style={[
|
||||
styles.filterButtonText,
|
||||
{
|
||||
color: selectedFilter === filter
|
||||
? currentTheme.colors.white
|
||||
color: selectedFilter === filter
|
||||
? currentTheme.colors.white
|
||||
: currentTheme.colors.text,
|
||||
}
|
||||
]}>
|
||||
|
|
@ -501,16 +596,16 @@ const DownloadsScreen: React.FC = () => {
|
|||
<View style={[
|
||||
styles.filterBadge,
|
||||
{
|
||||
backgroundColor: selectedFilter === filter
|
||||
? currentTheme.colors.white
|
||||
backgroundColor: selectedFilter === filter
|
||||
? currentTheme.colors.white
|
||||
: currentTheme.colors.primary,
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.filterBadgeText,
|
||||
{
|
||||
color: selectedFilter === filter
|
||||
? currentTheme.colors.primary
|
||||
color: selectedFilter === filter
|
||||
? currentTheme.colors.primary
|
||||
: currentTheme.colors.white,
|
||||
}
|
||||
]}>
|
||||
|
|
@ -529,22 +624,10 @@ const DownloadsScreen: React.FC = () => {
|
|||
backgroundColor="transparent"
|
||||
/>
|
||||
|
||||
{/* Header */}
|
||||
<Animated.View style={[
|
||||
styles.header,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
paddingTop: (Platform.OS === 'android'
|
||||
? (StatusBar.currentHeight || 0) + 26
|
||||
: safeAreaTop + 15) + (isTablet ? 64 : 0),
|
||||
borderBottomColor: currentTheme.colors.border,
|
||||
},
|
||||
headerStyle,
|
||||
]}>
|
||||
<View style={styles.headerTitleRow}>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
||||
Downloads
|
||||
</Text>
|
||||
{/* ScreenHeader Component */}
|
||||
<ScreenHeader
|
||||
title="Downloads"
|
||||
rightActionComponent={
|
||||
<TouchableOpacity
|
||||
style={styles.helpButton}
|
||||
onPress={showDownloadHelp}
|
||||
|
|
@ -556,8 +639,9 @@ const DownloadsScreen: React.FC = () => {
|
|||
color={currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
}
|
||||
isTablet={isTablet}
|
||||
>
|
||||
{downloads.length > 0 && (
|
||||
<View style={styles.filterContainer}>
|
||||
{renderFilterButton('all', 'All', stats.total)}
|
||||
|
|
@ -566,7 +650,7 @@ const DownloadsScreen: React.FC = () => {
|
|||
{renderFilterButton('paused', 'Paused', stats.paused)}
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
</ScreenHeader>
|
||||
|
||||
{/* Content */}
|
||||
{downloads.length === 0 ? (
|
||||
|
|
@ -624,10 +708,10 @@ const DownloadsScreen: React.FC = () => {
|
|||
<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?'}
|
||||
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: { } },
|
||||
{ label: 'Remove', onPress: () => { if (pendingRemoveItem) { cancelDownload(pendingRemoveItem.id); } setShowRemoveAlert(false); setPendingRemoveItem(null); }, style: {} },
|
||||
]}
|
||||
onClose={() => { setShowRemoveAlert(false); setPendingRemoveItem(null); }}
|
||||
/>
|
||||
|
|
@ -639,23 +723,6 @@ const styles = StyleSheet.create({
|
|||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05),
|
||||
paddingBottom: isTablet ? 20 : 16,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
headerTitleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: isTablet ? 20 : 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: isTablet ? 36 : Math.min(32, width * 0.08),
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
helpButton: {
|
||||
padding: 8,
|
||||
marginLeft: 8,
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Share } from 'react-native';
|
|||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import { useToast } from '../contexts/ToastContext';
|
||||
import DropUpMenu from '../components/home/DropUpMenu';
|
||||
import ScreenHeader from '../components/common/ScreenHeader';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -217,7 +218,7 @@ const LibraryScreen = () => {
|
|||
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
|
||||
const insets = useSafeAreaInsets();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings(); // ADD THIS
|
||||
const { settings } = useSettings();
|
||||
|
||||
// Trakt integration
|
||||
const {
|
||||
|
|
@ -760,15 +761,15 @@ const LibraryScreen = () => {
|
|||
|
||||
// Show collection folders
|
||||
return (
|
||||
<FlashList
|
||||
<FlashList
|
||||
data={traktFolders}
|
||||
renderItem={({ item }) => renderTraktCollectionFolder({ folder: item })}
|
||||
keyExtractor={item => item.id}
|
||||
numColumns={numColumns}
|
||||
contentContainerStyle={styles.listContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => { }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -811,7 +812,7 @@ const LibraryScreen = () => {
|
|||
contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
onEndReached={() => { }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -910,21 +911,16 @@ const LibraryScreen = () => {
|
|||
contentContainerStyle={styles.listContainer}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onEndReachedThreshold={0.7}
|
||||
onEndReached={() => {}}
|
||||
onEndReached={() => { }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
// Tablet detection aligned with navigation tablet logic
|
||||
const isTablet = useMemo(() => {
|
||||
const smallestDimension = Math.min(width, height);
|
||||
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
|
||||
}, [width, height]);
|
||||
// Keep header below floating top navigator on tablets
|
||||
const tabletNavOffset = isTablet ? 64 : 0;
|
||||
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
|
||||
const headerHeight = headerBaseHeight + topSpacing;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
|
|
@ -993,9 +989,18 @@ const LibraryScreen = () => {
|
|||
</View>
|
||||
)}
|
||||
|
||||
{showTraktContent ? renderTraktContent() : renderContent()}
|
||||
{/* Content Container */}
|
||||
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{!showTraktContent && (
|
||||
<View style={styles.filtersContainer}>
|
||||
{renderFilter('trakt', 'Trakt', 'pan-tool')}
|
||||
{renderFilter('movies', 'Movies', 'movie')}
|
||||
{renderFilter('series', 'TV Shows', 'live-tv')}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{showTraktContent ? renderTraktContent() : renderContent()}
|
||||
</View>
|
||||
|
||||
{/* DropUpMenu integration */}
|
||||
{selectedItem && (
|
||||
|
|
@ -1009,45 +1014,45 @@ const LibraryScreen = () => {
|
|||
if (!selectedItem) return;
|
||||
switch (option) {
|
||||
case 'library': {
|
||||
try {
|
||||
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
|
||||
showInfo('Removed from Library', 'Item removed from your library');
|
||||
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
|
||||
setMenuVisible(false);
|
||||
} catch (error) {
|
||||
showError('Failed to update Library', 'Unable to remove item from library');
|
||||
}
|
||||
break;
|
||||
try {
|
||||
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
|
||||
showInfo('Removed from Library', 'Item removed from your library');
|
||||
setLibraryItems(prev => prev.filter(item => !(item.id === selectedItem.id && item.type === selectedItem.type)));
|
||||
setMenuVisible(false);
|
||||
} catch (error) {
|
||||
showError('Failed to update Library', 'Unable to remove item from library');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'watched': {
|
||||
try {
|
||||
// Use AsyncStorage to store watched status by key
|
||||
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
||||
const newWatched = !selectedItem.watched;
|
||||
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
|
||||
showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched');
|
||||
// Instantly update local state
|
||||
setLibraryItems(prev => prev.map(item =>
|
||||
item.id === selectedItem.id && item.type === selectedItem.type
|
||||
? { ...item, watched: newWatched }
|
||||
: item
|
||||
));
|
||||
} catch (error) {
|
||||
showError('Failed to update watched status', 'Unable to update watched status');
|
||||
}
|
||||
break;
|
||||
try {
|
||||
// Use AsyncStorage to store watched status by key
|
||||
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
||||
const newWatched = !selectedItem.watched;
|
||||
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
|
||||
showInfo(newWatched ? 'Marked as Watched' : 'Marked as Unwatched', newWatched ? 'Item marked as watched' : 'Item marked as unwatched');
|
||||
// Instantly update local state
|
||||
setLibraryItems(prev => prev.map(item =>
|
||||
item.id === selectedItem.id && item.type === selectedItem.type
|
||||
? { ...item, watched: newWatched }
|
||||
: item
|
||||
));
|
||||
} catch (error) {
|
||||
showError('Failed to update watched status', 'Unable to update watched status');
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'share': {
|
||||
let url = '';
|
||||
if (selectedItem.id) {
|
||||
url = `https://www.imdb.com/title/${selectedItem.id}/`;
|
||||
}
|
||||
const message = `${selectedItem.name}\n${url}`;
|
||||
Share.share({ message, url, title: selectedItem.name });
|
||||
break;
|
||||
let url = '';
|
||||
if (selectedItem.id) {
|
||||
url = `https://www.imdb.com/title/${selectedItem.id}/`;
|
||||
}
|
||||
const message = `${selectedItem.name}\n${url}`;
|
||||
Share.share({ message, url, title: selectedItem.name });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
@ -1060,13 +1065,6 @@ const styles = StyleSheet.create({
|
|||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
headerBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
watchedIndicator: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
|
|
@ -1078,23 +1076,6 @@ const styles = StyleSheet.create({
|
|||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 20,
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 8,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 2,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
filtersContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
|
|
@ -1148,7 +1129,7 @@ const styles = StyleSheet.create({
|
|||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
aspectRatio: 2/3,
|
||||
aspectRatio: 2 / 3,
|
||||
elevation: 5,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
|
|
@ -1271,7 +1252,7 @@ const styles = StyleSheet.create({
|
|||
borderRadius: 8,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||
aspectRatio: 2/3,
|
||||
aspectRatio: 2 / 3,
|
||||
elevation: 5,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
isLast,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPress={onPress}
|
||||
|
|
@ -175,17 +175,17 @@ const PlayerSettingsScreen: React.FC = () => {
|
|||
Settings
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
<View style={styles.headerActions}>
|
||||
{/* Empty for now, but ready for future actions */}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
||||
Video Player
|
||||
</Text>
|
||||
|
||||
<ScrollView
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
>
|
||||
|
|
@ -229,7 +229,7 @@ const PlayerSettingsScreen: React.FC = () => {
|
|||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text
|
||||
style={[
|
||||
|
|
@ -322,6 +322,48 @@ const PlayerSettingsScreen: React.FC = () => {
|
|||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* External Player for Downloads */}
|
||||
{((Platform.OS === 'android' && settings.useExternalPlayer) ||
|
||||
(Platform.OS === 'ios' && settings.preferredPlayer !== 'internal')) && (
|
||||
<View style={[styles.settingItem, styles.settingItemBorder, { borderBottomWidth: 0, borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.08)' }]}>
|
||||
<View style={styles.settingContent}>
|
||||
<View style={[
|
||||
styles.settingIconContainer,
|
||||
{ backgroundColor: 'rgba(255,255,255,0.1)' }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name="open-in-new"
|
||||
size={20}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingText}>
|
||||
<Text
|
||||
style={[
|
||||
styles.settingTitle,
|
||||
{ color: currentTheme.colors.text },
|
||||
]}
|
||||
>
|
||||
External Player for Downloads
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
styles.settingDescription,
|
||||
{ color: currentTheme.colors.textMuted },
|
||||
]}
|
||||
>
|
||||
Play downloaded content in your preferred external player.
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={settings.useExternalPlayerForDownloads}
|
||||
onValueChange={(value) => updateSetting('useExternalPlayerForDownloads', value)}
|
||||
thumbColor={settings.useExternalPlayerForDownloads ? currentTheme.colors.primary : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -27,11 +27,11 @@ import debounce from 'lodash/debounce';
|
|||
import { DropUpMenu } from '../components/home/DropUpMenu';
|
||||
import { DeviceEventEmitter, Share } from 'react-native';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
interpolate,
|
||||
withSpring,
|
||||
|
|
@ -43,6 +43,7 @@ import { BlurView } from 'expo-blur';
|
|||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import LoadingSpinner from '../components/common/LoadingSpinner';
|
||||
import ScreenHeader from '../components/common/ScreenHeader';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
|
|
@ -110,21 +111,21 @@ const SkeletonLoader = () => {
|
|||
const renderSkeletonItem = () => (
|
||||
<View style={styles.skeletonVerticalItem}>
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonPoster,
|
||||
styles.skeletonPoster,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<View style={styles.skeletonItemDetails}>
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonTitle,
|
||||
styles.skeletonTitle,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<View style={styles.skeletonMetaRow}>
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonMeta,
|
||||
styles.skeletonMeta,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonMeta,
|
||||
styles.skeletonMeta,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
</View>
|
||||
|
|
@ -138,7 +139,7 @@ const SkeletonLoader = () => {
|
|||
<View key={index}>
|
||||
{index === 0 && (
|
||||
<RNAnimated.View style={[
|
||||
styles.skeletonSectionHeader,
|
||||
styles.skeletonSectionHeader,
|
||||
{ opacity, backgroundColor: currentTheme.colors.darkBackground }
|
||||
]} />
|
||||
)}
|
||||
|
|
@ -157,7 +158,7 @@ const SimpleSearchAnimation = () => {
|
|||
const spinAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||
const fadeAnim = React.useRef(new RNAnimated.Value(0)).current;
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
// Rotation animation
|
||||
const spin = RNAnimated.loop(
|
||||
|
|
@ -168,32 +169,32 @@ const SimpleSearchAnimation = () => {
|
|||
useNativeDriver: true,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
// Fade animation
|
||||
const fade = RNAnimated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
});
|
||||
|
||||
|
||||
// Start animations
|
||||
spin.start();
|
||||
fade.start();
|
||||
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
spin.stop();
|
||||
};
|
||||
}, [spinAnim, fadeAnim]);
|
||||
|
||||
|
||||
// Simple rotation interpolation
|
||||
const spin = spinAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0deg', '360deg'],
|
||||
});
|
||||
|
||||
|
||||
return (
|
||||
<RNAnimated.View
|
||||
<RNAnimated.View
|
||||
style={[
|
||||
styles.simpleAnimationContainer,
|
||||
{ opacity: fadeAnim }
|
||||
|
|
@ -204,10 +205,10 @@ const SimpleSearchAnimation = () => {
|
|||
styles.spinnerContainer,
|
||||
{ transform: [{ rotate: spin }], backgroundColor: currentTheme.colors.primary }
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={32}
|
||||
color={currentTheme.colors.white}
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={32}
|
||||
color={currentTheme.colors.white}
|
||||
/>
|
||||
</RNAnimated.View>
|
||||
<Text style={[styles.simpleAnimationText, { color: currentTheme.colors.white }]}>Searching</Text>
|
||||
|
|
@ -268,9 +269,9 @@ const SearchScreen = () => {
|
|||
StatusBar.setBackgroundColor('transparent');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
applyStatusBarConfig();
|
||||
|
||||
|
||||
// Re-apply on focus
|
||||
const unsubscribe = navigation.addListener('focus', applyStatusBarConfig);
|
||||
return unsubscribe;
|
||||
|
|
@ -284,7 +285,7 @@ const SearchScreen = () => {
|
|||
|
||||
useEffect(() => {
|
||||
loadRecentSearches();
|
||||
|
||||
|
||||
// Cleanup function to cancel pending searches on unmount
|
||||
return () => {
|
||||
debouncedSearch.cancel();
|
||||
|
|
@ -302,12 +303,12 @@ const SearchScreen = () => {
|
|||
return {
|
||||
opacity: backButtonOpacity.value,
|
||||
transform: [
|
||||
{
|
||||
{
|
||||
translateX: interpolate(
|
||||
backButtonOpacity.value,
|
||||
[0, 1],
|
||||
[-20, 0]
|
||||
)
|
||||
)
|
||||
}
|
||||
]
|
||||
};
|
||||
|
|
@ -361,14 +362,14 @@ const SearchScreen = () => {
|
|||
const saveRecentSearch = async (searchQuery: string) => {
|
||||
try {
|
||||
setRecentSearches(prevSearches => {
|
||||
const newRecentSearches = [
|
||||
searchQuery,
|
||||
const newRecentSearches = [
|
||||
searchQuery,
|
||||
...prevSearches.filter(s => s !== searchQuery)
|
||||
].slice(0, MAX_RECENT_SEARCHES);
|
||||
|
||||
].slice(0, MAX_RECENT_SEARCHES);
|
||||
|
||||
// Save to AsyncStorage
|
||||
mmkvStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(newRecentSearches));
|
||||
|
||||
|
||||
return newRecentSearches;
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -400,7 +401,7 @@ const SearchScreen = () => {
|
|||
const rank: Record<string, number> = {};
|
||||
addons.forEach((a, idx) => { rank[a.id] = idx; });
|
||||
addonOrderRankRef.current = rank;
|
||||
} catch {}
|
||||
} catch { }
|
||||
|
||||
const handle = catalogService.startLiveSearch(searchQuery, async (section: AddonSearchResults) => {
|
||||
// Append/update this addon section immediately with minimal changes
|
||||
|
|
@ -444,7 +445,7 @@ const SearchScreen = () => {
|
|||
// Save to recents after first result batch
|
||||
try {
|
||||
await saveRecentSearch(searchQuery);
|
||||
} catch {}
|
||||
} catch { }
|
||||
});
|
||||
liveSearchHandle.current = handle;
|
||||
}, 800);
|
||||
|
|
@ -502,7 +503,7 @@ const SearchScreen = () => {
|
|||
if (!showRecent || recentSearches.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<Animated.View
|
||||
style={styles.recentSearchesContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
|
|
@ -586,10 +587,10 @@ const SearchScreen = () => {
|
|||
entering={FadeIn.duration(300).delay(index * 50)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.horizontalItemPosterContainer, {
|
||||
<View style={[styles.horizontalItemPosterContainer, {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
borderColor: 'rgba(255,255,255,0.05)'
|
||||
}]}>
|
||||
}]}>
|
||||
<FastImage
|
||||
source={{ uri: item.poster || PLACEHOLDER_POSTER }}
|
||||
style={styles.horizontalItemPoster}
|
||||
|
|
@ -597,28 +598,28 @@ const SearchScreen = () => {
|
|||
/>
|
||||
{/* Bookmark and watched icons top right, bookmark to the left of watched */}
|
||||
{inLibrary && (
|
||||
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }] }>
|
||||
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }]}>
|
||||
<Feather name="bookmark" size={16} color={currentTheme.colors.white} />
|
||||
</View>
|
||||
)}
|
||||
{watched && (
|
||||
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }] }>
|
||||
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }]}>
|
||||
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
|
||||
</View>
|
||||
)}
|
||||
{item.imdbRating && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<MaterialIcons name="star" size={12} color="#FFC107" />
|
||||
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
|
||||
<Text style={[styles.ratingText, { color: currentTheme.colors.white }]}>
|
||||
{item.imdbRating}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
<Text
|
||||
style={[
|
||||
styles.horizontalItemTitle,
|
||||
{
|
||||
styles.horizontalItemTitle,
|
||||
{
|
||||
color: currentTheme.colors.white,
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
|
||||
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
|
||||
|
|
@ -629,36 +630,36 @@ const SearchScreen = () => {
|
|||
{item.name}
|
||||
</Text>
|
||||
{item.year && (
|
||||
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
|
||||
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
|
||||
{item.year}
|
||||
</Text>
|
||||
)}
|
||||
</AnimatedTouchable>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const hasResultsToShow = useMemo(() => {
|
||||
return results.byAddon.length > 0;
|
||||
}, [results]);
|
||||
|
||||
// Memoized addon section to prevent re-rendering unchanged sections
|
||||
const AddonSection = React.memo(({
|
||||
addonGroup,
|
||||
addonIndex
|
||||
}: {
|
||||
addonGroup: AddonSearchResults;
|
||||
const AddonSection = React.memo(({
|
||||
addonGroup,
|
||||
addonIndex
|
||||
}: {
|
||||
addonGroup: AddonSearchResults;
|
||||
addonIndex: number;
|
||||
}) => {
|
||||
const movieResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type === 'movie'),
|
||||
const movieResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type === 'movie'),
|
||||
[addonGroup.results]
|
||||
);
|
||||
const seriesResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type === 'series'),
|
||||
const seriesResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type === 'series'),
|
||||
[addonGroup.results]
|
||||
);
|
||||
const otherResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
|
||||
const otherResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
|
||||
[addonGroup.results]
|
||||
);
|
||||
|
||||
|
|
@ -679,15 +680,15 @@ const SearchScreen = () => {
|
|||
{/* Movies */}
|
||||
{movieResults.length > 0 && (
|
||||
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
Movies ({movieResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
|
|
@ -713,15 +714,15 @@ const SearchScreen = () => {
|
|||
{/* TV Shows */}
|
||||
{seriesResults.length > 0 && (
|
||||
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
TV Shows ({seriesResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
|
|
@ -747,15 +748,15 @@ const SearchScreen = () => {
|
|||
{/* Other types */}
|
||||
{otherResults.length > 0 && (
|
||||
<Animated.View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]} entering={FadeIn.duration(300)}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
|
|
@ -784,12 +785,6 @@ const SearchScreen = () => {
|
|||
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
|
||||
});
|
||||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
// Keep header below floating top navigator on tablets by adding extra offset
|
||||
const tabletNavOffset = (isTV || isLargeTablet || isTablet) ? 64 : 0;
|
||||
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
|
||||
const headerHeight = headerBaseHeight + topSpacing + 60;
|
||||
|
||||
// Set up listeners for watched status and library updates
|
||||
// These will trigger re-renders in individual SearchResultItem components
|
||||
useEffect(() => {
|
||||
|
|
@ -809,11 +804,11 @@ const SearchScreen = () => {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<Animated.View
|
||||
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
|
||||
entering={Platform.OS === 'android' ? undefined : FadeIn.duration(350)}
|
||||
exiting={Platform.OS === 'android' ?
|
||||
FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
|
||||
exiting={Platform.OS === 'android' ?
|
||||
FadeOut.duration(200).withInitialValues({ opacity: 1 }) :
|
||||
FadeOut.duration(250)
|
||||
}
|
||||
>
|
||||
|
|
@ -822,172 +817,170 @@ const SearchScreen = () => {
|
|||
backgroundColor="transparent"
|
||||
translucent
|
||||
/>
|
||||
{/* Fixed position header background to prevent shifts */}
|
||||
<View style={[styles.headerBackground, {
|
||||
height: headerHeight,
|
||||
backgroundColor: currentTheme.colors.darkBackground
|
||||
}]} />
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Header Section with proper top spacing */}
|
||||
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Search</Text>
|
||||
<View style={styles.searchBarContainer}>
|
||||
|
||||
{/* ScreenHeader Component */}
|
||||
<ScreenHeader
|
||||
title="Search"
|
||||
isTablet={isTV || isLargeTablet || isTablet}
|
||||
>
|
||||
{/* Search Bar */}
|
||||
<View style={styles.searchBarContainer}>
|
||||
<View style={[
|
||||
styles.searchBarWrapper,
|
||||
{ width: '100%' }
|
||||
]}>
|
||||
<View style={[
|
||||
styles.searchBarWrapper,
|
||||
{ width: '100%' }
|
||||
styles.searchBar,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation2,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
}
|
||||
]}>
|
||||
<View style={[
|
||||
styles.searchBar,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation2,
|
||||
borderColor: 'rgba(255,255,255,0.1)',
|
||||
borderWidth: 1,
|
||||
}
|
||||
]}>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={24}
|
||||
color={currentTheme.colors.lightGray}
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.searchInput,
|
||||
{ color: currentTheme.colors.white }
|
||||
]}
|
||||
placeholder="Search movies, shows..."
|
||||
placeholderTextColor={currentTheme.colors.lightGray}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
returnKeyType="search"
|
||||
keyboardAppearance="dark"
|
||||
ref={inputRef}
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={handleClearSearch}
|
||||
style={styles.clearButton}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="close"
|
||||
size={20}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={24}
|
||||
color={currentTheme.colors.lightGray}
|
||||
style={styles.searchIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.searchInput,
|
||||
{ color: currentTheme.colors.white }
|
||||
]}
|
||||
placeholder="Search movies, shows..."
|
||||
placeholderTextColor={currentTheme.colors.lightGray}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
returnKeyType="search"
|
||||
keyboardAppearance="dark"
|
||||
ref={inputRef}
|
||||
/>
|
||||
{query.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={handleClearSearch}
|
||||
style={styles.clearButton}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="close"
|
||||
size={20}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
{/* Content Container */}
|
||||
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{searching ? (
|
||||
<View style={styles.loadingOverlay} pointerEvents="none">
|
||||
<LoadingSpinner
|
||||
size="large"
|
||||
offsetY={-60}
|
||||
</ScreenHeader>
|
||||
|
||||
{/* Content Container */}
|
||||
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
{searching ? (
|
||||
<View style={styles.loadingOverlay} pointerEvents="none">
|
||||
<LoadingSpinner
|
||||
size="large"
|
||||
offsetY={-60}
|
||||
/>
|
||||
</View>
|
||||
) : query.trim().length === 1 ? (
|
||||
<Animated.View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={64}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||
Keep typing...
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Type at least 2 characters to search
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : searched && !hasResultsToShow ? (
|
||||
<Animated.View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="search-off"
|
||||
size={64}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||
No results found
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Try different keywords or check your spelling
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollViewContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScrollBeginDrag={Keyboard.dismiss}
|
||||
entering={FadeIn.duration(300)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{!query.trim() && renderRecentSearches()}
|
||||
{/* Render results grouped by addon using memoized component */}
|
||||
{results.byAddon.map((addonGroup, addonIndex) => (
|
||||
<AddonSection
|
||||
key={addonGroup.addonId}
|
||||
addonGroup={addonGroup}
|
||||
addonIndex={addonIndex}
|
||||
/>
|
||||
</View>
|
||||
) : query.trim().length === 1 ? (
|
||||
<Animated.View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="search"
|
||||
size={64}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||
Keep typing...
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Type at least 2 characters to search
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : searched && !hasResultsToShow ? (
|
||||
<Animated.View
|
||||
style={styles.emptyContainer}
|
||||
entering={FadeIn.duration(300)}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="search-off"
|
||||
size={64}
|
||||
color={currentTheme.colors.lightGray}
|
||||
/>
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.white }]}>
|
||||
No results found
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.lightGray }]}>
|
||||
Try different keywords or check your spelling
|
||||
</Text>
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollViewContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
onScrollBeginDrag={Keyboard.dismiss}
|
||||
entering={FadeIn.duration(300)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{!query.trim() && renderRecentSearches()}
|
||||
{/* Render results grouped by addon using memoized component */}
|
||||
{results.byAddon.map((addonGroup, addonIndex) => (
|
||||
<AddonSection
|
||||
key={addonGroup.addonId}
|
||||
addonGroup={addonGroup}
|
||||
addonIndex={addonIndex}
|
||||
/>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
)}
|
||||
</View>
|
||||
{/* DropUpMenu integration for search results */}
|
||||
{selectedItem && (
|
||||
<DropUpMenu
|
||||
visible={menuVisible}
|
||||
onClose={() => setMenuVisible(false)}
|
||||
item={selectedItem}
|
||||
isSaved={isSaved}
|
||||
isWatched={isWatched}
|
||||
onOptionSelect={async (option: string) => {
|
||||
if (!selectedItem) return;
|
||||
switch (option) {
|
||||
case 'share': {
|
||||
let url = '';
|
||||
if (selectedItem.id) {
|
||||
url = `https://www.imdb.com/title/${selectedItem.id}/`;
|
||||
}
|
||||
const message = `${selectedItem.name}\n${url}`;
|
||||
Share.share({ message, url, title: selectedItem.name });
|
||||
break;
|
||||
}
|
||||
case 'library': {
|
||||
if (isSaved) {
|
||||
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
|
||||
setIsSaved(false);
|
||||
} else {
|
||||
await catalogService.addToLibrary(selectedItem);
|
||||
setIsSaved(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'watched': {
|
||||
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
||||
const newWatched = !isWatched;
|
||||
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
|
||||
setIsWatched(newWatched);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Animated.ScrollView>
|
||||
)}
|
||||
</View>
|
||||
{/* DropUpMenu integration for search results */}
|
||||
{selectedItem && (
|
||||
<DropUpMenu
|
||||
visible={menuVisible}
|
||||
onClose={() => setMenuVisible(false)}
|
||||
item={selectedItem}
|
||||
isSaved={isSaved}
|
||||
isWatched={isWatched}
|
||||
onOptionSelect={async (option: string) => {
|
||||
if (!selectedItem) return;
|
||||
switch (option) {
|
||||
case 'share': {
|
||||
let url = '';
|
||||
if (selectedItem.id) {
|
||||
url = `https://www.imdb.com/title/${selectedItem.id}/`;
|
||||
}
|
||||
const message = `${selectedItem.name}\n${url}`;
|
||||
Share.share({ message, url, title: selectedItem.name });
|
||||
break;
|
||||
}
|
||||
case 'library': {
|
||||
if (isSaved) {
|
||||
await catalogService.removeFromLibrary(selectedItem.type, selectedItem.id);
|
||||
setIsSaved(false);
|
||||
} else {
|
||||
await catalogService.addToLibrary(selectedItem);
|
||||
setIsSaved(true);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'watched': {
|
||||
const key = `watched:${selectedItem.type}:${selectedItem.id}`;
|
||||
const newWatched = !isWatched;
|
||||
await mmkvStorage.setItem(key, newWatched ? 'true' : 'false');
|
||||
setIsWatched(newWatched);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
|
@ -996,30 +989,10 @@ const styles = StyleSheet.create({
|
|||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
headerBackground: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingTop: 0,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 15,
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 0,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 32,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 12,
|
||||
},
|
||||
searchBarContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||
import * as Sentry from '@sentry/react-native';
|
||||
import { getDisplayedAppVersion } from '../utils/version';
|
||||
import CustomAlert from '../components/CustomAlert';
|
||||
import ScreenHeader from '../components/common/ScreenHeader';
|
||||
import PluginIcon from '../components/icons/PluginIcon';
|
||||
import TraktIcon from '../components/icons/TraktIcon';
|
||||
import TMDBIcon from '../components/icons/TMDBIcon';
|
||||
|
|
@ -86,7 +87,11 @@ const SettingsCard: React.FC<SettingsCardProps> = ({ children, title, isTablet =
|
|||
)}
|
||||
<View style={[
|
||||
styles.card,
|
||||
{ backgroundColor: currentTheme.colors.elevation1 },
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.colors.elevation2,
|
||||
},
|
||||
isTablet && styles.tabletCard
|
||||
]}>
|
||||
{children}
|
||||
|
|
@ -134,9 +139,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
<View style={[
|
||||
styles.settingIconContainer,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkGray,
|
||||
borderWidth: 1,
|
||||
borderColor: currentTheme.colors.primary + '20'
|
||||
backgroundColor: currentTheme.colors.primary + '12',
|
||||
},
|
||||
isTablet && styles.tabletSettingIconContainer
|
||||
]}>
|
||||
|
|
@ -145,7 +148,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
|
|||
) : (
|
||||
<Feather
|
||||
name={icon! as any}
|
||||
size={isTablet ? 24 : 20}
|
||||
size={isTablet ? 22 : 18}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -195,11 +198,18 @@ interface SidebarProps {
|
|||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, currentTheme, categories, extraTopPadding = 0 }) => {
|
||||
return (
|
||||
<View style={[styles.sidebar, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={[
|
||||
styles.sidebar,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation1,
|
||||
borderRightColor: currentTheme.colors.elevation2,
|
||||
}
|
||||
]}>
|
||||
<View style={[
|
||||
styles.sidebarHeader,
|
||||
{
|
||||
paddingTop: (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48) + extraTopPadding,
|
||||
borderBottomColor: currentTheme.colors.elevation2,
|
||||
}
|
||||
]}>
|
||||
<Text style={[styles.sidebarTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
|
|
@ -215,26 +225,37 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
|
|||
styles.sidebarItem,
|
||||
selectedCategory === category.id && [
|
||||
styles.sidebarItemActive,
|
||||
{ backgroundColor: `${currentTheme.colors.primary}15` }
|
||||
{ backgroundColor: currentTheme.colors.primary + '10' }
|
||||
]
|
||||
]}
|
||||
onPress={() => onCategorySelect(category.id)}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<Feather
|
||||
name={category.icon as any}
|
||||
size={22}
|
||||
color={
|
||||
selectedCategory === category.id
|
||||
? currentTheme.colors.primary
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
<View style={[
|
||||
styles.sidebarItemIconContainer,
|
||||
{
|
||||
backgroundColor: selectedCategory === category.id
|
||||
? currentTheme.colors.primary + '15'
|
||||
: 'transparent',
|
||||
}
|
||||
/>
|
||||
]}>
|
||||
<Feather
|
||||
name={category.icon as any}
|
||||
size={20}
|
||||
color={
|
||||
selectedCategory === category.id
|
||||
? currentTheme.colors.primary
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[
|
||||
styles.sidebarItemText,
|
||||
{
|
||||
color: selectedCategory === category.id
|
||||
? currentTheme.colors.primary
|
||||
: currentTheme.colors.mediumEmphasis
|
||||
? currentTheme.colors.highEmphasis
|
||||
: currentTheme.colors.mediumEmphasis,
|
||||
fontWeight: selectedCategory === category.id ? '600' : '500',
|
||||
}
|
||||
]}>
|
||||
{category.title}
|
||||
|
|
@ -863,11 +884,8 @@ const SettingsScreen: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
// Keep headers below floating top navigator on tablets by adding extra offset
|
||||
const tabletNavOffset = isTablet ? 64 : 0;
|
||||
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
|
||||
const headerHeight = headerBaseHeight + topSpacing;
|
||||
|
||||
if (isTablet) {
|
||||
return (
|
||||
|
|
@ -917,7 +935,21 @@ const SettingsScreen: React.FC = () => {
|
|||
</Text>
|
||||
</View>
|
||||
<View style={styles.discordContainer}>
|
||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||
<TouchableOpacity
|
||||
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0, marginBottom: 8 }]}
|
||||
onPress={() => WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', {
|
||||
presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET
|
||||
})}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FastImage
|
||||
source={require('../../assets/support_me_on_kofi_red.png')}
|
||||
style={styles.kofiImage}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<TouchableOpacity
|
||||
style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]}
|
||||
onPress={() => Linking.openURL('https://discord.gg/6w8dr3TSDN')}
|
||||
|
|
@ -930,23 +962,26 @@ const SettingsScreen: React.FC = () => {
|
|||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Join Discord
|
||||
Discord
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0 }]}
|
||||
onPress={() => WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', {
|
||||
presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET
|
||||
})}
|
||||
style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]}
|
||||
onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FastImage
|
||||
source={require('../../assets/support_me_on_kofi_red.png')}
|
||||
style={styles.kofiImage}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<View style={styles.discordButtonContent}>
|
||||
<FastImage
|
||||
source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
|
||||
style={styles.discordLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[styles.discordButtonText, { color: '#FF4500' }]}>
|
||||
Reddit
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -973,12 +1008,10 @@ const SettingsScreen: React.FC = () => {
|
|||
{ backgroundColor: currentTheme.colors.darkBackground }
|
||||
]}>
|
||||
<StatusBar barStyle={'light-content'} />
|
||||
<ScreenHeader
|
||||
title="Settings"
|
||||
/>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
||||
Settings
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.contentContainer}>
|
||||
<ScrollView
|
||||
|
|
@ -1017,7 +1050,21 @@ const SettingsScreen: React.FC = () => {
|
|||
|
||||
{/* Support & Community Buttons */}
|
||||
<View style={styles.discordContainer}>
|
||||
<View style={{ flexDirection: 'row', gap: 12 }}>
|
||||
<TouchableOpacity
|
||||
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0, marginBottom: 8 }]}
|
||||
onPress={() => WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', {
|
||||
presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET
|
||||
})}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FastImage
|
||||
source={require('../../assets/support_me_on_kofi_red.png')}
|
||||
style={styles.kofiImage}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
|
||||
<TouchableOpacity
|
||||
style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]}
|
||||
onPress={() => Linking.openURL('https://discord.gg/6w8dr3TSDN')}
|
||||
|
|
@ -1030,23 +1077,26 @@ const SettingsScreen: React.FC = () => {
|
|||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Join Discord
|
||||
Discord
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0 }]}
|
||||
onPress={() => WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', {
|
||||
presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET
|
||||
})}
|
||||
style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]}
|
||||
onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<FastImage
|
||||
source={require('../../assets/support_me_on_kofi_red.png')}
|
||||
style={styles.kofiImage}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<View style={styles.discordButtonContent}>
|
||||
<FastImage
|
||||
source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
|
||||
style={styles.discordLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[styles.discordButtonText, { color: '#FF4500' }]}>
|
||||
Reddit
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -1069,20 +1119,6 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
// Mobile styles
|
||||
header: {
|
||||
paddingHorizontal: Math.max(1, width * 0.05),
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
paddingBottom: 8,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 2,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: Math.min(32, width * 0.08),
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
zIndex: 1,
|
||||
|
|
@ -1095,7 +1131,8 @@ const styles = StyleSheet.create({
|
|||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
paddingBottom: 90,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 100,
|
||||
},
|
||||
|
||||
// Tablet-specific styles
|
||||
|
|
@ -1106,39 +1143,45 @@ const styles = StyleSheet.create({
|
|||
sidebar: {
|
||||
width: 280,
|
||||
borderRightWidth: 1,
|
||||
borderRightColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
sidebarHeader: {
|
||||
padding: 24,
|
||||
paddingHorizontal: 24,
|
||||
paddingBottom: 20,
|
||||
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
sidebarTitle: {
|
||||
fontSize: 28,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.3,
|
||||
fontSize: 42,
|
||||
fontWeight: '700',
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
paddingTop: 16,
|
||||
paddingTop: 12,
|
||||
paddingBottom: 24,
|
||||
},
|
||||
sidebarItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 24,
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 2,
|
||||
borderRadius: 12,
|
||||
borderRadius: 10,
|
||||
},
|
||||
sidebarItemActive: {
|
||||
borderRadius: 12,
|
||||
borderRadius: 10,
|
||||
},
|
||||
sidebarItemIconContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 8,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sidebarItemText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
marginLeft: 16,
|
||||
fontSize: 15,
|
||||
marginLeft: 12,
|
||||
},
|
||||
tabletContent: {
|
||||
flex: 1,
|
||||
|
|
@ -1146,80 +1189,74 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
tabletScrollView: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 32,
|
||||
paddingHorizontal: 40,
|
||||
},
|
||||
tabletScrollContent: {
|
||||
paddingBottom: 32,
|
||||
paddingTop: 8,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
|
||||
// Common card styles
|
||||
cardContainer: {
|
||||
width: '100%',
|
||||
marginBottom: 20,
|
||||
marginBottom: 24,
|
||||
},
|
||||
tabletCardContainer: {
|
||||
marginBottom: 32,
|
||||
marginBottom: 28,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.8,
|
||||
marginLeft: Math.max(12, width * 0.04),
|
||||
marginBottom: 8,
|
||||
letterSpacing: 1,
|
||||
marginLeft: Math.max(16, width * 0.045),
|
||||
marginBottom: 10,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
tabletCardTitle: {
|
||||
fontSize: 14,
|
||||
marginLeft: 0,
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
marginBottom: 12,
|
||||
},
|
||||
card: {
|
||||
marginHorizontal: Math.max(12, width * 0.04),
|
||||
borderRadius: 16,
|
||||
marginHorizontal: Math.max(16, width * 0.04),
|
||||
borderRadius: 14,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
width: undefined,
|
||||
},
|
||||
tabletCard: {
|
||||
marginHorizontal: 0,
|
||||
borderRadius: 20,
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
borderRadius: 16,
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: Math.max(12, width * 0.04),
|
||||
borderBottomWidth: 0.5,
|
||||
minHeight: Math.max(54, width * 0.14),
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: Math.max(14, width * 0.04),
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
minHeight: Math.max(60, width * 0.15),
|
||||
width: '100%',
|
||||
},
|
||||
tabletSettingItem: {
|
||||
paddingVertical: 16,
|
||||
paddingHorizontal: 24,
|
||||
minHeight: 70,
|
||||
paddingHorizontal: 20,
|
||||
minHeight: 68,
|
||||
},
|
||||
settingItemBorder: {
|
||||
// Border styling handled directly in the component with borderBottomWidth
|
||||
},
|
||||
settingIconContainer: {
|
||||
marginRight: 16,
|
||||
width: 36,
|
||||
height: 36,
|
||||
marginRight: 14,
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
tabletSettingIconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
marginRight: 20,
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: 11,
|
||||
marginRight: 16,
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
|
|
@ -1230,32 +1267,33 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
},
|
||||
settingTitle: {
|
||||
fontSize: Math.min(16, width * 0.042),
|
||||
fontSize: Math.min(16, width * 0.04),
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
tabletSettingTitle: {
|
||||
fontSize: 17,
|
||||
fontWeight: '500',
|
||||
marginBottom: 3,
|
||||
},
|
||||
tabletSettingTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 4,
|
||||
},
|
||||
settingDescription: {
|
||||
fontSize: Math.min(14, width * 0.037),
|
||||
opacity: 0.8,
|
||||
fontSize: Math.min(13, width * 0.034),
|
||||
opacity: 0.7,
|
||||
},
|
||||
tabletSettingDescription: {
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
fontSize: 14,
|
||||
opacity: 0.6,
|
||||
},
|
||||
settingControl: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 12,
|
||||
paddingLeft: 10,
|
||||
},
|
||||
badge: {
|
||||
height: 22,
|
||||
minWidth: 22,
|
||||
borderRadius: 11,
|
||||
height: 20,
|
||||
minWidth: 20,
|
||||
borderRadius: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 6,
|
||||
|
|
@ -1263,8 +1301,8 @@ const styles = StyleSheet.create({
|
|||
},
|
||||
badgeText: {
|
||||
color: 'white',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
},
|
||||
segmentedControl: {
|
||||
flexDirection: 'row',
|
||||
|
|
@ -1293,26 +1331,27 @@ const styles = StyleSheet.create({
|
|||
footer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: 10,
|
||||
marginBottom: 8,
|
||||
marginTop: 24,
|
||||
marginBottom: 12,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: 14,
|
||||
fontSize: 13,
|
||||
opacity: 0.5,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
// New styles for Discord button
|
||||
// Support buttons
|
||||
discordContainer: {
|
||||
marginTop: 8,
|
||||
marginBottom: 20,
|
||||
marginTop: 12,
|
||||
marginBottom: 24,
|
||||
alignItems: 'center',
|
||||
},
|
||||
discordButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 18,
|
||||
borderRadius: 10,
|
||||
maxWidth: 200,
|
||||
},
|
||||
discordButtonContent: {
|
||||
|
|
@ -1320,34 +1359,34 @@ const styles = StyleSheet.create({
|
|||
alignItems: 'center',
|
||||
},
|
||||
discordLogo: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
marginRight: 8,
|
||||
width: 18,
|
||||
height: 18,
|
||||
marginRight: 10,
|
||||
},
|
||||
discordButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
fontWeight: '600',
|
||||
},
|
||||
kofiImage: {
|
||||
height: 32,
|
||||
width: 150,
|
||||
height: 34,
|
||||
width: 155,
|
||||
},
|
||||
downloadsContainer: {
|
||||
marginTop: 20,
|
||||
marginBottom: 12,
|
||||
marginTop: 32,
|
||||
marginBottom: 16,
|
||||
alignItems: 'center',
|
||||
},
|
||||
downloadsNumber: {
|
||||
fontSize: 32,
|
||||
fontSize: 36,
|
||||
fontWeight: '800',
|
||||
letterSpacing: 1,
|
||||
marginBottom: 4,
|
||||
letterSpacing: 0.5,
|
||||
marginBottom: 6,
|
||||
},
|
||||
downloadsLabel: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
opacity: 0.6,
|
||||
letterSpacing: 1.2,
|
||||
opacity: 0.5,
|
||||
letterSpacing: 1.5,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
loadingSpinner: {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -29,7 +29,7 @@ class StorageService {
|
|||
private watchProgressCacheTimestamp = 0;
|
||||
private readonly WATCH_PROGRESS_CACHE_TTL = 5000; // 5 seconds
|
||||
|
||||
private constructor() {}
|
||||
private constructor() { }
|
||||
|
||||
public static getInstance(): StorageService {
|
||||
if (!StorageService.instance) {
|
||||
|
|
@ -88,7 +88,7 @@ class StorageService {
|
|||
const map = JSON.parse(json) as Record<string, number>;
|
||||
map[this.buildWpKeyString(id, type, episodeId)] = deletedAtMs || Date.now();
|
||||
await mmkvStorage.setItem(key, JSON.stringify(map));
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
public async clearWatchProgressTombstone(
|
||||
|
|
@ -105,7 +105,7 @@ class StorageService {
|
|||
delete map[k];
|
||||
await mmkvStorage.setItem(key, JSON.stringify(map));
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
}
|
||||
|
||||
public async getWatchProgressTombstones(): Promise<Record<string, number>> {
|
||||
|
|
@ -220,7 +220,7 @@ class StorageService {
|
|||
lastUpdated: Date.now()
|
||||
};
|
||||
await this.setWatchProgress(id, type, updatedProgress, episodeId);
|
||||
logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration/60).toFixed(0)}min to ${(newDuration/60).toFixed(0)}min`);
|
||||
logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration / 60).toFixed(0)}min to ${(newDuration / 60).toFixed(0)}min`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating progress duration:', error);
|
||||
|
|
@ -247,15 +247,15 @@ class StorageService {
|
|||
if (newestTombAt && (progress.lastUpdated == null || progress.lastUpdated <= newestTombAt)) {
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
} catch { }
|
||||
|
||||
// Check if progress has actually changed significantly, unless forceWrite is requested
|
||||
if (!options?.forceWrite) {
|
||||
const existingProgress = await this.getWatchProgress(id, type, episodeId);
|
||||
if (existingProgress) {
|
||||
const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime);
|
||||
const durationDiff = Math.abs(progress.duration - existingProgress.duration);
|
||||
|
||||
|
||||
// Only update if there's a significant change (>5 seconds or duration change)
|
||||
if (timeDiff < 5 && durationDiff < 1) {
|
||||
return; // Skip update for minor changes
|
||||
|
|
@ -266,9 +266,24 @@ class StorageService {
|
|||
const timestamp = (options?.preserveTimestamp && typeof progress.lastUpdated === 'number')
|
||||
? progress.lastUpdated
|
||||
: Date.now();
|
||||
|
||||
|
||||
try {
|
||||
const removedMap = await this.getContinueWatchingRemoved();
|
||||
const removedKey = this.buildWpKeyString(id, type);
|
||||
const removedAt = removedMap[removedKey];
|
||||
|
||||
if (removedAt != null && timestamp > removedAt) {
|
||||
logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${type}:${id}`);
|
||||
await this.removeContinueWatchingRemoved(id, type);
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore error checks for restoration to prevent blocking save
|
||||
}
|
||||
|
||||
const updated = { ...progress, lastUpdated: timestamp };
|
||||
await mmkvStorage.setItem(key, JSON.stringify(updated));
|
||||
|
||||
|
||||
// Invalidate cache
|
||||
this.invalidateWatchProgressCache();
|
||||
|
||||
|
|
@ -285,12 +300,12 @@ class StorageService {
|
|||
|
||||
private debouncedNotifySubscribers(): void {
|
||||
const now = Date.now();
|
||||
|
||||
|
||||
// Clear existing timer
|
||||
if (this.notificationDebounceTimer) {
|
||||
clearTimeout(this.notificationDebounceTimer);
|
||||
}
|
||||
|
||||
|
||||
// If we notified recently, debounce longer
|
||||
const timeSinceLastNotification = now - this.lastNotificationTime;
|
||||
if (timeSinceLastNotification < this.MIN_NOTIFICATION_INTERVAL) {
|
||||
|
|
@ -306,16 +321,16 @@ class StorageService {
|
|||
private notifyWatchProgressSubscribers(): void {
|
||||
this.lastNotificationTime = Date.now();
|
||||
this.notificationDebounceTimer = null;
|
||||
|
||||
|
||||
// Only notify if we have subscribers
|
||||
if (this.watchProgressSubscribers.length > 0) {
|
||||
this.watchProgressSubscribers.forEach(callback => callback());
|
||||
this.watchProgressSubscribers.forEach(callback => callback());
|
||||
}
|
||||
}
|
||||
|
||||
public subscribeToWatchProgressUpdates(callback: () => void): () => void {
|
||||
this.watchProgressSubscribers.push(callback);
|
||||
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const index = this.watchProgressSubscribers.indexOf(callback);
|
||||
|
|
@ -334,7 +349,7 @@ class StorageService {
|
|||
}
|
||||
|
||||
public async getWatchProgress(
|
||||
id: string,
|
||||
id: string,
|
||||
type: string,
|
||||
episodeId?: string
|
||||
): Promise<WatchProgress | null> {
|
||||
|
|
@ -349,7 +364,7 @@ class StorageService {
|
|||
}
|
||||
|
||||
public async removeWatchProgress(
|
||||
id: string,
|
||||
id: string,
|
||||
type: string,
|
||||
episodeId?: string
|
||||
): Promise<void> {
|
||||
|
|
@ -357,14 +372,14 @@ class StorageService {
|
|||
const key = await this.getWatchProgressKeyScoped(id, type, episodeId);
|
||||
await mmkvStorage.removeItem(key);
|
||||
await this.addWatchProgressTombstone(id, type, episodeId);
|
||||
|
||||
|
||||
// Invalidate cache
|
||||
this.invalidateWatchProgressCache();
|
||||
|
||||
|
||||
// Notify subscribers
|
||||
this.notifyWatchProgressSubscribers();
|
||||
// Emit explicit remove event for sync layer
|
||||
try { this.watchProgressRemoveListeners.forEach(l => l(id, type, episodeId)); } catch {}
|
||||
try { this.watchProgressRemoveListeners.forEach(l => l(id, type, episodeId)); } catch { }
|
||||
} catch (error) {
|
||||
logger.error('Error removing watch progress:', error);
|
||||
}
|
||||
|
|
@ -383,25 +398,25 @@ class StorageService {
|
|||
const keys = await mmkvStorage.getAllKeys();
|
||||
const watchProgressKeys = keys.filter(key => key.startsWith(prefix));
|
||||
const pairs = await mmkvStorage.multiGet(watchProgressKeys);
|
||||
|
||||
|
||||
const result = pairs.reduce((acc, [key, value]) => {
|
||||
if (value) {
|
||||
acc[key.replace(prefix, '')] = JSON.parse(value);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, WatchProgress>);
|
||||
|
||||
|
||||
// Update cache
|
||||
this.watchProgressCache = result;
|
||||
this.watchProgressCacheTimestamp = now;
|
||||
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error('Error getting all watch progress:', error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private invalidateWatchProgressCache(): void {
|
||||
this.watchProgressCache = null;
|
||||
this.watchProgressCacheTimestamp = 0;
|
||||
|
|
@ -419,7 +434,7 @@ class StorageService {
|
|||
exactTime?: number
|
||||
): Promise<void> {
|
||||
try {
|
||||
const existingProgress = await this.getWatchProgress(id, type, episodeId);
|
||||
const existingProgress = await this.getWatchProgress(id, type, episodeId);
|
||||
if (existingProgress) {
|
||||
// Preserve the highest Trakt progress and currentTime values to avoid accidental regressions
|
||||
const highestTraktProgress = (() => {
|
||||
|
|
@ -479,9 +494,9 @@ class StorageService {
|
|||
continue;
|
||||
}
|
||||
// Check if needs sync (either never synced or local progress is newer)
|
||||
const needsSync = !progress.traktSynced ||
|
||||
const needsSync = !progress.traktSynced ||
|
||||
(progress.traktLastSynced && progress.lastUpdated > progress.traktLastSynced);
|
||||
|
||||
|
||||
if (needsSync) {
|
||||
const parts = key.split(':');
|
||||
const type = parts[0];
|
||||
|
|
@ -517,14 +532,14 @@ class StorageService {
|
|||
): Promise<void> {
|
||||
try {
|
||||
logger.log(`🗑️ [StorageService] removeAllWatchProgressForContent called for ${type}:${id}`);
|
||||
|
||||
|
||||
const all = await this.getAllWatchProgress();
|
||||
const prefix = `${type}:${id}`;
|
||||
logger.log(`🔍 [StorageService] Looking for keys with prefix: ${prefix}`);
|
||||
|
||||
|
||||
const matchingKeys = Object.keys(all).filter(key => key === prefix || key.startsWith(`${prefix}:`));
|
||||
logger.log(`📊 [StorageService] Found ${matchingKeys.length} matching keys:`, matchingKeys);
|
||||
|
||||
|
||||
const removals: Array<Promise<void>> = [];
|
||||
for (const key of matchingKeys) {
|
||||
// Compute episodeId if present
|
||||
|
|
@ -532,16 +547,16 @@ class StorageService {
|
|||
logger.log(`🗑️ [StorageService] Removing progress for key: ${key} (episodeId: ${episodeId})`);
|
||||
removals.push(this.removeWatchProgress(id, type, episodeId));
|
||||
}
|
||||
|
||||
|
||||
await Promise.allSettled(removals);
|
||||
logger.log(`✅ [StorageService] All watch progress removals completed`);
|
||||
|
||||
|
||||
if (options?.addBaseTombstone) {
|
||||
logger.log(`🪦 [StorageService] Adding tombstone for ${type}:${id}`);
|
||||
await this.addWatchProgressTombstone(id, type);
|
||||
logger.log(`✅ [StorageService] Tombstone added successfully`);
|
||||
}
|
||||
|
||||
|
||||
logger.log(`✅ [StorageService] removeAllWatchProgressForContent completed for ${type}:${id}`);
|
||||
} catch (error) {
|
||||
logger.error(`❌ [StorageService] Error removing all watch progress for content ${type}:${id}:`, error);
|
||||
|
|
@ -562,12 +577,12 @@ class StorageService {
|
|||
try {
|
||||
const localProgress = await this.getWatchProgress(id, type, episodeId);
|
||||
const traktTimestamp = new Date(traktPausedAt).getTime();
|
||||
|
||||
|
||||
if (!localProgress) {
|
||||
// No local progress - use stored duration or estimate
|
||||
let duration = await this.getContentDuration(id, type, episodeId);
|
||||
let currentTime: number;
|
||||
|
||||
|
||||
if (exactTime && exactTime > 0) {
|
||||
// Use exact time from Trakt if available
|
||||
currentTime = exactTime;
|
||||
|
|
@ -589,7 +604,7 @@ class StorageService {
|
|||
}
|
||||
currentTime = (traktProgress / 100) * duration;
|
||||
}
|
||||
|
||||
|
||||
const newProgress: WatchProgress = {
|
||||
currentTime,
|
||||
duration,
|
||||
|
|
@ -599,41 +614,41 @@ class StorageService {
|
|||
traktProgress
|
||||
};
|
||||
await this.setWatchProgress(id, type, newProgress, episodeId);
|
||||
|
||||
|
||||
// Progress creation logging removed
|
||||
} else {
|
||||
// Local progress exists - merge intelligently
|
||||
const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100;
|
||||
|
||||
|
||||
// Only proceed if there's a significant difference (>5% or different completion status)
|
||||
const progressDiff = Math.abs(traktProgress - localProgressPercent);
|
||||
if (progressDiff < 5 && traktProgress < 100 && localProgressPercent < 100) {
|
||||
return; // Skip minor updates
|
||||
}
|
||||
|
||||
|
||||
let currentTime: number;
|
||||
let duration = localProgress.duration;
|
||||
|
||||
|
||||
if (exactTime && exactTime > 0 && localProgress.duration > 0) {
|
||||
// Use exact time from Trakt, keep local duration
|
||||
currentTime = exactTime;
|
||||
|
||||
|
||||
// If exact time doesn't match the duration well, recalculate duration
|
||||
const calculatedDuration = (exactTime / traktProgress) * 100;
|
||||
const durationDiff = Math.abs(calculatedDuration - localProgress.duration);
|
||||
if (durationDiff > 300) { // More than 5 minutes difference
|
||||
duration = calculatedDuration;
|
||||
logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration/60).toFixed(0)}min → ${(duration/60).toFixed(0)}min`);
|
||||
logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration / 60).toFixed(0)}min → ${(duration / 60).toFixed(0)}min`);
|
||||
}
|
||||
} else if (localProgress.duration > 0) {
|
||||
// Use percentage calculation with local duration
|
||||
currentTime = (traktProgress / 100) * localProgress.duration;
|
||||
} else {
|
||||
// No local duration, check stored duration
|
||||
const storedDuration = await this.getContentDuration(id, type, episodeId);
|
||||
duration = storedDuration || 0;
|
||||
|
||||
if (!duration || duration <= 0) {
|
||||
// No local duration, check stored duration
|
||||
const storedDuration = await this.getContentDuration(id, type, episodeId);
|
||||
duration = storedDuration || 0;
|
||||
|
||||
if (!duration || duration <= 0) {
|
||||
if (exactTime && exactTime > 0) {
|
||||
duration = (exactTime / traktProgress) * 100;
|
||||
currentTime = exactTime;
|
||||
|
|
@ -649,21 +664,21 @@ class StorageService {
|
|||
currentTime = (traktProgress / 100) * duration;
|
||||
}
|
||||
} else {
|
||||
currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration;
|
||||
currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration;
|
||||
}
|
||||
}
|
||||
|
||||
const updatedProgress: WatchProgress = {
|
||||
|
||||
const updatedProgress: WatchProgress = {
|
||||
...localProgress,
|
||||
currentTime,
|
||||
duration,
|
||||
lastUpdated: traktTimestamp,
|
||||
traktSynced: true,
|
||||
traktLastSynced: Date.now(),
|
||||
traktProgress
|
||||
};
|
||||
await this.setWatchProgress(id, type, updatedProgress, episodeId);
|
||||
|
||||
lastUpdated: traktTimestamp,
|
||||
traktSynced: true,
|
||||
traktLastSynced: Date.now(),
|
||||
traktProgress
|
||||
};
|
||||
await this.setWatchProgress(id, type, updatedProgress, episodeId);
|
||||
|
||||
// Progress update logging removed
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue