Compare commits

...

7 commits
main ... tv

Author SHA1 Message Date
tapframe
e9f02adb98 some changes 2025-08-12 14:15:00 +05:30
tapframe
e912149ff6 some UI changes 2025-08-12 13:51:19 +05:30
tapframe
99dc34cb65 homescreenc changes 2025-08-12 12:42:56 +05:30
tapframe
11030c5601 test 2025-08-03 01:07:30 +05:30
tapframe
8da78d1b0d updating 2025-08-02 16:41:40 +05:30
tapframe
332cf99f67 removed reanimated 2025-08-02 14:26:47 +05:30
tapframe
604b38ba20 initial commit 2025-08-02 13:51:15 +05:30
42 changed files with 3682 additions and 8980 deletions

View file

@ -19,7 +19,7 @@ import AppNavigator, {
CustomNavigationDarkTheme,
CustomDarkTheme
} from './src/navigation/AppNavigator';
import 'react-native-reanimated';
// Removed react-native-reanimated import
import { CatalogProvider } from './src/contexts/CatalogContext';
import { GenreProvider } from './src/contexts/GenreContext';
import { TraktProvider } from './src/contexts/TraktContext';

131
TV_SETUP.md Normal file
View file

@ -0,0 +1,131 @@
# Nuvio TV Setup Guide
This project has been configured to support both mobile (Android/iOS) and TV (Android TV/Apple TV) platforms using React Native TV.
## Prerequisites
### For Apple TV Development
- Xcode with tvOS SDK 17 or later
- Install tvOS SDK: `xcodebuild -downloadAllPlatforms`
- Apple TV simulator or physical Apple TV device
### For Android TV Development
- Android Studio with Android TV emulator
- Android TV device or emulator with API level 24+
## Key Changes Made
1. **React Native TV Package**: Replaced `react-native` with `react-native-tvos` package
2. **TV Config Plugin**: Added `@react-native-tvos/config-tv` plugin for automatic TV configuration
3. **Removed expo-dev-client**: Not supported on TV platforms
4. **EAS Build Configuration**: Added TV-specific build profiles
5. **Package.json Scripts**: Added TV-specific development commands
## Development Commands
### Mobile Development (Original)
```bash
npm run start # Start Expo development server
npm run ios # Run on iOS simulator
npm run android # Run on Android emulator
```
### TV Development
```bash
npm run start:tv # Start Expo development server for TV
npm run ios:tv # Run on Apple TV simulator
npm run android:tv # Run on Android TV emulator
npm run prebuild:tv # Clean prebuild for TV platforms
```
## Building for TV
### Local Development
1. Set the environment variable: `export EXPO_TV=1`
2. Run prebuild: `npm run prebuild:tv`
3. Start development: `npm run start:tv`
4. Run on TV simulator: `npm run ios:tv` or `npm run android:tv`
### EAS Build
Use the TV-specific build profiles:
```bash
# Development builds for TV
eas build --profile development_tv --platform ios
eas build --profile development_tv --platform android
# Production builds for TV
eas build --profile production_tv --platform ios
eas build --profile production_tv --platform android
```
## TV-Specific Considerations
### Navigation
- The app uses React Navigation which works well with TV focus management
- TV remote navigation is handled automatically
- Consider adding `hasTVPreferredFocus` prop to important UI elements
### UI/UX Adaptations
- Bottom tab navigation works on TV but consider if it's optimal for TV UX
- Video player controls should work well with TV remotes
- Consider larger touch targets for TV interaction
### Unsupported Features on TV
- `expo-dev-client` - Development client not supported
- `expo-router` - File-based routing not supported on TV
- Some Expo modules may not work on TV platforms
### Focus Management
For better TV experience, you may want to add focus management:
```jsx
import { Platform } from 'react-native';
// Add TV-specific focus handling
const isTV = Platform.isTV;
<TouchableOpacity
hasTVPreferredFocus={isTV}
tvParallaxProperties={{
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
}}
>
{/* Your content */}
</TouchableOpacity>
```
## Testing
### Apple TV
- Use Apple TV simulator in Xcode
- For physical device: Long press play/pause button for dev menu
- Don't shake the Apple TV device (it won't work!)
### Android TV
- Use Android TV emulator in Android Studio
- Dev menu behavior same as Android phone
- Expo dev menu is not supported on TV
## Troubleshooting
### Common Issues
1. **Build errors**: Make sure you've run `npm run prebuild:tv` with `EXPO_TV=1`
2. **Navigation issues**: TV navigation uses focus-based system, not touch
3. **Missing dependencies**: Some mobile-specific packages may not work on TV
### Environment Variables
Always set `EXPO_TV=1` when developing for TV:
```bash
export EXPO_TV=1
# Then run your commands
npm run start
```
## Resources
- [React Native TV Documentation](https://github.com/react-native-tvos/react-native-tvos)
- [Expo TV Guide](https://docs.expo.dev/guides/building-for-tv/)
- [TV Config Plugin](https://www.npmjs.com/package/@react-native-tvos/config-tv)

View file

@ -96,15 +96,6 @@ android {
versionCode 1
versionName "1.0.0"
}
splits {
abi {
reset()
enable true
universalApk false // If true, also generate a universal APK
include "arm64-v8a", "armeabi-v7a", "x86", "x86_64"
}
}
signingConfigs {
debug {
storeFile file('debug.keystore')

View file

@ -5,6 +5,9 @@
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-feature android:name="android.hardware.touchscreen" android:required="false"/>
<uses-feature android:name="android.hardware.faketouch" android:required="false"/>
<uses-feature android:name="android.software.leanback" android:required="false"/>
<queries>
<intent>
<action android:name="android.intent.action.VIEW"/>
@ -16,10 +19,11 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="unspecified">
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
@ -27,7 +31,6 @@
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="stremioexpo"/>
<data android:scheme="com.nuvio.app"/>
<data android:scheme="exp+nuvio"/>
</intent-filter>
</activity>
</application>

0
android/gradlew vendored Normal file → Executable file
View file

View file

@ -58,6 +58,7 @@
},
"owner": "nayifleo",
"plugins": [
"@react-native-tvos/config-tv",
[
"@sentry/react-native/expo",
{

View file

@ -8,9 +8,21 @@
"developmentClient": true,
"distribution": "internal"
},
"development_tv": {
"extends": "development",
"env": {
"EXPO_TV": "1"
}
},
"preview": {
"distribution": "internal"
},
"preview_tv": {
"extends": "preview",
"env": {
"EXPO_TV": "1"
}
},
"production": {
"autoIncrement": true,
"extends": "apk",
@ -20,17 +32,35 @@
"image": "latest"
}
},
"production_tv": {
"extends": "production",
"env": {
"EXPO_TV": "1"
}
},
"release": {
"distribution": "store",
"android": {
"buildType": "app-bundle"
}
},
"release_tv": {
"extends": "release",
"env": {
"EXPO_TV": "1"
}
},
"apk": {
"android": {
"buildType": "apk",
"gradleCommand": ":app:assembleRelease"
}
},
"apk_tv": {
"extends": "apk",
"env": {
"EXPO_TV": "1"
}
}
},
"submit": {

1
nuvio-providers Submodule

@ -0,0 +1 @@
Subproject commit 96be1f53604182cb53f027160db9fc969ed3bdcc

5501
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,9 +4,13 @@
"main": "index.ts",
"scripts": {
"start": "expo start",
"start:tv": "EXPO_TV=1 expo start",
"android": "expo run:android",
"android:tv": "EXPO_TV=1 expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
"ios:tv": "EXPO_TV=1 expo run:ios",
"web": "expo start --web",
"prebuild:tv": "EXPO_TV=1 expo prebuild --clean"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
@ -14,12 +18,12 @@
"@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/blur": "^4.4.1",
"@react-native-community/slider": "^4.5.6",
"@react-native-picker/picker": "^2.11.0",
"@react-navigation/bottom-tabs": "^7.3.10",
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
"@react-navigation/stack": "^7.2.10",
"@sentry/react-native": "^6.15.1",
"@shopify/flash-list": "^1.8.3",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
"axios": "^1.10.0",
@ -28,27 +32,21 @@
"date-fns": "^4.1.0",
"eventemitter3": "^5.0.1",
"expo": "~52.0.43",
"expo-auth-session": "^6.0.3",
"expo-blur": "^14.0.3",
"expo-dev-client": "~5.0.20",
"expo-file-system": "^18.0.12",
"expo-haptics": "~14.0.1",
"expo-image": "~2.0.7",
"expo-intent-launcher": "~12.0.2",
"expo-linear-gradient": "~14.0.2",
"expo-notifications": "~0.29.14",
"expo-random": "^14.0.1",
"expo-screen-orientation": "~8.0.4",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "^4.0.9",
"expo-web-browser": "~14.0.2",
"lodash": "^4.17.21",
"react": "18.3.1",
"react-native": "0.76.9",
"react-native": "npm:react-native-tvos@latest",
"react-native-gesture-handler": "~2.20.2",
"react-native-immersive-mode": "^2.0.2",
"react-native-paper": "^5.13.1",
"react-native-reanimated": "^3.18.0",
"react-native-reanimated": "3.6.0",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "^15.11.2",
@ -58,11 +56,20 @@
},
"devDependencies": {
"@babel/core": "^7.25.2",
"@babel/plugin-transform-template-literals": "^7.27.1",
"@react-native-tvos/config-tv": "^0.1.3",
"@types/react": "~18.3.12",
"@types/react-native": "^0.72.8",
"babel-plugin-transform-remove-console": "^6.9.4",
"react-native-svg-transformer": "^1.5.0",
"typescript": "^5.3.3"
},
"expo": {
"install": {
"exclude": [
"react-native"
]
}
},
"private": true
}

View file

@ -1,5 +1,6 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, FlatList, Platform, Dimensions } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions } from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
@ -11,14 +12,17 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
interface CatalogSectionProps {
catalog: CatalogContent;
onPosterPress?: (content: StreamingContent) => void;
onPosterFocus?: (content: StreamingContent) => void;
}
const { width } = Dimensions.get('window');
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
// TV gets larger posters
const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 100;
const MAX_POSTER_WIDTH = Platform.isTV ? 180 : 130;
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
@ -26,7 +30,7 @@ const calculatePosterLayout = (screenWidth: number) => {
const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
let bestLayout = { numFullPosters: 3, posterWidth: Platform.isTV ? 160 : 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
@ -52,12 +56,17 @@ const calculatePosterLayout = (screenWidth: number) => {
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const CatalogSection = ({ catalog, onPosterPress, onPosterFocus }: CatalogSectionProps) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
const handleContentPress = (id: string, type: string) => {
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
const content = catalog.items.find(item => item.id === id && item.type === type);
if (content && onPosterPress) {
onPosterPress(content);
} else {
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
}
};
const renderContentItem = ({ item, index }: { item: StreamingContent, index: number }) => {
@ -68,6 +77,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
<ContentItem
item={item}
onPress={handleContentPress}
onFocusItem={onPosterFocus}
/>
</Animated.View>
);
@ -83,46 +93,31 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
<Text style={[styles.catalogTitle, { color: currentTheme.colors.text }]} numberOfLines={1}>{catalog.name}</Text>
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
</View>
<TouchableOpacity
onPress={() =>
navigation.navigate('Catalog', {
id: catalog.id,
type: catalog.type,
addonId: catalog.addon
})
}
style={styles.viewAllButton}
>
<Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} />
</TouchableOpacity>
</View>
<FlatList
<FlashList
data={catalog.items}
renderItem={renderContentItem}
keyExtractor={(item) => `${item.id}-${item.type}`}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={[styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }]}
contentContainerStyle={StyleSheet.flatten([styles.catalogList, { paddingRight: 16 - posterLayout.partialPosterWidth }])}
snapToInterval={POSTER_WIDTH + 8}
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 8 }} />}
initialNumToRender={3}
maxToRenderPerBatch={2}
windowSize={3}
removeClippedSubviews={Platform.OS === 'android'}
updateCellsBatchingPeriod={50}
getItemLayout={(data, index) => ({
length: POSTER_WIDTH + 8,
offset: (POSTER_WIDTH + 8) * index,
index,
})}
maintainVisibleContentPosition={{
minIndexForVisible: 0
}}
estimatedItemSize={POSTER_WIDTH + 8}
onEndReachedThreshold={1}
// TV-specific focus navigation properties
{...(Platform.isTV && {
directionalLockEnabled: true,
horizontal: true,
scrollEnabled: true,
focusable: false,
tvParallaxProperties: {
enabled: false,
},
})}
/>
</Animated.View>
);
@ -131,12 +126,14 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const styles = StyleSheet.create({
catalogContainer: {
marginBottom: 28,
overflow: 'visible',
paddingVertical: Platform.isTV ? 8 : 0,
},
catalogHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingHorizontal: 24,
marginBottom: 16,
},
titleContainer: {
@ -173,8 +170,10 @@ const styles = StyleSheet.create({
marginRight: 4,
},
catalogList: {
paddingHorizontal: 16,
paddingHorizontal: 24,
paddingVertical: Platform.isTV ? 12 : 0,
overflow: 'visible',
},
});
export default React.memo(CatalogSection);
export default React.memo(CatalogSection);

View file

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text } from 'react-native';
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Animated } from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
@ -9,14 +9,16 @@ import { DropUpMenu } from './DropUpMenu';
interface ContentItemProps {
item: StreamingContent;
onPress: (id: string, type: string) => void;
onFocusItem?: (item: StreamingContent) => void;
}
const { width } = Dimensions.get('window');
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
// TV gets larger posters
const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 100;
const MAX_POSTER_WIDTH = Platform.isTV ? 180 : 130;
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
@ -24,7 +26,7 @@ const calculatePosterLayout = (screenWidth: number) => {
const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
let bestLayout = { numFullPosters: 3, posterWidth: Platform.isTV ? 160 : 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
@ -50,10 +52,14 @@ const calculatePosterLayout = (screenWidth: number) => {
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => {
const ContentItem = React.memo(({ item, onPress, onFocusItem }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const { currentTheme } = useTheme();
// Animation values for TV focus effects
const scaleAnim = useRef(new Animated.Value(1)).current;
const handleLongPress = useCallback(() => {
setMenuVisible(true);
@ -86,39 +92,80 @@ const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => {
setMenuVisible(false);
}, []);
// TV Focus handlers
const handleFocus = useCallback(() => {
if (Platform.isTV) {
setIsFocused(true);
Animated.spring(scaleAnim, {
toValue: 1.10,
useNativeDriver: true,
tension: 80,
friction: 6,
}).start();
}
if (onFocusItem) {
onFocusItem(item);
}
}, [scaleAnim]);
const handleBlur = useCallback(() => {
if (Platform.isTV) {
setIsFocused(false);
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
tension: 80,
friction: 6,
}).start();
}
}, [scaleAnim]);
// Dynamic styles for focus effects
const animatedContainerStyle = {
transform: [{ scale: scaleAnim }],
zIndex: isFocused && Platform.isTV ? 10 : 1,
marginHorizontal: isFocused && Platform.isTV ? 8 : 0,
elevation: isFocused && Platform.isTV ? 20 : 6,
};
return (
<>
<View style={styles.itemContainer}>
<TouchableOpacity
style={styles.contentItem}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
<Animated.View style={animatedContainerStyle}>
<TouchableOpacity
style={styles.contentItem}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={false}
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
</View>
)}
{item.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
</View>
)}
{item.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
</View>
</TouchableOpacity>
</TouchableOpacity>
</Animated.View>
<Text style={[styles.title, { color: currentTheme.colors.text }]} numberOfLines={2}>
{item.name}
</Text>
@ -199,4 +246,4 @@ const styles = StyleSheet.create({
}
});
export default ContentItem;
export default ContentItem;

View file

@ -3,15 +3,17 @@ import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
Dimensions,
AppState,
AppStateStatus,
Alert,
ActivityIndicator
ActivityIndicator,
Platform,
Animated
} from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { FlashList } from '@shopify/flash-list';
// Removed react-native-reanimated import
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
@ -41,8 +43,9 @@ interface ContinueWatchingRef {
// Dynamic poster calculation based on screen width for Continue Watching section
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 120; // Slightly larger for continue watching items
const MAX_POSTER_WIDTH = 160; // Maximum poster width for this section
// TV gets larger posters
const MIN_POSTER_WIDTH = Platform.isTV ? 160 : 120;
const MAX_POSTER_WIDTH = Platform.isTV ? 200 : 160;
const HORIZONTAL_PADDING = 40; // Total horizontal padding/margins
// Calculate how many posters can fit (fewer items for continue watching)
@ -88,6 +91,160 @@ const isEpisodeReleased = (video: any): boolean => {
};
// Create a proper imperative handle with React.forwardRef and updated type
// Continue Watching Item Component with TV Focus Animations
const ContinueWatchingItem = React.memo(({ item, onPress, onLongPress, deletingItemId, currentTheme }: {
item: ContinueWatchingItem;
onPress: () => void;
onLongPress: () => void;
deletingItemId: string | null;
currentTheme: any;
}) => {
const [isFocused, setIsFocused] = useState(false);
// Animation values for TV focus effects
const scaleAnim = useRef(new Animated.Value(1)).current;
// TV Focus handlers
const handleFocus = useCallback(() => {
if (Platform.isTV) {
setIsFocused(true);
Animated.spring(scaleAnim, {
toValue: 1.08,
useNativeDriver: true,
tension: 80,
friction: 6,
}).start();
}
}, [scaleAnim]);
const handleBlur = useCallback(() => {
if (Platform.isTV) {
setIsFocused(false);
Animated.spring(scaleAnim, {
toValue: 1,
useNativeDriver: true,
tension: 80,
friction: 6,
}).start();
}
}, [scaleAnim]);
// Dynamic styles for focus effects
const animatedContainerStyle = {
transform: [{ scale: scaleAnim }],
zIndex: isFocused && Platform.isTV ? 10 : 1,
};
return (
<Animated.View style={animatedContainerStyle}>
<TouchableOpacity
style={[styles.wideContentItem, {
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black
}]}
activeOpacity={0.8}
onPress={onPress}
onLongPress={onLongPress}
delayLongPress={800}
onFocus={handleFocus}
onBlur={handleBlur}
hasTVPreferredFocus={false}
>
{/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.continueWatchingPoster}
contentFit="cover"
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<View style={styles.deletingOverlay}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</View>
{/* Content Details */}
<View style={styles.contentDetails}>
<View style={styles.titleRow}>
{(() => {
const isUpNext = item.progress === 0;
return (
<View style={styles.titleRow}>
<Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{item.name}
</Text>
{isUpNext && (
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.progressText}>Up Next</Text>
</View>
)}
</View>
);
})()}
</View>
{/* Episode Info or Year */}
{(() => {
if (item.type === 'series' && item.season && item.episode) {
return (
<View style={styles.episodeRow}>
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
Season {item.season}
</Text>
{item.episodeTitle && (
<Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={1}
>
Episode {item.episode}: {item.episodeTitle}
</Text>
)}
</View>
);
} else {
return (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.year}
</Text>
);
}
})()}
{/* Progress Bar */}
<View style={styles.wideProgressContainer}>
<View style={styles.wideProgressTrack}>
<View
style={[
styles.wideProgressBar,
{
backgroundColor: currentTheme.colors.primary,
width: `${item.progress}%`
}
]}
/>
</View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.mediumEmphasis }]}>
{item.progress}% watched
</Text>
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
});
const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, ref) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme();
@ -572,7 +729,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
}
return (
<Animated.View entering={FadeIn.duration(300).delay(150)} style={styles.container}>
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text>
@ -580,116 +737,16 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
</View>
</View>
<FlatList
<FlashList
data={continueWatchingItems}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.wideContentItem, {
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border,
shadowColor: currentTheme.colors.black
}]}
activeOpacity={0.8}
<ContinueWatchingItem
item={item}
onPress={() => handleContentPress(item.id, item.type)}
onLongPress={() => handleLongPress(item)}
delayLongPress={800}
>
{/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.continueWatchingPoster}
contentFit="cover"
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
{/* Delete Indicator Overlay */}
{deletingItemId === item.id && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={styles.deletingOverlay}
>
<ActivityIndicator size="large" color="#FFFFFF" />
</Animated.View>
)}
</View>
{/* Content Details */}
<View style={styles.contentDetails}>
<View style={styles.titleRow}>
{(() => {
const isUpNext = item.progress === 0;
return (
<View style={styles.titleRow}>
<Text
style={[styles.contentTitle, { color: currentTheme.colors.highEmphasis }]}
numberOfLines={1}
>
{item.name}
</Text>
{isUpNext && (
<View style={[styles.progressBadge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.progressText}>Up Next</Text>
</View>
)}
</View>
);
})()}
</View>
{/* Episode Info or Year */}
{(() => {
if (item.type === 'series' && item.season && item.episode) {
return (
<View style={styles.episodeRow}>
<Text style={[styles.episodeText, { color: currentTheme.colors.mediumEmphasis }]}>
Season {item.season}
</Text>
{item.episodeTitle && (
<Text
style={[styles.episodeTitle, { color: currentTheme.colors.mediumEmphasis }]}
numberOfLines={1}
>
{item.episodeTitle}
</Text>
)}
</View>
);
} else {
return (
<Text style={[styles.yearText, { color: currentTheme.colors.mediumEmphasis }]}>
{item.year} {item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
);
}
})()}
{/* Progress Bar */}
{item.progress > 0 && (
<View style={styles.wideProgressContainer}>
<View style={styles.wideProgressTrack}>
<View
style={[
styles.wideProgressBar,
{
width: `${item.progress}%`,
backgroundColor: currentTheme.colors.primary
}
]}
/>
</View>
<Text style={[styles.progressLabel, { color: currentTheme.colors.textMuted }]}>
{Math.round(item.progress)}% watched
</Text>
</View>
)}
</View>
</TouchableOpacity>
deletingItemId={deletingItemId}
currentTheme={currentTheme}
/>
)}
keyExtractor={(item) => `continue-${item.id}-${item.type}`}
horizontal
@ -699,8 +756,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
estimatedItemSize={280 + 16}
// TV-specific focus navigation properties
{...(Platform.isTV && {
directionalLockEnabled: true,
horizontal: true,
scrollEnabled: true,
focusable: false,
tvParallaxProperties: {
enabled: false,
},
})}
/>
</Animated.View>
</View>
);
});
@ -709,6 +777,8 @@ const styles = StyleSheet.create({
marginBottom: 28,
paddingTop: 0,
marginTop: 12,
overflow: 'visible',
paddingVertical: Platform.isTV ? 8 : 0,
},
header: {
flexDirection: 'row',
@ -736,9 +806,11 @@ const styles = StyleSheet.create({
opacity: 0.8,
},
wideList: {
paddingHorizontal: 16,
paddingHorizontal: 24,
paddingBottom: 8,
paddingTop: 4,
paddingVertical: Platform.isTV ? 12 : 4,
overflow: 'visible',
},
wideContentItem: {
width: 280,

View file

@ -1,4 +1,4 @@
import React, { useEffect } from 'react';
import React from 'react';
import {
View,
Text,
@ -13,19 +13,6 @@ import {
import { MaterialIcons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image';
import { colors } from '../../styles/colors';
import Animated, {
useAnimatedStyle,
withTiming,
useSharedValue,
interpolate,
Extrapolate,
runOnJS,
} from 'react-native-reanimated';
import {
Gesture,
GestureDetector,
GestureHandlerRootView,
} from 'react-native-gesture-handler';
import { StreamingContent } from '../../services/catalogService';
interface DropUpMenuProps {
@ -36,144 +23,92 @@ interface DropUpMenuProps {
}
export const DropUpMenu = ({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
const translateY = useSharedValue(300);
const opacity = useSharedValue(0);
const isDarkMode = useColorScheme() === 'dark';
const SNAP_THRESHOLD = 100;
useEffect(() => {
if (visible) {
opacity.value = withTiming(1, { duration: 200 });
translateY.value = withTiming(0, { duration: 300 });
} else {
opacity.value = withTiming(0, { duration: 200 });
translateY.value = withTiming(300, { duration: 300 });
}
}, [visible]);
const gesture = Gesture.Pan()
.onStart(() => {
// Store initial position if needed
})
.onUpdate((event) => {
if (event.translationY > 0) { // Only allow dragging downwards
translateY.value = event.translationY;
opacity.value = interpolate(
event.translationY,
[0, 300],
[1, 0],
Extrapolate.CLAMP
);
}
})
.onEnd((event) => {
if (event.translationY > SNAP_THRESHOLD || event.velocityY > 500) {
translateY.value = withTiming(300, { duration: 300 });
opacity.value = withTiming(0, { duration: 200 });
runOnJS(onClose)();
} else {
translateY.value = withTiming(0, { duration: 300 });
opacity.value = withTiming(1, { duration: 200 });
}
});
const overlayStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
const menuStyle = useAnimatedStyle(() => ({
transform: [{ translateY: translateY.value }],
borderTopLeftRadius: 24,
borderTopRightRadius: 24,
}));
const menuOptions = [
{
icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
action: 'library'
},
{
icon: 'check-circle',
label: 'Mark as Watched',
action: 'watched'
},
{
icon: 'playlist-add',
label: 'Add to Playlist',
action: 'playlist'
},
{
icon: 'share',
label: 'Share',
action: 'share'
}
{ id: 'play', label: 'Play', icon: 'play-arrow' },
{ id: 'info', label: 'More Info', icon: 'info-outline' },
{ id: 'save', label: 'Add to My List', icon: 'bookmark-border' },
{ id: 'share', label: 'Share', icon: 'share' },
];
const backgroundColor = isDarkMode ? '#1A1A1A' : '#FFFFFF';
const handleOptionPress = (optionId: string) => {
onOptionSelect(optionId);
onClose();
};
return (
<Modal
visible={visible}
transparent
animationType="none"
animationType="slide"
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Animated.View style={[styles.modalOverlay, overlayStyle]}>
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.menuContainer, menuStyle, { backgroundColor }]}>
<View style={styles.dragHandle} />
<View style={styles.menuHeader}>
<ExpoImage
source={{ uri: item.poster }}
style={styles.menuPoster}
contentFit="cover"
<View style={styles.modalOverlay}>
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
<View style={[
styles.menuContainer,
{ backgroundColor: isDarkMode ? colors.darkBackground : colors.lightBackground }
]}>
{/* Drag Handle */}
<View style={styles.dragHandle} />
{/* Header with item info */}
<View style={styles.menuHeader}>
<ExpoImage
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.menuPoster}
contentFit="cover"
cachePolicy="memory"
/>
<View style={styles.menuTitleContainer}>
<Text style={[
styles.menuTitle,
{ color: isDarkMode ? colors.white : colors.black }
]} numberOfLines={2}>
{item.name}
</Text>
{item.year && (
<Text style={[
styles.menuYear,
{ color: isDarkMode ? colors.textMuted : colors.textMutedDark }
]}>
{item.year}
</Text>
)}
</View>
</View>
{/* Menu Options */}
<View style={styles.menuOptions}>
{menuOptions.map((option, index) => (
<TouchableOpacity
key={option.id}
style={[
styles.menuOption,
{ borderBottomColor: isDarkMode ? colors.border : colors.border },
index === menuOptions.length - 1 && styles.lastMenuOption
]}
onPress={() => handleOptionPress(option.id)}
activeOpacity={0.7}
>
<MaterialIcons
name={option.icon as any}
size={24}
color={isDarkMode ? colors.white : colors.black}
/>
<View style={styles.menuTitleContainer}>
<Text style={[styles.menuTitle, { color: isDarkMode ? '#FFFFFF' : '#000000' }]}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.menuYear, { color: isDarkMode ? '#999999' : '#666666' }]}>
{item.year}
</Text>
)}
</View>
</View>
<View style={styles.menuOptions}>
{menuOptions.map((option, index) => (
<TouchableOpacity
key={option.action}
style={[
styles.menuOption,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' },
index === menuOptions.length - 1 && styles.lastMenuOption
]}
onPress={() => {
onOptionSelect(option.action);
onClose();
}}
>
<MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
size={24}
color={colors.primary}
/>
<Text style={[
styles.menuOptionText,
{ color: isDarkMode ? '#FFFFFF' : '#000000' }
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</Animated.View>
</GestureDetector>
</Animated.View>
</GestureHandlerRootView>
<Text style={[
styles.menuOptionText,
{ color: isDarkMode ? colors.white : colors.black }
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</View>
</View>
</Modal>
);
};
@ -254,4 +189,4 @@ const styles = StyleSheet.create({
},
});
export default DropUpMenu;
export default DropUpMenu;

File diff suppressed because it is too large Load diff

View file

@ -3,11 +3,11 @@ import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
Dimensions
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { Image } from 'expo-image';
@ -20,7 +20,7 @@ import { tmdbService } from '../../services/tmdbService';
import { useLibrary } from '../../hooks/useLibrary';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns';
import Animated, { FadeIn, FadeInRight } from 'react-native-reanimated';
// Removed react-native-reanimated import
import { useCalendarData } from '../../hooks/useCalendarData';
const { width } = Dimensions.get('window');
@ -109,10 +109,7 @@ export const ThisWeekSection = React.memo(() => {
item.poster);
return (
<Animated.View
entering={FadeInRight.delay(index * 50).duration(300)}
style={styles.episodeItemContainer}
>
<View style={styles.episodeItemContainer}>
<TouchableOpacity
style={[
styles.episodeItem,
@ -177,12 +174,12 @@ export const ThisWeekSection = React.memo(() => {
</LinearGradient>
</View>
</TouchableOpacity>
</Animated.View>
</View>
);
};
return (
<Animated.View entering={FadeIn.duration(300)} style={styles.container}>
<View style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text>
@ -194,7 +191,7 @@ export const ThisWeekSection = React.memo(() => {
</TouchableOpacity>
</View>
<FlatList
<FlashList
data={thisWeekEpisodes}
keyExtractor={(item) => item.id}
renderItem={renderEpisodeItem}
@ -205,8 +202,9 @@ export const ThisWeekSection = React.memo(() => {
decelerationRate="fast"
snapToAlignment="start"
ItemSeparatorComponent={() => <View style={{ width: 16 }} />}
estimatedItemSize={ITEM_WIDTH + 16}
/>
</Animated.View>
</View>
);
});
@ -218,7 +216,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
paddingHorizontal: 24,
marginBottom: 16,
},
titleContainer: {
@ -254,8 +252,8 @@ const styles = StyleSheet.create({
marginRight: 4,
},
listContent: {
paddingLeft: 16,
paddingRight: 16,
paddingLeft: 24,
paddingRight: 24,
paddingBottom: 8,
},
loadingContainer: {
@ -337,4 +335,4 @@ const styles = StyleSheet.create({
marginLeft: 6,
letterSpacing: 0.3,
},
});
});

View file

@ -7,19 +7,11 @@ import {
ActivityIndicator,
Dimensions,
Platform,
Modal,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
withSpring,
runOnJS,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { useTheme } from '../../contexts/ThemeContext';
import { Cast } from '../../types/cast';
@ -54,419 +46,360 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
const { currentTheme } = useTheme();
const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null);
const [loading, setLoading] = useState(false);
const [hasFetched, setHasFetched] = useState(false);
const modalOpacity = useSharedValue(0);
const modalScale = useSharedValue(0.9);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (visible && castMember) {
modalOpacity.value = withTiming(1, { duration: 250 });
modalScale.value = withSpring(1, { damping: 20, stiffness: 200 });
if (!hasFetched || personDetails?.id !== castMember.id) {
fetchPersonDetails();
}
} else {
modalOpacity.value = withTiming(0, { duration: 200 });
modalScale.value = withTiming(0.9, { duration: 200 });
if (!visible) {
setHasFetched(false);
setPersonDetails(null);
}
if (visible && castMember?.id) {
fetchPersonDetails();
}
}, [visible, castMember]);
}, [visible, castMember?.id]);
const fetchPersonDetails = async () => {
if (!castMember || loading) return;
if (!castMember?.id) return;
setLoading(true);
setError(null);
try {
const details = await tmdbService.getPersonDetails(castMember.id);
setPersonDetails(details);
setHasFetched(true);
} catch (error) {
console.error('Error fetching person details:', error);
} catch (err) {
console.error('Error fetching person details:', err);
setError('Failed to load cast member details');
} finally {
setLoading(false);
}
};
const modalStyle = useAnimatedStyle(() => ({
opacity: modalOpacity.value,
transform: [{ scale: modalScale.value }],
}));
const handleClose = () => {
modalOpacity.value = withTiming(0, { duration: 200 });
modalScale.value = withTiming(0.9, { duration: 200 }, () => {
runOnJS(onClose)();
});
};
const formatDate = (dateString: string | null) => {
if (!dateString) return null;
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch {
return dateString;
}
};
const calculateAge = (birthday: string | null) => {
if (!birthday) return null;
const today = new Date();
const birthDate = new Date(birthday);
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
try {
const birthDate = new Date(birthday);
const today = new Date();
let age = today.getFullYear() - birthDate.getFullYear();
const monthDiff = today.getMonth() - birthDate.getMonth();
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
age--;
}
return age;
} catch {
return null;
}
return age;
};
if (!visible || !castMember) return null;
return (
<Animated.View
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(200)}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.85)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
padding: 20,
}}
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
>
<TouchableOpacity
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
}}
onPress={handleClose}
activeOpacity={1}
/>
<Animated.View
style={[
{
width: MODAL_WIDTH,
height: MODAL_HEIGHT,
overflow: 'hidden',
borderRadius: 24,
backgroundColor: Platform.OS === 'android'
? 'rgba(20, 20, 20, 0.95)'
: 'transparent',
},
modalStyle,
]}
>
{Platform.OS === 'ios' ? (
<BlurView
intensity={100}
tint="dark"
style={{
width: '100%',
height: '100%',
backgroundColor: 'rgba(20, 20, 20, 0.8)',
}}
>
{renderContent()}
</BlurView>
) : (
renderContent()
)}
</Animated.View>
</Animated.View>
);
function renderContent() {
return (
<>
{/* Header */}
<LinearGradient
colors={[
currentTheme.colors.primary + 'DD',
currentTheme.colors.primaryVariant + 'CC',
]}
style={{
padding: 20,
paddingTop: 24,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{
width: 60,
height: 60,
borderRadius: 30,
overflow: 'hidden',
marginRight: 16,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
}}>
{castMember.profile_path ? (
<Image
source={{
uri: `https://image.tmdb.org/t/p/w185${castMember.profile_path}`,
}}
style={{ width: '100%', height: '100%' }}
contentFit="cover"
/>
) : (
<View style={{
width: '100%',
height: '100%',
alignItems: 'center',
justifyContent: 'center',
}}>
<Text style={{
color: '#fff',
fontSize: 18,
fontWeight: '700',
}}>
{castMember.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)}
</Text>
</View>
)}
</View>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
fontSize: 18,
fontWeight: '800',
marginBottom: 4,
}} numberOfLines={2}>
{castMember.name}
</Text>
{castMember.character && (
<Text style={{
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 14,
fontWeight: '500',
}} numberOfLines={2}>
as {castMember.character}
</Text>
)}
</View>
<TouchableOpacity
style={{
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#fff" />
<View style={styles.overlay}>
<TouchableOpacity
style={styles.backdrop}
activeOpacity={1}
onPress={onClose}
/>
<View style={[styles.modalContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{Platform.OS === 'ios' ? (
<BlurView intensity={80} style={styles.blurBackground} tint="dark" />
) : (
<View style={[styles.androidBackground, { backgroundColor: currentTheme.colors.darkBackground }]} />
)}
{/* Header */}
<View style={styles.header}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
Cast Details
</Text>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialIcons name="close" size={24} color={currentTheme.colors.highEmphasis} />
</TouchableOpacity>
</View>
</LinearGradient>
{/* Content */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ padding: 20 }}
showsVerticalScrollIndicator={false}
>
{loading ? (
<View style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
}}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
marginTop: 12,
}}>
Loading details...
</Text>
</View>
) : (
<View>
{/* Quick Info */}
{(personDetails?.known_for_department || personDetails?.birthday || personDetails?.place_of_birth) && (
<View style={{
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderRadius: 16,
padding: 16,
marginBottom: 20,
}}>
{personDetails?.known_for_department && (
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: personDetails?.birthday || personDetails?.place_of_birth ? 12 : 0
}}>
<MaterialIcons name="work" size={16} color={currentTheme.colors.primary} />
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
marginLeft: 8,
marginRight: 12,
}}>
Department
</Text>
<Text style={{
color: '#fff',
fontSize: 14,
fontWeight: '600',
}}>
{personDetails.known_for_department}
</Text>
</View>
)}
{personDetails?.birthday && (
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: personDetails?.place_of_birth ? 12 : 0
}}>
<MaterialIcons name="cake" size={16} color="#22C55E" />
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
marginLeft: 8,
marginRight: 12,
}}>
Age
</Text>
<Text style={{
color: '#fff',
fontSize: 14,
fontWeight: '600',
}}>
{calculateAge(personDetails.birthday)} years old
</Text>
</View>
)}
{personDetails?.place_of_birth && (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<MaterialIcons name="place" size={16} color="#F59E0B" />
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
marginLeft: 8,
marginRight: 12,
}}>
Born in
</Text>
<Text style={{
color: '#fff',
fontSize: 14,
fontWeight: '600',
flex: 1,
}}>
{personDetails.place_of_birth}
</Text>
</View>
)}
{personDetails?.birthday && (
<View style={{
marginTop: 12,
paddingTop: 12,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.1)',
}}>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 12,
marginBottom: 4,
}}>
Born on {formatDate(personDetails.birthday)}
</Text>
</View>
)}
{/* Content */}
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{loading ? (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
Loading details...
</Text>
</View>
) : error ? (
<View style={styles.errorContainer}>
<MaterialIcons name="error-outline" size={48} color={currentTheme.colors.error} />
<Text style={[styles.errorText, { color: currentTheme.colors.error }]}>
{error}
</Text>
<TouchableOpacity onPress={fetchPersonDetails} style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity>
</View>
) : personDetails ? (
<View style={styles.detailsContainer}>
{/* Profile Image and Basic Info */}
<View style={styles.profileSection}>
<View style={styles.imageContainer}>
{personDetails.profile_path ? (
<Image
source={{ uri: `https://image.tmdb.org/t/p/w500${personDetails.profile_path}` }}
style={styles.profileImage}
contentFit="cover"
/>
) : (
<View style={[styles.placeholderImage, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="person" size={60} color={currentTheme.colors.mediumEmphasis} />
</View>
)}
</View>
<View style={styles.basicInfo}>
<Text style={[styles.name, { color: currentTheme.colors.highEmphasis }]}>
{personDetails.name}
</Text>
<Text style={[styles.department, { color: currentTheme.colors.primary }]}>
{personDetails.known_for_department}
</Text>
{personDetails.birthday && (
<View style={styles.infoRow}>
<MaterialIcons name="cake" size={16} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
{formatDate(personDetails.birthday)}
{calculateAge(personDetails.birthday) && ` (${calculateAge(personDetails.birthday)} years old)`}
</Text>
</View>
)}
{personDetails.place_of_birth && (
<View style={styles.infoRow}>
<MaterialIcons name="place" size={16} color={currentTheme.colors.mediumEmphasis} />
<Text style={[styles.infoText, { color: currentTheme.colors.mediumEmphasis }]}>
{personDetails.place_of_birth}
</Text>
</View>
)}
</View>
</View>
)}
{/* Biography */}
{personDetails.biography && (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
Biography
</Text>
<Text style={[styles.biography, { color: currentTheme.colors.mediumEmphasis }]}>
{personDetails.biography}
</Text>
</View>
)}
{/* Also Known As */}
{personDetails.also_known_as && personDetails.also_known_as.length > 0 && (
<View style={styles.section}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
Also Known As
</Text>
<View style={styles.aliasContainer}>
{personDetails.also_known_as.slice(0, 5).map((alias, index) => (
<View key={index} style={[styles.aliasChip, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Text style={[styles.aliasText, { color: currentTheme.colors.mediumEmphasis }]}>
{alias}
</Text>
</View>
))}
</View>
</View>
)}
</View>
) : null}
</ScrollView>
</View>
</View>
</Modal>
);
};
{/* Biography */}
{personDetails?.biography && (
<View style={{ marginBottom: 20 }}>
<Text style={{
color: '#fff',
fontSize: 16,
fontWeight: '700',
marginBottom: 12,
}}>
Biography
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.9)',
fontSize: 14,
lineHeight: 20,
fontWeight: '400',
}}>
{personDetails.biography}
</Text>
</View>
)}
{/* Also Known As - Compact */}
{personDetails?.also_known_as && personDetails.also_known_as.length > 0 && (
<View>
<Text style={{
color: '#fff',
fontSize: 16,
fontWeight: '700',
marginBottom: 12,
}}>
Also Known As
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 14,
lineHeight: 20,
}}>
{personDetails.also_known_as.slice(0, 4).join(' • ')}
</Text>
</View>
)}
{/* No details available */}
{!loading && !personDetails?.biography && !personDetails?.birthday && !personDetails?.place_of_birth && (
<View style={{
alignItems: 'center',
justifyContent: 'center',
paddingVertical: 40,
}}>
<MaterialIcons name="info" size={32} color="rgba(255, 255, 255, 0.3)" />
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
marginTop: 12,
textAlign: 'center',
}}>
No additional details available
</Text>
</View>
)}
</View>
)}
</ScrollView>
</>
);
}
const styles = {
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
justifyContent: 'center' as const,
alignItems: 'center' as const,
},
backdrop: {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
},
modalContainer: {
width: MODAL_WIDTH,
height: MODAL_HEIGHT,
borderRadius: 16,
overflow: 'hidden' as const,
elevation: 8,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
},
blurBackground: {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
},
androidBackground: {
position: 'absolute' as const,
top: 0,
left: 0,
right: 0,
bottom: 0,
opacity: 0.95,
},
header: {
flexDirection: 'row' as const,
justifyContent: 'space-between' as const,
alignItems: 'center' as const,
padding: 20,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.1)',
},
headerTitle: {
fontSize: 20,
fontWeight: '700' as const,
},
closeButton: {
padding: 4,
},
content: {
flex: 1,
padding: 20,
},
loadingContainer: {
flex: 1,
justifyContent: 'center' as const,
alignItems: 'center' as const,
paddingVertical: 40,
},
loadingText: {
marginTop: 16,
fontSize: 16,
},
errorContainer: {
flex: 1,
justifyContent: 'center' as const,
alignItems: 'center' as const,
paddingVertical: 40,
},
errorText: {
marginTop: 16,
fontSize: 16,
textAlign: 'center' as const,
marginBottom: 20,
},
retryButton: {
paddingHorizontal: 20,
paddingVertical: 10,
borderRadius: 8,
},
retryButtonText: {
color: '#fff',
fontWeight: '600' as const,
},
detailsContainer: {
flex: 1,
},
profileSection: {
flexDirection: 'row' as const,
marginBottom: 24,
},
imageContainer: {
marginRight: 16,
},
profileImage: {
width: 100,
height: 150,
borderRadius: 8,
},
placeholderImage: {
width: 100,
height: 150,
borderRadius: 8,
justifyContent: 'center' as const,
alignItems: 'center' as const,
},
basicInfo: {
flex: 1,
justifyContent: 'flex-start' as const,
},
name: {
fontSize: 24,
fontWeight: '700' as const,
marginBottom: 4,
},
department: {
fontSize: 16,
fontWeight: '600' as const,
marginBottom: 12,
},
infoRow: {
flexDirection: 'row' as const,
alignItems: 'center' as const,
marginBottom: 8,
},
infoText: {
fontSize: 14,
marginLeft: 8,
flex: 1,
},
section: {
marginBottom: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700' as const,
marginBottom: 12,
},
biography: {
fontSize: 14,
lineHeight: 20,
},
aliasContainer: {
flexDirection: 'row' as const,
flexWrap: 'wrap' as const,
gap: 8,
},
aliasChip: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
},
aliasText: {
fontSize: 12,
fontWeight: '500' as const,
},
};
export default CastDetailsModal;

View file

@ -106,27 +106,27 @@ const styles = StyleSheet.create({
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 12,
paddingHorizontal: 16,
paddingHorizontal: 24,
},
sectionTitle: {
fontSize: 18,
fontWeight: '700',
fontSize: 22,
fontWeight: '800',
},
castList: {
paddingHorizontal: 16,
paddingBottom: 4,
paddingHorizontal: 24,
paddingBottom: 8,
},
castCard: {
marginRight: 16,
width: 90,
marginRight: 20,
width: 110,
alignItems: 'center',
},
castImageContainer: {
width: 80,
height: 80,
borderRadius: 40,
width: 96,
height: 96,
borderRadius: 48,
overflow: 'hidden',
marginBottom: 8,
marginBottom: 10,
},
castImage: {
width: '100%',
@ -135,24 +135,24 @@ const styles = StyleSheet.create({
castImagePlaceholder: {
width: '100%',
height: '100%',
borderRadius: 40,
borderRadius: 48,
alignItems: 'center',
justifyContent: 'center',
},
placeholderText: {
fontSize: 24,
fontWeight: '600',
fontSize: 28,
fontWeight: '700',
},
castName: {
fontSize: 14,
fontWeight: '600',
fontSize: 16,
fontWeight: '700',
textAlign: 'center',
width: 90,
width: 110,
},
characterName: {
fontSize: 12,
fontSize: 13,
textAlign: 'center',
width: 90,
marginTop: 2,
width: 110,
marginTop: 4,
},
});

View file

@ -11,11 +11,6 @@ import { BlurView as ExpoBlurView } from 'expo-blur';
import { BlurView as CommunityBlurView } from '@react-native-community/blur';
import { MaterialIcons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolate,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
import { logger } from '../../utils/logger';
@ -27,9 +22,6 @@ interface FloatingHeaderProps {
handleBack: () => void;
handleToggleLibrary: () => void;
inLibrary: boolean;
headerOpacity: Animated.SharedValue<number>;
headerElementsY: Animated.SharedValue<number>;
headerElementsOpacity: Animated.SharedValue<number>;
safeAreaTop: number;
setLogoLoadError: (error: boolean) => void;
}
@ -40,37 +32,20 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
handleBack,
handleToggleLibrary,
inLibrary,
headerOpacity,
headerElementsY,
headerElementsOpacity,
safeAreaTop,
setLogoLoadError,
}) => {
const { currentTheme } = useTheme();
// Animated styles for the header
const headerAnimatedStyle = useAnimatedStyle(() => ({
opacity: headerOpacity.value,
transform: [
{ translateY: interpolate(headerOpacity.value, [0, 1], [-20, 0], Extrapolate.CLAMP) }
]
}));
// Animated style for header elements
const headerElementsStyle = useAnimatedStyle(() => ({
opacity: headerElementsOpacity.value,
transform: [{ translateY: headerElementsY.value }]
}));
return (
<Animated.View style={[styles.floatingHeader, headerAnimatedStyle]}>
<View style={styles.floatingHeader}>
{Platform.OS === 'ios' ? (
<ExpoBlurView
intensity={50}
tint="dark"
style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}
>
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
<View style={styles.floatingHeaderContent}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
@ -111,7 +86,7 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
color={currentTheme.colors.highEmphasis}
/>
</TouchableOpacity>
</Animated.View>
</View>
</ExpoBlurView>
) : (
<View style={[styles.blurContainer, { paddingTop: Math.max(safeAreaTop * 0.8, safeAreaTop - 6) }]}>
@ -121,7 +96,7 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
blurAmount={15}
reducedTransparencyFallbackColor="rgba(20, 20, 20, 0.9)"
/>
<Animated.View style={[styles.floatingHeaderContent, headerElementsStyle]}>
<View style={styles.floatingHeaderContent}>
<TouchableOpacity
style={styles.backButton}
onPress={handleBack}
@ -162,11 +137,11 @@ const FloatingHeader: React.FC<FloatingHeaderProps> = ({
color={currentTheme.colors.highEmphasis}
/>
</TouchableOpacity>
</Animated.View>
</View>
</View>
)}
{Platform.OS === 'ios' && <View style={[styles.headerBottomBorder, { backgroundColor: 'rgba(255,255,255,0.15)' }]} />}
</Animated.View>
</View>
);
};
@ -240,4 +215,4 @@ const styles = StyleSheet.create({
},
});
export default React.memo(FloatingHeader);
export default React.memo(FloatingHeader);

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import {
Text,
StyleSheet,
TouchableOpacity,
Platform,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { Image } from 'expo-image';
@ -112,11 +113,11 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
gap: 12,
paddingHorizontal: 16,
paddingHorizontal: 24,
marginBottom: 12,
},
metaText: {
fontSize: 15,
fontSize: Platform.isTV ? 30 : 15,
fontWeight: '700',
letterSpacing: 0.3,
textTransform: 'uppercase',
@ -127,18 +128,18 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
imdbLogo: {
width: 35,
height: 18,
marginRight: 4,
width: Platform.isTV ? 44 : 35,
height: Platform.isTV ? 22 : 18,
marginRight: Platform.isTV ? 6 : 4,
},
ratingText: {
fontWeight: '700',
fontSize: 15,
fontSize: Platform.isTV ? 18 : 15,
letterSpacing: 0.3,
},
creatorContainer: {
marginBottom: 2,
paddingHorizontal: 16,
paddingHorizontal: 24,
},
creatorSection: {
flexDirection: 'row',
@ -147,23 +148,23 @@ const styles = StyleSheet.create({
height: 20
},
creatorLabel: {
fontSize: 14,
fontSize: Platform.isTV ? 16 : 14,
fontWeight: '600',
marginRight: 8,
lineHeight: 20
},
creatorText: {
fontSize: 14,
fontSize: Platform.isTV ? 16 : 14,
flex: 1,
lineHeight: 20
},
descriptionContainer: {
marginBottom: 16,
paddingHorizontal: 16,
paddingHorizontal: 24,
},
description: {
fontSize: 15,
lineHeight: 24,
fontSize: Platform.isTV ? 25 : 17,
lineHeight: Platform.isTV ? 30 : 26,
},
showMoreButton: {
flexDirection: 'row',
@ -172,7 +173,7 @@ const styles = StyleSheet.create({
paddingVertical: 4,
},
showMoreText: {
fontSize: 14,
fontSize: Platform.isTV ? 17 : 15,
marginRight: 4,
},
});

View file

@ -8,12 +8,13 @@ import {
ActivityIndicator,
Dimensions,
Alert,
Platform,
} from 'react-native';
import { Image } from 'expo-image';
import { useNavigation, StackActions } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { StreamingContent } from '../../types/metadata';
import { StreamingContent } from '../../services/catalogService';
import { useTheme } from '../../contexts/ThemeContext';
import { TMDBService } from '../../services/tmdbService';
import { catalogService } from '../../services/catalogService';
@ -22,8 +23,9 @@ const { width } = Dimensions.get('window');
// Dynamic poster calculation based on screen width for More Like This section
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Slightly smaller for more items in this section
const MAX_POSTER_WIDTH = 130; // Maximum poster width
// TV gets larger posters
const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 100;
const MAX_POSTER_WIDTH = Platform.isTV ? 170 : 130;
const HORIZONTAL_PADDING = 48; // Total horizontal padding/margins
// Calculate how many posters can fit (aim for slightly more items than main sections)
@ -169,4 +171,4 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
});
});

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, ActivityIndicator, Image, Animated } from 'react-native';
import { View, Text, StyleSheet, ActivityIndicator, Image, Animated, Platform } from 'react-native';
import { useTheme } from '../../contexts/ThemeContext';
import { useMDBListRatings } from '../../hooks/useMDBListRatings';
import AsyncStorage from '@react-native-async-storage/async-storage';
@ -190,8 +190,8 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
) : (
<View style={styles.compactSvgContainer}>
{React.createElement(config.icon as any, {
width: 16,
height: 16,
width: Platform.isTV ? 22 : 16,
height: Platform.isTV ? 22 : 16,
})}
</View>
)}
@ -209,8 +209,8 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
const styles = StyleSheet.create({
container: {
marginTop: 2,
marginBottom: 8,
paddingHorizontal: 16,
marginBottom: 10,
paddingHorizontal: Platform.isTV ? 24 : 16,
},
loadingContainer: {
height: 40,
@ -225,18 +225,18 @@ const styles = StyleSheet.create({
compactRatingItem: {
flexDirection: 'row',
alignItems: 'center',
marginRight: 12,
marginRight: Platform.isTV ? 16 : 12,
},
compactRatingIcon: {
width: 16,
height: 16,
marginRight: 4,
width: Platform.isTV ? 22 : 16,
height: Platform.isTV ? 22 : 16,
marginRight: Platform.isTV ? 6 : 4,
},
compactSvgContainer: {
marginRight: 4,
marginRight: Platform.isTV ? 6 : 4,
},
compactRatingValue: {
fontSize: 14,
fontWeight: '600',
fontSize: Platform.isTV ? 18 : 14,
fontWeight: '700',
},
});

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Platform } from 'react-native';
import { Image } from 'expo-image';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { LinearGradient } from 'expo-linear-gradient';
@ -231,7 +231,10 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
horizontal
showsHorizontalScrollIndicator={false}
style={styles.seasonSelectorContainer}
contentContainerStyle={styles.seasonSelectorContent}
contentContainerStyle={[
styles.seasonSelectorContent,
Platform.isTV && { paddingVertical: 10, paddingHorizontal: 24 }
]}
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={3}
@ -250,18 +253,31 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
key={season}
style={[
styles.seasonButton,
Platform.isTV && { width: 140, marginRight: 24 },
selectedSeason === season && [styles.selectedSeasonButton, { borderColor: currentTheme.colors.primary }]
]}
onPress={() => onSeasonChange(season)}
hasTVPreferredFocus={Platform.isTV && selectedSeason === season}
activeOpacity={0.85}
tvParallaxProperties={Platform.isTV ? {
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.08,
} : undefined}
>
<View style={styles.seasonPosterContainer}>
<View style={[
styles.seasonPosterContainer,
Platform.isTV && { width: 140, height: 210, borderRadius: 12 }
]}>
<Image
source={{ uri: seasonPoster }}
style={styles.seasonPoster}
contentFit="cover"
/>
{selectedSeason === season && (
<View style={[styles.selectedSeasonIndicator, { backgroundColor: currentTheme.colors.primary }]} />
<View style={[styles.selectedSeasonIndicator, { backgroundColor: currentTheme.colors.primary, height: 6 }]} />
)}
{/* Show episode count badge, including when there are no episodes */}
<View style={[styles.episodeCountBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
@ -274,6 +290,7 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
style={[
styles.seasonButtonText,
{ color: currentTheme.colors.mediumEmphasis },
Platform.isTV && { fontSize: 18, marginTop: 6 },
selectedSeason === season && [styles.selectedSeasonButtonText, { color: currentTheme.colors.primary }]
]}
>
@ -340,14 +357,24 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
style={[
styles.episodeCardVertical,
isTablet && styles.episodeCardVerticalTablet,
{ backgroundColor: currentTheme.colors.elevation2 }
{ backgroundColor: currentTheme.colors.elevation2 },
Platform.isTV && { height: 150 }
]}
onPress={() => onSelectEpisode(episode)}
activeOpacity={0.7}
hasTVPreferredFocus={Platform.isTV && episode === episodes[0]}
tvParallaxProperties={Platform.isTV ? {
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.05,
} : undefined}
>
<View style={[
styles.episodeImageContainer,
isTablet && styles.episodeImageContainerTablet
isTablet && styles.episodeImageContainerTablet,
Platform.isTV && { width: 150, height: 150 }
]}>
<Image
source={{ uri: episodeImage }}
@ -488,6 +515,14 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
]}
onPress={() => onSelectEpisode(episode)}
activeOpacity={0.85}
hasTVPreferredFocus={Platform.isTV && episode === episodes[0]}
tvParallaxProperties={Platform.isTV ? {
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.06,
} : undefined}
>
{/* Gradient Border Container */}
<View style={{
@ -644,7 +679,8 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
entering={FadeIn.duration(300).delay(100 + index * 30)}
style={[
styles.episodeCardWrapperHorizontal,
isTablet && styles.episodeCardWrapperHorizontalTablet
isTablet && styles.episodeCardWrapperHorizontalTablet,
Platform.isTV && { width: width * 0.3, marginRight: 24 }
]}
>
{renderHorizontalEpisodeCard(episode)}
@ -653,9 +689,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
keyExtractor={episode => episode.id.toString()}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.episodeListContentHorizontal}
contentContainerStyle={[
styles.episodeListContentHorizontal,
Platform.isTV && { paddingLeft: 24, paddingRight: 24 }
]}
decelerationRate="fast"
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
snapToInterval={Platform.isTV ? width * 0.3 + 24 : (isTablet ? width * 0.4 + 16 : width * 0.85 + 16)}
snapToAlignment="start"
initialNumToRender={3}
maxToRenderPerBatch={3}
@ -721,7 +760,7 @@ const styles = StyleSheet.create({
opacity: 0.8,
},
sectionTitle: {
fontSize: 20,
fontSize: 22,
fontWeight: '700',
marginBottom: 16,
paddingHorizontal: 16,
@ -756,22 +795,22 @@ const styles = StyleSheet.create({
shadowRadius: 8,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.1)',
height: 120,
height: 130,
},
episodeCardVerticalTablet: {
width: '47%',
flexDirection: 'row',
height: 140,
height: 160,
marginBottom: 0,
},
episodeImageContainer: {
position: 'relative',
width: 120,
height: 120,
width: 130,
height: 130,
},
episodeImageContainerTablet: {
width: 140,
height: 140,
width: 160,
height: 160,
},
episodeImage: {
width: '100%',
@ -811,14 +850,14 @@ const styles = StyleSheet.create({
marginBottom: 6,
},
episodeTitle: {
fontSize: 15,
fontSize: 16,
fontWeight: '700',
letterSpacing: 0.3,
marginBottom: 2,
},
episodeTitleTablet: {
fontSize: 16,
marginBottom: 4,
fontSize: 18,
marginBottom: 6,
},
episodeMetadata: {
flexDirection: 'row',
@ -843,7 +882,7 @@ const styles = StyleSheet.create({
},
ratingText: {
color: '#01b4e4',
fontSize: 13,
fontSize: 14,
fontWeight: '700',
marginLeft: 4,
},
@ -856,22 +895,22 @@ const styles = StyleSheet.create({
borderRadius: 4,
},
runtimeText: {
fontSize: 13,
fontSize: 14,
fontWeight: '600',
marginLeft: 4,
},
airDateText: {
fontSize: 12,
fontSize: 13,
opacity: 0.8,
},
episodeOverview: {
fontSize: 13,
lineHeight: 18,
},
episodeOverviewTablet: {
fontSize: 14,
lineHeight: 20,
},
episodeOverviewTablet: {
fontSize: 15,
lineHeight: 22,
},
progressBarContainer: {
position: 'absolute',
bottom: 0,
@ -919,13 +958,13 @@ const styles = StyleSheet.create({
shadowRadius: 12,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.05)',
height: 200,
height: Platform.isTV ? 200 : 220,
position: 'relative',
width: '100%',
backgroundColor: 'transparent',
},
episodeCardHorizontalTablet: {
height: 180,
height: 250,
},
episodeBackgroundImage: {
width: '100%',
@ -963,7 +1002,7 @@ const styles = StyleSheet.create({
},
episodeTitleHorizontal: {
color: '#fff',
fontSize: 15,
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.3,
marginBottom: 4,
@ -971,8 +1010,8 @@ const styles = StyleSheet.create({
},
episodeDescriptionHorizontal: {
color: 'rgba(255,255,255,0.85)',
fontSize: 12,
lineHeight: 16,
fontSize: Platform.isTV ? 14 : 13,
lineHeight: 18,
marginBottom: 8,
opacity: 0.9,
},
@ -991,7 +1030,7 @@ const styles = StyleSheet.create({
},
runtimeTextHorizontal: {
color: 'rgba(255,255,255,0.8)',
fontSize: 11,
fontSize: 12,
fontWeight: '500',
},
ratingContainerHorizontal: {
@ -1005,7 +1044,7 @@ const styles = StyleSheet.create({
},
ratingTextHorizontal: {
color: '#FFD700',
fontSize: 11,
fontSize: 12,
fontWeight: '600',
},
progressBarContainerHorizontal: {

View file

@ -5,7 +5,7 @@ import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import RNImmersiveMode from 'react-native-immersive-mode';
import * as ScreenOrientation from 'expo-screen-orientation';
import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
@ -258,10 +258,8 @@ const AndroidVideoPlayer: React.FC = () => {
initializePlayer();
return () => {
subscription?.remove();
const unlockOrientation = async () => {
await ScreenOrientation.unlockAsync();
};
unlockOrientation();
// Screen orientation unlocking is not supported on tvOS
// Orientation is handled automatically by the platform
disableImmersiveMode();
};
}, []);
@ -649,14 +647,10 @@ const AndroidVideoPlayer: React.FC = () => {
logger.log(`[AndroidVideoPlayer] Current progress: ${actualCurrentTime}/${duration} (${progressPercent.toFixed(1)}%)`);
// Navigate immediately without delay
ScreenOrientation.unlockAsync().then(() => {
disableImmersiveMode();
navigation.goBack();
}).catch(() => {
// Fallback: navigate even if orientation unlock fails
disableImmersiveMode();
navigation.goBack();
});
// Screen orientation unlocking is not supported on tvOS
// Orientation is handled automatically by the platform
disableImmersiveMode();
navigation.goBack();
// Send Trakt sync in background (don't await)
const backgroundSync = async () => {

View file

@ -5,7 +5,7 @@ import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
import { RootStackParamList, RootStackNavigationProp } from '../../navigation/AppNavigator';
import { PinchGestureHandler, State, PinchGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import RNImmersiveMode from 'react-native-immersive-mode';
import * as ScreenOrientation from 'expo-screen-orientation';
import { storageService } from '../../services/storageService';
import { logger } from '../../utils/logger';
import AsyncStorage from '@react-native-async-storage/async-storage';
@ -259,26 +259,10 @@ const VideoPlayer: React.FC = () => {
}
}, [effectiveDimensions, videoAspectRatio]);
// Force landscape orientation immediately when component mounts
// Screen orientation locking is not supported on tvOS
// Orientation is handled automatically by the platform
useEffect(() => {
const lockOrientation = async () => {
try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
logger.log('[VideoPlayer] Locked to landscape orientation');
} catch (error) {
logger.warn('[VideoPlayer] Failed to lock orientation:', error);
}
};
// Lock orientation immediately
lockOrientation();
return () => {
// Unlock orientation when component unmounts
ScreenOrientation.unlockAsync().catch(() => {
// Ignore unlock errors
});
};
logger.log('[VideoPlayer] Orientation handling skipped on TV platform');
}, []);
useEffect(() => {
@ -713,13 +697,9 @@ const VideoPlayer: React.FC = () => {
// Cleanup and navigate back immediately without delay
const cleanup = async () => {
try {
// Unlock orientation first
await ScreenOrientation.unlockAsync();
logger.log('[VideoPlayer] Orientation unlocked');
} catch (orientationError) {
logger.warn('[VideoPlayer] Failed to unlock orientation:', orientationError);
}
// Screen orientation unlocking is not supported on tvOS
// Orientation is handled automatically by the platform
logger.log('[VideoPlayer] Orientation handling skipped on TV platform');
// Disable immersive mode
disableImmersiveMode();

View file

@ -1,12 +1,7 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Dimensions } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import Animated, {
FadeIn,
FadeOut,
SlideInRight,
SlideOutRight,
} from 'react-native-reanimated';
// Removed react-native-reanimated imports
import { styles } from '../utils/playerStyles';
import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes';
import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils';
@ -89,9 +84,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
return (
<>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
<View
style={{
position: 'absolute',
top: 0,
@ -107,12 +100,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
onPress={handleClose}
activeOpacity={1}
/>
</Animated.View>
</View>
{/* Side Menu */}
<Animated.View
entering={SlideInRight.duration(300)}
exiting={SlideOutRight.duration(250)}
<View
style={{
position: 'absolute',
top: 0,
@ -527,7 +518,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
</TouchableOpacity>
</View>
</ScrollView>
</Animated.View>
</View>
</>
);
};
@ -539,4 +530,4 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
);
};
export default SubtitleModals;
export default SubtitleModals;

View file

@ -1,204 +0,0 @@
import { useEffect } from 'react';
import { Dimensions } from 'react-native';
import {
useSharedValue,
withTiming,
withSpring,
Easing,
useAnimatedScrollHandler,
runOnUI,
cancelAnimation,
} from 'react-native-reanimated';
const { width, height } = Dimensions.get('window');
// Highly optimized animation configurations
const fastSpring = {
damping: 15,
mass: 0.8,
stiffness: 150,
};
const ultraFastSpring = {
damping: 12,
mass: 0.6,
stiffness: 200,
};
// Ultra-optimized easing functions
const easings = {
fast: Easing.out(Easing.quad),
ultraFast: Easing.out(Easing.linear),
natural: Easing.bezier(0.2, 0, 0.2, 1),
};
export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) => {
// Consolidated entrance animations - start with visible values for Android compatibility
const screenOpacity = useSharedValue(1);
const contentOpacity = useSharedValue(1);
// Combined hero animations
const heroOpacity = useSharedValue(1);
const heroScale = useSharedValue(1); // Start at 1 for Android compatibility
const heroHeightValue = useSharedValue(height * 0.5);
// Combined UI element animations
const uiElementsOpacity = useSharedValue(1);
const uiElementsTranslateY = useSharedValue(0);
// Progress animation - simplified to single value
const progressOpacity = useSharedValue(0);
// Scroll values - minimal
const scrollY = useSharedValue(0);
const headerProgress = useSharedValue(0); // Single value for all header animations
// Static header elements Y for performance
const staticHeaderElementsY = useSharedValue(0);
// Ultra-fast entrance sequence - batch animations for better performance
useEffect(() => {
// Batch all entrance animations to run simultaneously with safety
const enterAnimations = () => {
'worklet';
try {
// Start with slightly reduced values and animate to full visibility
screenOpacity.value = withTiming(1, {
duration: 250,
easing: easings.fast
});
heroOpacity.value = withTiming(1, {
duration: 300,
easing: easings.fast
});
heroScale.value = withSpring(1, ultraFastSpring);
uiElementsOpacity.value = withTiming(1, {
duration: 400,
easing: easings.natural
});
uiElementsTranslateY.value = withSpring(0, fastSpring);
contentOpacity.value = withTiming(1, {
duration: 350,
easing: easings.fast
});
} catch (error) {
// Silently handle any animation errors
console.warn('Animation error in enterAnimations:', error);
}
};
// Use runOnUI for better performance with error handling
try {
runOnUI(enterAnimations)();
} catch (error) {
console.warn('Failed to run enter animations:', error);
}
}, []);
// Optimized watch progress animation with safety
useEffect(() => {
const hasProgress = watchProgress && watchProgress.duration > 0;
const updateProgress = () => {
'worklet';
try {
progressOpacity.value = withTiming(hasProgress ? 1 : 0, {
duration: hasProgress ? 200 : 150,
easing: easings.fast
});
} catch (error) {
console.warn('Animation error in updateProgress:', error);
}
};
try {
runOnUI(updateProgress)();
} catch (error) {
console.warn('Failed to run progress animation:', error);
}
}, [watchProgress]);
// Cleanup function to cancel animations
useEffect(() => {
return () => {
try {
cancelAnimation(screenOpacity);
cancelAnimation(contentOpacity);
cancelAnimation(heroOpacity);
cancelAnimation(heroScale);
cancelAnimation(uiElementsOpacity);
cancelAnimation(uiElementsTranslateY);
cancelAnimation(progressOpacity);
cancelAnimation(scrollY);
cancelAnimation(headerProgress);
cancelAnimation(staticHeaderElementsY);
} catch (error) {
console.warn('Error canceling animations:', error);
}
};
}, []);
// Ultra-optimized scroll handler with minimal calculations and safety
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
'worklet';
try {
const rawScrollY = event.contentOffset.y;
scrollY.value = rawScrollY;
// Single calculation for header threshold
const threshold = height * 0.4 - safeAreaTop;
const progress = rawScrollY > threshold ? 1 : 0;
// Use single progress value for all header animations
if (headerProgress.value !== progress) {
headerProgress.value = withTiming(progress, {
duration: progress ? 200 : 150,
easing: easings.ultraFast
});
}
} catch (error) {
console.warn('Animation error in scroll handler:', error);
}
},
});
return {
// Optimized shared values - reduced count
screenOpacity,
contentOpacity,
heroOpacity,
heroScale,
uiElementsOpacity,
uiElementsTranslateY,
progressOpacity,
scrollY,
headerProgress,
// Computed values for compatibility (derived from optimized values)
get heroHeight() { return heroHeightValue; },
get logoOpacity() { return uiElementsOpacity; },
get buttonsOpacity() { return uiElementsOpacity; },
get buttonsTranslateY() { return uiElementsTranslateY; },
get contentTranslateY() { return uiElementsTranslateY; },
get watchProgressOpacity() { return progressOpacity; },
get watchProgressWidth() { return progressOpacity; }, // Reuse for width animation
get headerOpacity() { return headerProgress; },
get headerElementsY() {
return staticHeaderElementsY; // Use pre-created shared value
},
get headerElementsOpacity() { return headerProgress; },
// Functions
scrollHandler,
animateLogo: () => {}, // Simplified - no separate logo animation
};
};

View file

@ -10,7 +10,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { colors } from '../styles/colors';
import { NuvioHeader } from '../components/NuvioHeader';
import { Stream } from '../types/streams';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { useTheme } from '../contexts/ThemeContext';
@ -28,7 +27,7 @@ import ShowRatingsScreen from '../screens/ShowRatingsScreen';
import CatalogSettingsScreen from '../screens/CatalogSettingsScreen';
import StreamsScreen from '../screens/StreamsScreen';
import CalendarScreen from '../screens/CalendarScreen';
import NotificationSettingsScreen from '../screens/NotificationSettingsScreen';
import MDBListSettingsScreen from '../screens/MDBListSettingsScreen';
import TMDBSettingsScreen from '../screens/TMDBSettingsScreen';
import HomeScreenSettings from '../screens/HomeScreenSettings';
@ -96,7 +95,7 @@ export type RootStackParamList = {
About: undefined;
Addons: undefined;
CatalogSettings: undefined;
NotificationSettings: undefined;
MDBListSettings: undefined;
TMDBSettings: undefined;
HomeScreenSettings: undefined;
@ -372,11 +371,11 @@ const TabScreenWrapper: React.FC<{children: React.ReactNode}> = ({ children }) =
position: 'relative',
overflow: 'hidden'
}}>
{/* Reserve consistent space for the header area on all screens */}
{/* Optional reserved space behind content if needed */}
<View style={{
height: Platform.OS === 'android' ? 80 : 60,
height: Platform.OS === 'android' ? 56 : 56,
width: '100%',
backgroundColor: colors.darkBackground,
backgroundColor: 'transparent',
position: 'absolute',
top: 0,
left: 0,
@ -403,128 +402,168 @@ const MainTabs = () => {
const renderTabBar = (props: BottomTabBarProps) => {
return (
<View style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 85,
backgroundColor: 'transparent',
overflow: 'hidden',
}}>
<View
style={{
position: 'absolute',
top: 8,
left: 0,
right: 0,
height: 40,
backgroundColor: 'transparent',
alignItems: 'center',
justifyContent: 'flex-start',
overflow: 'visible',
zIndex: 100,
}}
>
{Platform.OS === 'ios' ? (
<BlurView
tint="dark"
intensity={75}
style={{
position: 'absolute',
height: '100%',
width: '100%',
borderTopColor: currentTheme.colors.border,
borderTopWidth: 0.5,
shadowColor: currentTheme.colors.black,
shadowOffset: { width: 0, height: -2 },
shadowOpacity: 0.1,
shadowRadius: 3,
borderRadius: 20,
overflow: 'hidden',
paddingVertical: 6,
paddingHorizontal: 8,
minWidth: 200,
maxWidth: '90%',
}}
/>
) : (
<LinearGradient
colors={[
'rgba(0, 0, 0, 0)',
'rgba(0, 0, 0, 0.65)',
'rgba(0, 0, 0, 0.85)',
'rgba(0, 0, 0, 0.98)',
]}
locations={[0, 0.2, 0.4, 0.8]}
style={{
position: 'absolute',
height: '100%',
width: '100%',
}}
/>
)}
<View
style={{
height: '100%',
paddingBottom: 20,
paddingTop: 12,
backgroundColor: 'transparent',
}}
>
<View style={{ flexDirection: 'row', paddingTop: 4 }}>
{props.state.routes.map((route, index) => {
const { options } = props.descriptors[route.key];
const label =
options.tabBarLabel !== undefined
? options.tabBarLabel
: options.title !== undefined
? options.title
: route.name;
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{props.state.routes.map((route, index) => {
const { options } = props.descriptors[route.key];
const label =
options.tabBarLabel !== undefined
? options.tabBarLabel
: options.title !== undefined
? options.title
: route.name;
const isFocused = props.state.index === index;
const isFocused = props.state.index === index;
const onPress = () => {
const event = props.navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
const onPress = () => {
const event = props.navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
props.navigation.navigate(route.name);
}
};
if (!isFocused && !event.defaultPrevented) {
props.navigation.navigate(route.name);
}
};
let iconName: IconNameType = 'home';
switch (route.name) {
case 'Home':
iconName = 'home';
break;
case 'Library':
iconName = 'play-box-multiple';
break;
case 'Search':
iconName = 'feature-search';
break;
case 'Settings':
iconName = 'cog';
break;
}
return (
<TouchableOpacity
key={route.key}
activeOpacity={0.7}
onPress={onPress}
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'transparent',
}}
>
<TabIcon
focused={isFocused}
color={isFocused ? currentTheme.colors.primary : currentTheme.colors.white}
iconName={iconName}
/>
<Text
return (
<TouchableOpacity
key={route.key}
activeOpacity={0.8}
onPress={onPress}
hasTVPreferredFocus={index === 0 && Platform.isTV}
tvParallaxProperties={Platform.isTV ? {
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.05,
} : undefined}
style={{
fontSize: 12,
fontWeight: '600',
marginTop: 4,
color: isFocused ? currentTheme.colors.primary : currentTheme.colors.white,
opacity: isFocused ? 1 : 0.7,
paddingVertical: 4,
paddingHorizontal: 12,
marginHorizontal: 2,
borderRadius: 14,
backgroundColor: 'transparent',
}}
>
{typeof label === 'string' ? label : ''}
</Text>
</TouchableOpacity>
);
})}
<Text
style={{
fontSize: 12,
fontWeight: '600',
color: isFocused ? currentTheme.colors.primary : currentTheme.colors.white,
opacity: isFocused ? 1 : 0.75,
}}
>
{typeof label === 'string' ? label : ''}
</Text>
</TouchableOpacity>
);
})}
</View>
</BlurView>
) : (
<View
style={{
borderRadius: 20,
overflow: 'hidden',
paddingVertical: 6,
paddingHorizontal: 8,
minWidth: 200,
maxWidth: '90%',
backgroundColor: 'rgba(0,0,0,0.6)',
borderWidth: 0.5,
borderColor: currentTheme.colors.border,
}}
>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{props.state.routes.map((route, index) => {
const { options } = props.descriptors[route.key];
const label =
options.tabBarLabel !== undefined
? options.tabBarLabel
: options.title !== undefined
? options.title
: route.name;
const isFocused = props.state.index === index;
const onPress = () => {
const event = props.navigation.emit({
type: 'tabPress',
target: route.key,
canPreventDefault: true,
});
if (!isFocused && !event.defaultPrevented) {
props.navigation.navigate(route.name);
}
};
return (
<TouchableOpacity
key={route.key}
activeOpacity={0.8}
onPress={onPress}
hasTVPreferredFocus={index === 0 && Platform.isTV}
tvParallaxProperties={Platform.isTV ? {
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.05,
} : undefined}
style={{
paddingVertical: 4,
paddingHorizontal: 12,
marginHorizontal: 2,
borderRadius: 14,
backgroundColor: 'transparent',
}}
>
<Text
style={{
fontSize: 12,
fontWeight: '600',
color: isFocused ? currentTheme.colors.primary : currentTheme.colors.white,
opacity: isFocused ? 1 : 0.8,
}}
>
{typeof label === 'string' ? label : ''}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
</View>
)}
</View>
);
};
@ -570,8 +609,7 @@ const MainTabs = () => {
],
},
}),
header: () => (route.name === 'Home' ? <NuvioHeader /> : null),
headerShown: route.name === 'Home',
headerShown: false,
tabBarShowLabel: false,
tabBarStyle: {
position: 'absolute',
@ -911,17 +949,7 @@ const AppNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootStack
},
}}
/>
<Stack.Screen
name="NotificationSettings"
component={NotificationSettingsScreen as any}
options={{
animation: 'slide_from_right',
animationDuration: Platform.OS === 'android' ? 250 : 300,
contentStyle: {
backgroundColor: currentTheme.colors.darkBackground,
},
}}
/>
<Stack.Screen
name="MDBListSettings"
component={MDBListSettingsScreen}

View file

@ -3,7 +3,6 @@ import {
View,
Text,
StyleSheet,
FlatList,
TouchableOpacity,
ActivityIndicator,
SafeAreaView,
@ -18,6 +17,7 @@ import {
Pressable,
Alert
} from 'react-native';
import { FlashList } from '@shopify/flash-list';
import { useNavigation, useFocusEffect } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
@ -61,7 +61,7 @@ import { SkeletonFeatured } from '../components/home/SkeletonLoaders';
import homeStyles, { sharedStyles } from '../styles/homeStyles';
import { useTheme } from '../contexts/ThemeContext';
import type { Theme } from '../contexts/ThemeContext';
import * as ScreenOrientation from 'expo-screen-orientation';
import AsyncStorage from '@react-native-async-storage/async-storage';
import FirstTimeWelcome from '../components/FirstTimeWelcome';
import { imageCacheService } from '../services/imageCacheService';
@ -106,6 +106,117 @@ const SkeletonCatalog = React.memo(() => {
});
const HomeScreen = () => {
const styles = StyleSheet.create<any>({
stickyHeroContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
},
container: {
flex: 1,
},
scrollContent: {
paddingBottom: 90,
},
loadingMainContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingBottom: 90,
},
loadingText: {
marginTop: 12,
fontSize: 14,
},
loadingMoreCatalogs: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: 16,
marginHorizontal: 16,
marginBottom: 16,
backgroundColor: 'rgba(0,0,0,0.2)',
borderRadius: 8,
},
loadingMoreText: {
marginLeft: 12,
fontSize: 14,
},
catalogPlaceholder: {
marginBottom: 24,
paddingHorizontal: 16,
},
placeholderHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
},
placeholderTitle: {
width: 150,
height: 20,
borderRadius: 4,
},
placeholderPosters: {
flexDirection: 'row',
paddingVertical: 8,
gap: 8,
},
placeholderPoster: {
width: POSTER_WIDTH,
aspectRatio: 2/3,
borderRadius: 4,
marginRight: 2,
},
emptyCatalog: {
padding: 32,
alignItems: 'center',
margin: 16,
borderRadius: 16,
},
addCatalogButton: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 10,
borderRadius: 30,
marginTop: 16,
elevation: 3,
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 3,
},
addCatalogButtonText: {
fontSize: 14,
fontWeight: '600',
marginLeft: 8,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
featuredContainer: {
width: '100%',
height: height * 0.6,
marginTop: Platform.OS === 'ios' ? 0 : 0,
marginBottom: 8,
position: 'relative',
},
featuredBanner: {
width: '100%',
height: '100%',
},
featuredGradient: {
width: '100%',
height: '100%',
justifyContent: 'space-between',
}
});
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
const { currentTheme } = useTheme();
@ -130,6 +241,12 @@ const HomeScreen = () => {
handleSaveToLibrary,
refreshFeatured
} = useFeaturedContent();
const [selectedContent, setSelectedContent] = useState<StreamingContent | null>(null);
useEffect(() => {
if (!selectedContent && featuredContent) {
setSelectedContent(featuredContent);
}
}, [featuredContent, selectedContent]);
// Progressive catalog loading function
const loadCatalogsProgressively = useCallback(async () => {
@ -388,18 +505,11 @@ const HomeScreen = () => {
}
}
// Lock orientation to landscape before navigation to prevent glitches
try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
// Screen orientation locking is not supported on tvOS
// Orientation is handled automatically by the platform
// Longer delay to ensure orientation is fully set before navigation
await new Promise(resolve => setTimeout(resolve, 200));
} catch (orientationError) {
// If orientation lock fails, continue anyway but log it
logger.warn('[HomeScreen] Orientation lock failed:', orientationError);
// Still add a small delay
// Small delay for smooth navigation
await new Promise(resolve => setTimeout(resolve, 100));
}
navigation.navigate('Player', {
uri: stream.url,
@ -483,9 +593,7 @@ const HomeScreen = () => {
}
// Normal flow when addons are present
if (showHeroSection) {
data.push({ type: 'featured', key: 'featured' });
}
// Hero section will be rendered locked at the top outside the list
data.push({ type: 'thisWeek', key: 'thisWeek' });
data.push({ type: 'continueWatching', key: 'continueWatching' });
@ -500,19 +608,12 @@ const HomeScreen = () => {
});
return data;
}, [hasAddons, showHeroSection, catalogs]);
}, [hasAddons, catalogs]);
const renderListItem = useCallback(({ item }: { item: HomeScreenListItem }) => {
switch (item.type) {
case 'featured':
return (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
);
return null;
case 'thisWeek':
return <Animated.View entering={FadeIn.duration(300).delay(100)}><ThisWeekSection /></Animated.View>;
case 'continueWatching':
@ -520,7 +621,11 @@ const HomeScreen = () => {
case 'catalog':
return (
<Animated.View entering={FadeIn.duration(300)}>
<CatalogSection catalog={item.catalog} />
<CatalogSection
catalog={item.catalog}
onPosterPress={handlePosterPress}
onPosterFocus={(content) => setSelectedContent(content)}
/>
</Animated.View>
);
case 'placeholder':
@ -587,7 +692,11 @@ const HomeScreen = () => {
</>
), [catalogsLoading, catalogs, loadedCatalogCount, totalCatalogsRef.current, navigation, currentTheme.colors]);
// Memoize the main content section
// Handle poster press from catalogs to navigate to Metadata
const handlePosterPress = useCallback((content: StreamingContent) => {
navigation.navigate('Metadata', { id: content.id, type: content.type });
}, [navigation]);
const renderMainContent = useMemo(() => {
if (isLoading) return null;
@ -598,31 +707,25 @@ const HomeScreen = () => {
backgroundColor="transparent"
translucent
/>
<FlatList
{/* Locked hero section using selectedContent */}
{showHeroSection && (
<View>
<FeaturedContent
featuredContent={selectedContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
</View>
)}
<FlashList
data={listData}
renderItem={renderListItem}
keyExtractor={item => item.key}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
]}
contentContainerStyle={StyleSheet.flatten([styles.scrollContent, { paddingTop: 0, paddingHorizontal: 24 }])}
showsVerticalScrollIndicator={false}
ListFooterComponent={ListFooterComponent}
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={11}
removeClippedSubviews={Platform.OS === 'android'}
estimatedItemSize={280}
onEndReachedThreshold={0.5}
updateCellsBatchingPeriod={50}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
autoscrollToTopThreshold: 10
}}
getItemLayout={(data, index) => ({
length: index === 0 ? 400 : 280, // Approximate heights for different item types
offset: index === 0 ? 0 : 400 + (index - 1) * 280,
index,
})}
/>
</View>
);
@ -631,7 +734,11 @@ const HomeScreen = () => {
currentTheme.colors,
listData,
renderListItem,
ListFooterComponent
ListFooterComponent,
showHeroSection,
selectedContent,
isSaved,
handleSaveToLibrary
]);
return isLoading ? renderLoadingScreen : renderMainContent;
@ -641,8 +748,9 @@ const { width, height } = Dimensions.get('window');
// Dynamic poster calculation based on screen width - show 1/4 of next poster
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 100; // Reduced minimum for more posters
const MAX_POSTER_WIDTH = 130; // Reduced maximum for more posters
// TV gets larger posters
const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 100;
const MAX_POSTER_WIDTH = Platform.isTV ? 180 : 130;
const LEFT_PADDING = 16; // Left padding
const SPACING = 8; // Space between posters
@ -650,7 +758,7 @@ const calculatePosterLayout = (screenWidth: number) => {
const availableWidth = screenWidth - LEFT_PADDING;
// Try different numbers of full posters to find the best fit
let bestLayout = { numFullPosters: 3, posterWidth: 120 };
let bestLayout = { numFullPosters: 3, posterWidth: Platform.isTV ? 160 : 120 };
for (let n = 3; n <= 6; n++) {
// Calculate poster width needed for N full posters + 0.25 partial poster
@ -676,7 +784,14 @@ const calculatePosterLayout = (screenWidth: number) => {
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
const styles = StyleSheet.create<any>({
const styles = StyleSheet.create<any>({
stickyHeroContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
},
container: {
flex: 1,
},
@ -1131,4 +1246,4 @@ const styles = StyleSheet.create<any>({
},
});
export default React.memo(HomeScreen);
export default React.memo(HomeScreen);

View file

@ -13,12 +13,13 @@ import {
ActivityIndicator,
Platform,
ScrollView,
TVEventHandler,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
// Removed react-native-reanimated import
import { LinearGradient } from 'expo-linear-gradient';
import { catalogService } from '../services/catalogService';
import type { StreamingContent } from '../services/catalogService';
@ -203,7 +204,7 @@ const SkeletonLoader = () => {
const LibraryScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const isDarkMode = useColorScheme() === 'dark';
const { width } = useWindowDimensions();
const { width, height } = useWindowDimensions();
const [loading, setLoading] = useState(true);
const [libraryItems, setLibraryItems] = useState<LibraryItem[]>([]);
const [filter, setFilter] = useState<'all' | 'movies' | 'series'>('all');
@ -212,6 +213,22 @@ const LibraryScreen = () => {
const insets = useSafeAreaInsets();
const { currentTheme } = useTheme();
// TV-optimized grid calculations
const isTV = Platform.isTV || width > 1200;
const getNumColumns = () => {
if (isTV) {
if (width >= 1920) return 6; // 4K TVs
if (width >= 1280) return 5; // HD TVs
return 4; // Smaller TVs
}
return 2; // Mobile/tablet
};
const numColumns = getNumColumns();
const itemSpacing = isTV ? 24 : 16;
const containerPadding = isTV ? 48 : 12;
const itemWidth = (width - (containerPadding * 2) - (itemSpacing * (numColumns - 1))) / numColumns;
// Trakt integration
const {
isAuthenticated: traktAuthenticated,
@ -270,6 +287,51 @@ const LibraryScreen = () => {
};
}, []);
// TV Event Handler for remote control navigation
useEffect(() => {
if (!isTV) return;
const handleTVEvent = (evt: any) => {
if (evt && evt.eventType === 'focus') {
// Handle focus events for TV navigation
console.log('TV Focus Event:', evt);
} else if (evt && evt.eventType === 'blur') {
// Handle blur events
console.log('TV Blur Event:', evt);
} else if (evt && evt.eventType === 'select') {
// Handle select/enter button press
console.log('TV Select Event:', evt);
} else if (evt && evt.eventType === 'longSelect') {
// Handle long press on select button
console.log('TV Long Select Event:', evt);
} else if (evt && evt.eventType === 'left') {
// Handle left arrow navigation
console.log('TV Left Event:', evt);
} else if (evt && evt.eventType === 'right') {
// Handle right arrow navigation
console.log('TV Right Event:', evt);
} else if (evt && evt.eventType === 'up') {
// Handle up arrow navigation
console.log('TV Up Event:', evt);
} else if (evt && evt.eventType === 'down') {
// Handle down arrow navigation
console.log('TV Down Event:', evt);
} else if (evt && evt.eventType === 'playPause') {
// Handle play/pause button
console.log('TV Play/Pause Event:', evt);
} else if (evt && evt.eventType === 'menu') {
// Handle menu button - could show filters or options
console.log('TV Menu Event:', evt);
}
};
const subscription = TVEventHandler.addListener(handleTVEvent);
return () => {
subscription?.remove();
};
}, [isTV]);
const filteredItems = libraryItems.filter(item => {
if (filter === 'all') return true;
if (filter === 'movies') return item.type === 'movie';
@ -328,15 +390,37 @@ const LibraryScreen = () => {
return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const itemWidth = (width - 48) / 2; // 2 items per row with padding
// Use the TV-optimized itemWidth from above instead of hardcoded calculation
const renderItem = ({ item }: { item: LibraryItem }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
style={[
styles.itemContainer,
{
width: itemWidth,
marginHorizontal: itemSpacing / 2,
}
]}
onPress={() => navigation.navigate('Metadata', { id: item.id, type: item.type })}
activeOpacity={0.7}
// TV optimizations
hasTVPreferredFocus={false}
tvParallaxProperties={{
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.1,
}}
>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<View style={[
styles.posterContainer,
{
shadowColor: currentTheme.colors.black,
// TV-optimized dimensions
height: itemWidth * 1.5, // 2:3 aspect ratio
}
]}>
<Image
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
@ -348,13 +432,24 @@ const LibraryScreen = () => {
style={styles.posterGradient}
>
<Text
style={[styles.itemTitle, { color: currentTheme.colors.white }]}
style={[
styles.itemTitle,
{
color: currentTheme.colors.white,
fontSize: width > 1920 ? 18 : width > 1280 ? 16 : 15, // Responsive font size
}
]}
numberOfLines={2}
>
{item.name}
</Text>
{item.lastWatched && (
<Text style={styles.lastWatched}>
<Text style={[
styles.lastWatched,
{
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
}
]}>
{item.lastWatched}
</Text>
)}
@ -365,7 +460,11 @@ const LibraryScreen = () => {
<View
style={[
styles.progressBar,
{ width: `${item.progress * 100}%`, backgroundColor: currentTheme.colors.primary }
{
width: `${item.progress * 100}%`,
backgroundColor: currentTheme.colors.primary,
height: width > 1920 ? 6 : 4, // Larger progress bar for TV
}
]}
/>
</View>
@ -374,11 +473,17 @@ const LibraryScreen = () => {
<View style={styles.badgeContainer}>
<MaterialIcons
name="live-tv"
size={14}
size={width > 1920 ? 18 : width > 1280 ? 16 : 14} // Responsive icon size
color={currentTheme.colors.white}
style={{ marginRight: 4 }}
/>
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>Series</Text>
<Text style={[
styles.badgeText,
{
color: currentTheme.colors.white,
fontSize: width > 1920 ? 12 : width > 1280 ? 11 : 10, // Responsive font size
}
]}>Series</Text>
</View>
)}
</View>
@ -388,31 +493,69 @@ const LibraryScreen = () => {
// Render individual Trakt collection folder
const renderTraktCollectionFolder = ({ folder }: { folder: TraktFolder }) => (
<TouchableOpacity
style={[styles.itemContainer, { width: itemWidth }]}
style={[
styles.itemContainer,
{
width: itemWidth,
marginHorizontal: itemSpacing / 2,
}
]}
onPress={() => {
setSelectedTraktFolder(folder.id);
loadAllCollections(); // Load all collections when entering a specific folder
}}
activeOpacity={0.7}
// TV optimizations
hasTVPreferredFocus={false}
tvParallaxProperties={{
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.1,
}}
>
<View style={[styles.posterContainer, styles.folderContainer, { shadowColor: currentTheme.colors.black }]}>
<View style={[
styles.posterContainer,
styles.folderContainer,
{
shadowColor: currentTheme.colors.black,
height: itemWidth * 1.5, // 2:3 aspect ratio
}
]}>
<LinearGradient
colors={folder.gradient}
style={styles.folderGradient}
>
<MaterialIcons
name={folder.icon}
size={60}
size={width > 1920 ? 80 : width > 1280 ? 70 : 60} // Responsive icon size
color={currentTheme.colors.white}
style={{ marginBottom: 12 }}
style={{ marginBottom: width > 1920 ? 16 : 12 }}
/>
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
<Text style={[
styles.folderTitle,
{
color: currentTheme.colors.white,
fontSize: width > 1920 ? 22 : width > 1280 ? 20 : 18, // Responsive font size
}
]}>
{folder.name}
</Text>
<Text style={styles.folderCount}>
<Text style={[
styles.folderCount,
{
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
}
]}>
{folder.itemCount} items
</Text>
<Text style={styles.folderSubtitle}>
<Text style={[
styles.folderSubtitle,
{
fontSize: width > 1920 ? 14 : width > 1280 ? 13 : 12, // Responsive font size
}
]}>
{folder.description}
</Text>
</LinearGradient>
@ -859,14 +1002,28 @@ const LibraryScreen = () => {
return renderItem({ item: item as LibraryItem });
}}
keyExtractor={item => item.id}
numColumns={2}
contentContainerStyle={styles.listContainer}
numColumns={numColumns}
contentContainerStyle={[
styles.listContainer,
{
paddingHorizontal: containerPadding,
paddingVertical: width > 1920 ? 24 : width > 1280 ? 20 : 16,
paddingBottom: width > 1920 ? 120 : 90,
}
]}
showsVerticalScrollIndicator={false}
columnWrapperStyle={styles.columnWrapper}
initialNumToRender={6}
maxToRenderPerBatch={6}
columnWrapperStyle={numColumns > 1 ? [
styles.columnWrapper,
{
marginBottom: width > 1920 ? 24 : width > 1280 ? 20 : 16,
}
] : undefined}
initialNumToRender={numColumns * 3}
maxToRenderPerBatch={numColumns * 2}
windowSize={5}
removeClippedSubviews={Platform.OS === 'android'}
// TV optimizations
getItemLayout={undefined} // Let FlatList calculate for TV focus
/>
);
};
@ -1017,7 +1174,7 @@ const styles = StyleSheet.create({
paddingBottom: 90,
},
columnWrapper: {
justifyContent: 'space-between',
justifyContent: 'flex-start',
marginBottom: 16,
},
skeletonContainer: {
@ -1248,4 +1405,4 @@ const styles = StyleSheet.create({
},
});
export default LibraryScreen;
export default LibraryScreen;

View file

@ -7,6 +7,7 @@ import {
ActivityIndicator,
Dimensions,
TouchableOpacity,
ScrollView,
} from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRoute, useNavigation } from '@react-navigation/native';
@ -21,13 +22,6 @@ import { MovieContent } from '../components/metadata/MovieContent';
import { MoreLikeThisSection } from '../components/metadata/MoreLikeThisSection';
import { RatingsSection } from '../components/metadata/RatingsSection';
import { RouteParams, Episode } from '../types/metadata';
import Animated, {
useAnimatedStyle,
interpolate,
Extrapolate,
useSharedValue,
withTiming,
} from 'react-native-reanimated';
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
@ -38,7 +32,6 @@ import { MetadataLoadingScreen } from '../components/loading/MetadataLoadingScre
import HeroSection from '../components/metadata/HeroSection';
import FloatingHeader from '../components/metadata/FloatingHeader';
import MetadataDetails from '../components/metadata/MetadataDetails';
import { useMetadataAnimations } from '../hooks/useMetadataAnimations';
import { useMetadataAssets } from '../hooks/useMetadataAssets';
import { useWatchProgress } from '../hooks/useWatchProgress';
import { TraktService, TraktPlaybackItem } from '../services/traktService';
@ -59,7 +52,7 @@ const MetadataScreen: React.FC = () => {
const [isContentReady, setIsContentReady] = useState(false);
const [showCastModal, setShowCastModal] = useState(false);
const [selectedCastMember, setSelectedCastMember] = useState<any>(null);
const transitionOpacity = useSharedValue(1);
// Removed animation state
const {
metadata,
@ -84,7 +77,6 @@ const MetadataScreen: React.FC = () => {
// Optimized hooks with memoization
const watchProgressData = useWatchProgress(id, type as 'movie' | 'series', episodeId, episodes);
const assetData = useMetadataAssets(metadata, id, type, imdbId, settings, setMetadata);
const animations = useMetadataAnimations(safeAreaTop, watchProgressData.watchProgress);
// Fetch and log Trakt progress data when entering the screen
useEffect(() => {
@ -192,11 +184,10 @@ const MetadataScreen: React.FC = () => {
useEffect(() => {
if (isReady) {
setIsContentReady(true);
transitionOpacity.value = withTiming(1, { duration: 50 });
// Removed animation logic
} else if (!isReady && isContentReady) {
setIsContentReady(false);
transitionOpacity.value = 0;
}
setIsContentReady(false);
}
}, [isReady, isContentReady]);
// Optimized callback functions with reduced dependencies
@ -318,19 +309,7 @@ const MetadataScreen: React.FC = () => {
setShowCastModal(true);
}, []);
// Ultra-optimized animated styles - minimal calculations
const containerStyle = useAnimatedStyle(() => ({
opacity: animations.screenOpacity.value,
}), []);
const contentStyle = useAnimatedStyle(() => ({
opacity: animations.contentOpacity.value,
transform: [{ translateY: animations.uiElementsTranslateY.value }]
}), []);
const transitionStyle = useAnimatedStyle(() => ({
opacity: transitionOpacity.value,
}), []);
// Removed animated styles
// Memoized error component for performance
const ErrorComponent = useMemo(() => {
@ -377,32 +356,16 @@ const MetadataScreen: React.FC = () => {
return (
<SafeAreaView
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
edges={['bottom']}
>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
{metadata && (
<>
{/* Floating Header - Optimized */}
<FloatingHeader
metadata={metadata}
logoLoadError={assetData.logoLoadError}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={assetData.setLogoLoadError}
/>
<Animated.ScrollView
<ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
bounces={false}
overScrollMode="never"
contentContainerStyle={styles.scrollContent}
@ -413,14 +376,6 @@ const MetadataScreen: React.FC = () => {
bannerImage={assetData.bannerImage}
loadingBanner={assetData.loadingBanner}
logoLoadError={assetData.logoLoadError}
scrollY={animations.scrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
logoOpacity={animations.logoOpacity}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgressData.watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={watchProgressData.getEpisodeDetails}
@ -436,7 +391,7 @@ const MetadataScreen: React.FC = () => {
/>
{/* Main Content - Optimized */}
<Animated.View style={contentStyle}>
<View>
<MetadataDetails
metadata={metadata}
imdbId={imdbId}
@ -475,8 +430,8 @@ const MetadataScreen: React.FC = () => {
) : (
metadata && <MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</View>
</ScrollView>
</>
)}

View file

@ -1,580 +0,0 @@
import React, { useState, useEffect } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
Switch,
TouchableOpacity,
Alert,
SafeAreaView,
StatusBar,
Platform,
} from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../contexts/ThemeContext';
import { notificationService, NotificationSettings } from '../services/notificationService';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
import { logger } from '../utils/logger';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
const NotificationSettingsScreen = () => {
const navigation = useNavigation();
const { currentTheme } = useTheme();
const [settings, setSettings] = useState<NotificationSettings>({
enabled: true,
newEpisodeNotifications: true,
reminderNotifications: true,
upcomingShowsNotifications: true,
timeBeforeAiring: 24,
});
const [loading, setLoading] = useState(true);
const [countdown, setCountdown] = useState<number | null>(null);
const [testNotificationId, setTestNotificationId] = useState<string | null>(null);
const [isSyncing, setIsSyncing] = useState(false);
const [notificationStats, setNotificationStats] = useState({ total: 0, upcoming: 0, thisWeek: 0 });
// Load settings and stats on mount
useEffect(() => {
const loadSettings = async () => {
try {
const savedSettings = await notificationService.getSettings();
setSettings(savedSettings);
// Load notification stats
const stats = notificationService.getNotificationStats();
setNotificationStats(stats);
} catch (error) {
logger.error('Error loading notification settings:', error);
} finally {
setLoading(false);
}
};
loadSettings();
}, []);
// Refresh stats when settings change
useEffect(() => {
if (!loading) {
const stats = notificationService.getNotificationStats();
setNotificationStats(stats);
}
}, [settings, loading]);
// Add countdown effect
useEffect(() => {
let intervalId: NodeJS.Timeout;
if (countdown !== null && countdown > 0) {
intervalId = setInterval(() => {
setCountdown(prev => prev !== null ? prev - 1 : null);
}, 1000);
} else if (countdown === 0) {
setCountdown(null);
setTestNotificationId(null);
}
return () => {
if (intervalId) {
clearInterval(intervalId);
}
};
}, [countdown]);
// Update a setting
const updateSetting = async (key: keyof NotificationSettings, value: boolean | number) => {
try {
const updatedSettings = {
...settings,
[key]: value,
};
// Special case: if enabling notifications, make sure permissions are granted
if (key === 'enabled' && value === true) {
// Permissions are handled in the service
}
// Update settings in the service
await notificationService.updateSettings({ [key]: value });
// Update local state
setSettings(updatedSettings);
} catch (error) {
logger.error('Error updating notification settings:', error);
Alert.alert('Error', 'Failed to update notification settings');
}
};
// Set time before airing
const setTimeBeforeAiring = (hours: number) => {
updateSetting('timeBeforeAiring', hours);
};
const resetAllNotifications = async () => {
Alert.alert(
'Reset Notifications',
'This will cancel all scheduled notifications. Are you sure?',
[
{
text: 'Cancel',
style: 'cancel',
},
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
try {
await notificationService.cancelAllNotifications();
Alert.alert('Success', 'All notifications have been reset');
} catch (error) {
logger.error('Error resetting notifications:', error);
Alert.alert('Error', 'Failed to reset notifications');
}
},
},
]
);
};
const handleSyncNotifications = async () => {
if (isSyncing) return;
setIsSyncing(true);
try {
await notificationService.syncAllNotifications();
// Refresh stats after sync
const stats = notificationService.getNotificationStats();
setNotificationStats(stats);
Alert.alert(
'Sync Complete',
`Successfully synced notifications for your library and Trakt items.\n\nScheduled: ${stats.upcoming} upcoming episodes\nThis week: ${stats.thisWeek} episodes`
);
} catch (error) {
logger.error('Error syncing notifications:', error);
Alert.alert('Error', 'Failed to sync notifications. Please try again.');
} finally {
setIsSyncing(false);
}
};
const handleTestNotification = async () => {
try {
// Cancel previous test notification if exists
if (testNotificationId) {
await notificationService.cancelNotification(testNotificationId);
}
const testNotification = {
id: 'test-notification-' + Date.now(),
seriesId: 'test-series',
seriesName: 'Test Show',
episodeTitle: 'Test Episode',
season: 1,
episode: 1,
releaseDate: new Date(Date.now() + 60000).toISOString(), // 1 minute from now
notified: false
};
const notificationId = await notificationService.scheduleEpisodeNotification(testNotification);
if (notificationId) {
setTestNotificationId(notificationId);
setCountdown(60); // Start 60 second countdown
Alert.alert('Success', 'Test notification scheduled for 1 minute from now');
} else {
Alert.alert('Error', 'Failed to schedule test notification. Make sure notifications are enabled.');
}
} catch (error) {
logger.error('Error scheduling test notification:', error);
Alert.alert('Error', 'Failed to schedule test notification');
}
};
if (loading) {
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Notification Settings</Text>
<View style={{ width: 40 }} />
</View>
<View style={styles.loadingContainer}>
<Text style={[styles.loadingText, { color: currentTheme.colors.text }]}>Loading settings...</Text>
</View>
</SafeAreaView>
);
}
return (
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
<StatusBar barStyle="light-content" />
<View style={[styles.header, { borderBottomColor: currentTheme.colors.border }]}>
<TouchableOpacity
style={styles.backButton}
onPress={() => navigation.goBack()}
>
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>Notification Settings</Text>
<View style={{ width: 40 }} />
</View>
<ScrollView style={styles.content}>
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>General</Text>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}>
<MaterialIcons name="notifications" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Enable Notifications</Text>
</View>
<Switch
value={settings.enabled}
onValueChange={(value) => updateSetting('enabled', value)}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={settings.enabled ? currentTheme.colors.primary : currentTheme.colors.lightGray}
/>
</View>
</View>
{settings.enabled && (
<>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Types</Text>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}>
<MaterialIcons name="new-releases" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>New Episodes</Text>
</View>
<Switch
value={settings.newEpisodeNotifications}
onValueChange={(value) => updateSetting('newEpisodeNotifications', value)}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={settings.newEpisodeNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray}
/>
</View>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}>
<MaterialIcons name="event" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Upcoming Shows</Text>
</View>
<Switch
value={settings.upcomingShowsNotifications}
onValueChange={(value) => updateSetting('upcomingShowsNotifications', value)}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={settings.upcomingShowsNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray}
/>
</View>
<View style={[styles.settingItem, { borderBottomColor: currentTheme.colors.border + '50' }]}>
<View style={styles.settingInfo}>
<MaterialIcons name="alarm" size={24} color={currentTheme.colors.text} />
<Text style={[styles.settingText, { color: currentTheme.colors.text }]}>Reminders</Text>
</View>
<Switch
value={settings.reminderNotifications}
onValueChange={(value) => updateSetting('reminderNotifications', value)}
trackColor={{ false: currentTheme.colors.border, true: currentTheme.colors.primary + '80' }}
thumbColor={settings.reminderNotifications ? currentTheme.colors.primary : currentTheme.colors.lightGray}
/>
</View>
</View>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Timing</Text>
<Text style={[styles.settingDescription, { color: currentTheme.colors.lightGray }]}>
When should you be notified before an episode airs?
</Text>
<View style={styles.timingOptions}>
{[1, 6, 12, 24].map((hours) => (
<TouchableOpacity
key={hours}
style={[
styles.timingOption,
{
backgroundColor: currentTheme.colors.elevation1,
borderColor: currentTheme.colors.border
},
settings.timeBeforeAiring === hours && {
backgroundColor: currentTheme.colors.primary + '30',
borderColor: currentTheme.colors.primary,
}
]}
onPress={() => setTimeBeforeAiring(hours)}
>
<Text style={[
styles.timingText,
{ color: currentTheme.colors.text },
settings.timeBeforeAiring === hours && {
color: currentTheme.colors.primary,
fontWeight: 'bold',
}
]}>
{hours === 1 ? '1 hour' : `${hours} hours`}
</Text>
</TouchableOpacity>
))}
</View>
</View>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Notification Status</Text>
<View style={[styles.statsContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<View style={styles.statItem}>
<MaterialIcons name="schedule" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>Upcoming</Text>
<Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.upcoming}</Text>
</View>
<View style={styles.statItem}>
<MaterialIcons name="today" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>This Week</Text>
<Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.thisWeek}</Text>
</View>
<View style={styles.statItem}>
<MaterialIcons name="notifications-active" size={20} color={currentTheme.colors.primary} />
<Text style={[styles.statLabel, { color: currentTheme.colors.textMuted }]}>Total</Text>
<Text style={[styles.statValue, { color: currentTheme.colors.text }]}>{notificationStats.total}</Text>
</View>
</View>
<TouchableOpacity
style={[
styles.resetButton,
{
backgroundColor: currentTheme.colors.primary + '20',
borderColor: currentTheme.colors.primary + '50'
}
]}
onPress={handleSyncNotifications}
disabled={isSyncing}
>
<MaterialIcons
name={isSyncing ? "sync" : "sync"}
size={24}
color={currentTheme.colors.primary}
style={isSyncing ? { transform: [{ rotate: '360deg' }] } : {}}
/>
<Text style={[styles.resetButtonText, { color: currentTheme.colors.primary }]}>
{isSyncing ? 'Syncing...' : 'Sync Library & Trakt'}
</Text>
</TouchableOpacity>
<Text style={[styles.resetDescription, { color: currentTheme.colors.lightGray }]}>
Automatically syncs notifications for all shows in your library and Trakt watchlist/collection.
</Text>
</View>
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Advanced</Text>
<TouchableOpacity
style={[
styles.resetButton,
{
backgroundColor: currentTheme.colors.error + '20',
borderColor: currentTheme.colors.error + '50'
}
]}
onPress={resetAllNotifications}
>
<MaterialIcons name="refresh" size={24} color={currentTheme.colors.error} />
<Text style={[styles.resetButtonText, { color: currentTheme.colors.error }]}>Reset All Notifications</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.resetButton,
{
marginTop: 12,
backgroundColor: currentTheme.colors.primary + '20',
borderColor: currentTheme.colors.primary + '50'
}
]}
onPress={handleTestNotification}
disabled={countdown !== null}
>
<MaterialIcons name="bug-report" size={24} color={currentTheme.colors.primary} />
<Text style={[styles.resetButtonText, { color: currentTheme.colors.primary }]}>
{countdown !== null
? `Notification in ${countdown}s...`
: 'Test Notification (1min)'}
</Text>
</TouchableOpacity>
{countdown !== null && (
<View style={styles.countdownContainer}>
<MaterialIcons
name="timer"
size={16}
color={currentTheme.colors.primary}
style={styles.countdownIcon}
/>
<Text style={[styles.countdownText, { color: currentTheme.colors.primary }]}>
Notification will appear in {countdown} seconds
</Text>
</View>
)}
<Text style={[styles.resetDescription, { color: currentTheme.colors.lightGray }]}>
This will cancel all scheduled notifications. You'll need to re-enable them manually.
</Text>
</View>
</>
)}
</Animated.View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 12,
borderBottomWidth: 1,
},
backButton: {
padding: 8,
},
headerTitle: {
fontSize: 18,
fontWeight: 'bold',
},
content: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
loadingText: {
fontSize: 16,
},
section: {
padding: 16,
borderBottomWidth: 1,
},
sectionTitle: {
fontSize: 16,
fontWeight: 'bold',
marginBottom: 16,
},
settingItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 12,
borderBottomWidth: 1,
},
settingInfo: {
flexDirection: 'row',
alignItems: 'center',
},
settingText: {
fontSize: 16,
marginLeft: 12,
},
settingDescription: {
fontSize: 14,
marginBottom: 16,
},
timingOptions: {
flexDirection: 'row',
justifyContent: 'space-between',
flexWrap: 'wrap',
marginTop: 8,
},
timingOption: {
paddingVertical: 10,
paddingHorizontal: 16,
borderRadius: 8,
borderWidth: 1,
marginBottom: 8,
width: '48%',
alignItems: 'center',
},
timingText: {
fontSize: 14,
},
resetButton: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderRadius: 8,
borderWidth: 1,
marginBottom: 8,
},
resetButtonText: {
fontSize: 16,
fontWeight: 'bold',
marginLeft: 8,
},
resetDescription: {
fontSize: 12,
fontStyle: 'italic',
},
countdownContainer: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
padding: 8,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
borderRadius: 4,
},
countdownIcon: {
marginRight: 8,
},
countdownText: {
fontSize: 14,
},
statsContainer: {
flexDirection: 'row',
justifyContent: 'space-around',
padding: 16,
borderRadius: 8,
marginBottom: 16,
},
statItem: {
alignItems: 'center',
flex: 1,
},
statLabel: {
fontSize: 12,
marginTop: 4,
textAlign: 'center',
},
statValue: {
fontSize: 18,
fontWeight: 'bold',
marginTop: 2,
},
});
export default NotificationSettingsScreen;

View file

@ -13,14 +13,7 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { MaterialIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
FadeInDown,
FadeInUp,
} from 'react-native-reanimated';
// Removed react-native-reanimated imports
import { useTheme } from '../contexts/ThemeContext';
import { NavigationProp, useNavigation } from '@react-navigation/native';
import { RootStackParamList } from '../navigation/AppNavigator';
@ -77,18 +70,14 @@ const OnboardingScreen = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const [currentIndex, setCurrentIndex] = useState(0);
const flatListRef = useRef<FlatList>(null);
const progressValue = useSharedValue(0);
const animatedProgressStyle = useAnimatedStyle(() => ({
width: withSpring(`${((currentIndex + 1) / onboardingData.length) * 100}%`),
}));
// Removed animated progress values
const handleNext = () => {
if (currentIndex < onboardingData.length - 1) {
const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex);
flatListRef.current?.scrollToIndex({ index: nextIndex, animated: true });
progressValue.value = (nextIndex + 1) / onboardingData.length;
// Removed progress animation
} else {
handleGetStarted();
}
@ -125,22 +114,16 @@ const OnboardingScreen = () => {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Animated.View
entering={FadeInDown.delay(300).duration(800)}
style={styles.iconWrapper}
>
<View style={styles.iconWrapper}>
<MaterialIcons
name={item.icon}
size={80}
color="white"
/>
</Animated.View>
</View>
</LinearGradient>
<Animated.View
entering={FadeInUp.delay(500).duration(800)}
style={styles.textContainer}
>
<View style={styles.textContainer}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>
{item.title}
</Text>
@ -150,7 +133,7 @@ const OnboardingScreen = () => {
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
{item.description}
</Text>
</Animated.View>
</View>
</View>
);
};
@ -188,11 +171,10 @@ const OnboardingScreen = () => {
{/* Progress Bar */}
<View style={[styles.progressContainer, { backgroundColor: currentTheme.colors.elevation1 }]}>
<Animated.View
<View
style={[
styles.progressBar,
{ backgroundColor: currentTheme.colors.primary },
animatedProgressStyle
{ backgroundColor: currentTheme.colors.primary, width: `${((currentIndex + 1) / onboardingData.length) * 100}%` }
]}
/>
</View>
@ -370,4 +352,4 @@ const styles = StyleSheet.create({
},
});
export default OnboardingScreen;
export default OnboardingScreen;

View file

@ -19,7 +19,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { Picker } from '@react-native-picker/picker';
import { useSettings, DEFAULT_SETTINGS } from '../hooks/useSettings';
import { RootStackParamList } from '../navigation/AppNavigator';
import { stremioService } from '../services/stremioService';
@ -408,14 +408,7 @@ const SettingsScreen: React.FC = () => {
renderControl={ChevronRight}
onPress={() => navigation.navigate('PlayerSettings')}
/>
<SettingItem
title="Notifications"
description="Episode reminders"
icon="notifications-none"
renderControl={ChevronRight}
onPress={() => navigation.navigate('NotificationSettings')}
isLast={true}
/>
</SettingsCard>
{/* About & Support */}

View file

@ -15,9 +15,10 @@ import {
Dimensions,
Linking,
Clipboard,
TVEventHandler,
} from 'react-native';
import * as ScreenOrientation from 'expo-screen-orientation';
import { useRoute, useNavigation } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@ -74,6 +75,8 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
showLogos?: boolean;
}) => {
// Handle long press to copy stream URL to clipboard
const handleLongPress = useCallback(async () => {
if (stream.url) {
@ -94,7 +97,6 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
}
}
}, [stream.url]);
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
const streamInfo = useMemo(() => {
const title = stream.title || '';
@ -170,77 +172,306 @@ const StreamCard = memo(({ stream, onPress, index, isLoading, statusMessage, the
};
}, [stream.addonId, stream.addon]);
const isTV = Platform.isTV;
// Horizontal TV-optimized card design
const cardStyle = {
flexDirection: 'row' as const,
alignItems: 'stretch' as const,
backgroundColor: isTV ? '#1a1a1a' : theme.colors.card,
borderRadius: isTV ? 24 : 12,
marginHorizontal: isTV ? 12 : 8,
marginVertical: isTV ? 16 : 8,
minHeight: isTV ? 160 : 90,
overflow: 'hidden' as const,
borderWidth: isTV ? 3 : 1,
borderColor: isTV ? '#333333' : theme.colors.cardHighlight,
shadowColor: '#000000',
shadowOffset: { width: 0, height: isTV ? 16 : 6 },
shadowOpacity: isTV ? 0.7 : 0.25,
shadowRadius: isTV ? 24 : 10,
elevation: isTV ? 20 : 6,
// Force visibility on TV
opacity: 1,
zIndex: isTV ? 10 : 1,
maxWidth: isTV ? width - (24 * 2) - (12 * 2) : undefined,
};
return (
<TouchableOpacity
style={[
styles.streamCard,
isLoading && styles.streamCardLoading
cardStyle,
isLoading && { opacity: 0.75 }
]}
onPress={onPress}
onLongPress={handleLongPress}
disabled={isLoading}
activeOpacity={0.7}
activeOpacity={0.85}
hasTVPreferredFocus={index === 0 && isTV}
tvParallaxProperties={isTV ? {
enabled: true,
shiftDistanceX: 6.0,
shiftDistanceY: 6.0,
tiltAngle: 0.18,
magnification: 1.03,
pressMagnification: 0.97,
pressDuration: 0.12,
} : undefined}
>
{/* Scraper Logo */}
{showLogos && scraperLogo && (
<View style={styles.scraperLogoContainer}>
<Image
source={{ uri: scraperLogo }}
style={styles.scraperLogo}
resizeMode="contain"
/>
</View>
)}
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
{streamInfo.displayName}
</Text>
{streamInfo.subTitle && (
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
{streamInfo.subTitle}
</Text>
)}
{/* Left Section - Logo and Quality Indicators */}
<View style={{
width: isTV ? 140 : 90,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: isTV ? '#2a2a2a' : 'rgba(0, 0, 0, 0.3)',
paddingVertical: isTV ? 24 : 16,
paddingHorizontal: isTV ? 20 : 12,
borderTopLeftRadius: isTV ? 24 : 12,
borderBottomLeftRadius: isTV ? 24 : 12,
}}>
{/* Scraper Logo */}
{showLogos && scraperLogo ? (
<View style={{
width: isTV ? 72 : 48,
height: isTV ? 72 : 48,
marginBottom: isTV ? 16 : 10,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.12)',
borderRadius: isTV ? 20 : 12,
borderWidth: isTV ? 3 : 2,
borderColor: 'rgba(255, 255, 255, 0.25)',
}}>
<Image
source={{ uri: scraperLogo }}
style={{
width: isTV ? 56 : 36,
height: isTV ? 56 : 36,
}}
resizeMode="contain"
/>
</View>
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
</View>
) : (
<View style={{
width: isTV ? 72 : 48,
height: isTV ? 72 : 48,
marginBottom: isTV ? 16 : 10,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
borderRadius: isTV ? 20 : 12,
borderWidth: isTV ? 3 : 2,
borderColor: 'rgba(255, 255, 255, 0.15)',
}}>
<MaterialIcons
name="movie"
size={isTV ? 40 : 24}
color="rgba(255, 255, 255, 0.5)"
/>
</View>
)}
<View style={styles.streamMetaRow}>
{streamInfo.isDolby && (
<QualityBadge type="VISION" />
)}
{streamInfo.size && (
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
{/* Quality and HDR Badges */}
<View style={{
alignItems: 'center',
gap: isTV ? 8 : 4,
}}>
{streamInfo.quality && (
<View style={{
backgroundColor: theme.colors.primary,
paddingHorizontal: isTV ? 16 : 10,
paddingVertical: isTV ? 8 : 5,
borderRadius: isTV ? 12 : 8,
borderWidth: isTV ? 2 : 1,
borderColor: 'rgba(255, 255, 255, 0.3)',
}}>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 12,
fontWeight: '900',
textAlign: 'center',
textShadowColor: 'rgba(0,0,0,0.5)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
}}>{streamInfo.quality}p</Text>
</View>
)}
{streamInfo.isDebrid && (
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
{streamInfo.isDolby && (
<View style={{
backgroundColor: '#FF6B35',
paddingHorizontal: isTV ? 12 : 8,
paddingVertical: isTV ? 6 : 4,
borderRadius: isTV ? 10 : 6,
borderWidth: isTV ? 2 : 1,
borderColor: '#FF8C69',
}}>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 14 : 10,
fontWeight: '800',
textAlign: 'center',
}}>HDR</Text>
</View>
)}
</View>
</View>
<View style={styles.streamAction}>
<MaterialIcons
name="play-arrow"
size={24}
color={theme.colors.primary}
/>
{/* Center Section - Stream Information */}
<View style={{
flex: 1,
paddingVertical: isTV ? 28 : 20,
paddingHorizontal: isTV ? 24 : 16,
justifyContent: 'space-between',
}}>
{/* Title Section */}
<View style={{ flex: 1, justifyContent: 'center' }}>
<Text style={{
fontSize: isTV ? 28 : 18,
fontWeight: '900',
color: isTV ? '#FFFFFF' : theme.colors.highEmphasis,
lineHeight: isTV ? 36 : 24,
marginBottom: isTV ? 12 : 6,
textShadowColor: isTV ? 'rgba(0,0,0,0.9)' : 'transparent',
textShadowOffset: isTV ? { width: 2, height: 2 } : { width: 0, height: 0 },
textShadowRadius: isTV ? 4 : 0,
}} numberOfLines={isTV ? 2 : 1}>
{streamInfo.displayName}
</Text>
{streamInfo.subTitle && (
<Text style={{
fontSize: isTV ? 20 : 15,
color: isTV ? 'rgba(255, 255, 255, 0.85)' : theme.colors.mediumEmphasis,
lineHeight: isTV ? 28 : 22,
fontWeight: isTV ? '700' : '600',
marginBottom: isTV ? 16 : 8,
textShadowColor: isTV ? 'rgba(0,0,0,0.7)' : 'transparent',
textShadowOffset: isTV ? { width: 1, height: 1 } : { width: 0, height: 0 },
textShadowRadius: isTV ? 2 : 0,
}} numberOfLines={isTV ? 2 : 1}>
{streamInfo.subTitle}
</Text>
)}
</View>
{/* Bottom Section - Size and Debrid */}
<View style={{
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: isTV ? 16 : 8,
}}>
{streamInfo.size && (
<View style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.15)',
paddingHorizontal: isTV ? 16 : 12,
paddingVertical: isTV ? 10 : 6,
borderRadius: isTV ? 12 : 8,
borderWidth: isTV ? 2 : 1,
borderColor: 'rgba(255, 255, 255, 0.25)',
}}>
<MaterialIcons
name="storage"
size={isTV ? 20 : 14}
color="rgba(255, 255, 255, 0.9)"
style={{ marginRight: isTV ? 8 : 6 }}
/>
<Text style={{
color: 'rgba(255, 255, 255, 0.95)',
fontSize: isTV ? 16 : 13,
fontWeight: '700',
}}>{streamInfo.size}</Text>
</View>
)}
{streamInfo.isDebrid && (
<View style={{
backgroundColor: '#00C851',
paddingHorizontal: isTV ? 16 : 12,
paddingVertical: isTV ? 10 : 6,
borderRadius: isTV ? 12 : 8,
borderWidth: isTV ? 2 : 1,
borderColor: '#00E676',
shadowColor: '#00C851',
shadowOffset: { width: 0, height: isTV ? 4 : 2 },
shadowOpacity: isTV ? 0.6 : 0.3,
shadowRadius: isTV ? 8 : 4,
elevation: isTV ? 8 : 4,
}}>
<Text style={{
color: '#FFFFFF',
fontSize: isTV ? 16 : 13,
fontWeight: '800',
textShadowColor: 'rgba(0,0,0,0.3)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 2,
}}>PREMIUM</Text>
</View>
)}
</View>
</View>
{/* Right Section - Play Button and Loading */}
<View style={{
width: isTV ? 120 : 80,
justifyContent: 'center',
alignItems: 'center',
paddingVertical: isTV ? 24 : 16,
paddingHorizontal: isTV ? 20 : 12,
backgroundColor: isTV ? '#2a2a2a' : 'rgba(0, 0, 0, 0.1)',
borderTopRightRadius: isTV ? 24 : 12,
borderBottomRightRadius: isTV ? 24 : 12,
}}>
{isLoading ? (
<View style={{
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
borderRadius: isTV ? 24 : 16,
padding: isTV ? 20 : 12,
borderWidth: isTV ? 3 : 2,
borderColor: theme.colors.primary,
}}>
<ActivityIndicator size={isTV ? "large" : "small"} color={theme.colors.primary} />
<Text style={{
color: theme.colors.primary,
fontSize: isTV ? 14 : 11,
marginTop: isTV ? 12 : 6,
fontWeight: '700',
textAlign: 'center',
}} numberOfLines={1}>
{statusMessage || "Loading"}
</Text>
</View>
) : (
<View style={{
width: isTV ? 96 : 60,
height: isTV ? 96 : 60,
borderRadius: isTV ? 48 : 30,
backgroundColor: theme.colors.primary,
justifyContent: 'center',
alignItems: 'center',
borderWidth: isTV ? 5 : 3,
borderColor: '#FFFFFF',
shadowColor: theme.colors.primary,
shadowOffset: { width: 0, height: isTV ? 12 : 6 },
shadowOpacity: isTV ? 0.8 : 0.4,
shadowRadius: isTV ? 16 : 8,
elevation: isTV ? 16 : 8,
}}>
<MaterialIcons
name="play-arrow"
size={isTV ? 56 : 36}
color="#FFFFFF"
style={{ marginLeft: isTV ? 6 : 3 }}
/>
</View>
)}
</View>
</TouchableOpacity>
);
@ -304,23 +535,37 @@ const ProviderFilter = memo(({
}) => {
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
<TouchableOpacity
key={item.id}
style={[
styles.filterChip,
selectedProvider === item.id && styles.filterChipSelected
]}
onPress={() => onSelect(item.id)}
>
<Text style={[
styles.filterChipText,
selectedProvider === item.id && styles.filterChipTextSelected
]}>
{item.name}
</Text>
</TouchableOpacity>
), [selectedProvider, onSelect, styles]);
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => {
const isTV = Platform.isTV;
return (
<TouchableOpacity
key={item.id}
style={[
styles.filterChip,
selectedProvider === item.id && styles.filterChipSelected
]}
onPress={() => onSelect(item.id)}
hasTVPreferredFocus={index === 0 && isTV}
tvParallaxProperties={isTV ? {
enabled: true,
shiftDistanceX: 2.0,
shiftDistanceY: 2.0,
tiltAngle: 0.05,
magnification: 1.05,
pressMagnification: 0.95,
pressDuration: 0.3,
} : undefined}
>
<Text style={[
styles.filterChipText,
selectedProvider === item.id && styles.filterChipTextSelected
]}>
{item.name}
</Text>
</TouchableOpacity>
);
}, [selectedProvider, onSelect, styles]);
return (
<View>
@ -334,10 +579,11 @@ const ProviderFilter = memo(({
bounces={true}
overScrollMode="never"
decelerationRate="fast"
initialNumToRender={5}
maxToRenderPerBatch={3}
windowSize={3}
getItemLayout={(data, index) => ({
initialNumToRender={Platform.isTV ? 8 : 5}
maxToRenderPerBatch={Platform.isTV ? 5 : 3}
windowSize={Platform.isTV ? 5 : 3}
removeClippedSubviews={!Platform.isTV}
getItemLayout={Platform.isTV ? undefined : (data, index) => ({
length: 100, // Approximate width of each item
offset: 100 * index,
index,
@ -887,16 +1133,8 @@ export const StreamsScreen = () => {
backdrop: bannerImage || undefined,
});
// Lock orientation to landscape after navigation has started
// This allows the player to open immediately while orientation is being set
try {
ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE)
.catch(error => {
logger.error('[StreamsScreen] Error locking orientation after navigation:', error);
});
} catch (error) {
logger.error('[StreamsScreen] Error locking orientation after navigation:', error);
}
// Screen orientation locking is not supported on tvOS
// Orientation is handled automatically by the platform
}, [metadata, type, currentEpisode, navigation, id, selectedEpisode, imdbId, episodeStreams, groupedStreams, bannerImage]);
@ -1163,6 +1401,14 @@ export const StreamsScreen = () => {
const sections = useMemo(() => {
const streams = type === 'series' ? episodeStreams : groupedStreams;
const installedAddons = stremioService.getInstalledAddons();
console.log('[StreamsScreen] Sections creation debug:');
console.log(' type:', type);
console.log(' episodeStreams:', episodeStreams);
console.log(' groupedStreams:', groupedStreams);
console.log(' streams (selected):', streams);
console.log(' selectedProvider:', selectedProvider);
console.log(' installedAddons:', installedAddons);
// Filter streams by selected provider
const filteredEntries = Object.entries(streams)
@ -1286,6 +1532,10 @@ export const StreamsScreen = () => {
const loadElapsed = streamsLoadStart ? Date.now() - streamsLoadStart : 0;
const showInitialLoading = streamsEmpty && (streamsLoadStart === null || loadElapsed < 10000);
const showStillFetching = streamsEmpty && loadElapsed >= 10000;
const heroStyle = useAnimatedStyle(() => ({
transform: [{ scale: heroScale.value }],
@ -1311,6 +1561,8 @@ export const StreamsScreen = () => {
// Don't show loading for individual streams that are already available and displayed
const isLoading = false; // If streams are being rendered, they're available and shouldn't be loading
return (
<StreamCard
key={`${stream.url}-${index}`}
@ -1329,13 +1581,50 @@ export const StreamsScreen = () => {
const isProviderLoading = loadingProviders[section.addonId];
return (
<View style={styles.sectionHeaderContainer}>
<View style={styles.sectionHeaderContent}>
<Text style={styles.streamGroupTitle}>{section.title}</Text>
<View style={{
padding: Platform.isTV ? 0 : 16,
paddingHorizontal: Platform.isTV ? 0 : 16,
paddingVertical: Platform.isTV ? 12 : 0,
backgroundColor: Platform.isTV ? 'rgba(0,0,0,0.6)' : 'transparent',
borderRadius: Platform.isTV ? 12 : 0,
marginBottom: Platform.isTV ? 8 : 0,
borderWidth: Platform.isTV ? 1 : 0,
borderColor: Platform.isTV ? '#333333' : 'transparent',
}}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<Text style={{
color: Platform.isTV ? '#FFFFFF' : colors.highEmphasis,
fontSize: Platform.isTV ? 20 : 15,
fontWeight: Platform.isTV ? '800' : '700',
marginBottom: Platform.isTV ? 0 : 8,
marginTop: 0,
backgroundColor: 'transparent',
textShadowColor: Platform.isTV ? 'rgba(0,0,0,0.8)' : 'transparent',
textShadowOffset: Platform.isTV ? { width: 1, height: 1 } : { width: 0, height: 0 },
textShadowRadius: Platform.isTV ? 2 : 0,
}}>{section.title}</Text>
{isProviderLoading && (
<View style={styles.sectionLoadingIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={[styles.sectionLoadingText, { color: colors.primary }]}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: Platform.isTV ? 'rgba(0,0,0,0.8)' : 'transparent',
paddingHorizontal: Platform.isTV ? 12 : 0,
paddingVertical: Platform.isTV ? 6 : 0,
borderRadius: Platform.isTV ? 8 : 0,
borderWidth: Platform.isTV ? 1 : 0,
borderColor: Platform.isTV ? colors.primary : 'transparent',
}}>
<ActivityIndicator size={Platform.isTV ? "large" : "small"} color={colors.primary} />
<Text style={{
marginLeft: Platform.isTV ? 12 : 8,
color: colors.primary,
fontSize: Platform.isTV ? 16 : 12,
fontWeight: Platform.isTV ? '700' : '500',
}}>
Loading...
</Text>
</View>
@ -1343,7 +1632,7 @@ export const StreamsScreen = () => {
</View>
</View>
);
}, [styles.streamGroupTitle, styles.sectionHeaderContainer, styles.sectionHeaderContent, styles.sectionLoadingIndicator, styles.sectionLoadingText, loadingProviders, colors.primary]);
}, [loadingProviders, colors.primary, colors.highEmphasis]);
// Cleanup on unmount
useEffect(() => {
@ -1557,15 +1846,12 @@ export const StreamsScreen = () => {
<View collapsable={false} style={{ flex: 1 }}>
{/* Show autoplay loading overlay if waiting for autoplay */}
{isAutoplayWaiting && !autoplayTriggered && (
<Animated.View
entering={FadeIn.duration(300)}
style={styles.autoplayOverlay}
>
<View style={styles.autoplayOverlay}>
<View style={styles.autoplayIndicator}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.autoplayText}>Starting best stream...</Text>
</View>
</Animated.View>
</View>
)}
<SectionList
@ -1574,20 +1860,48 @@ export const StreamsScreen = () => {
renderItem={renderItem}
renderSectionHeader={renderSectionHeader}
stickySectionHeadersEnabled={false}
initialNumToRender={6}
maxToRenderPerBatch={3}
windowSize={4}
initialNumToRender={Platform.isTV ? 6 : 6}
maxToRenderPerBatch={Platform.isTV ? 4 : 3}
windowSize={Platform.isTV ? 3 : 4}
removeClippedSubviews={false}
contentContainerStyle={styles.streamsContainer}
style={styles.streamsContent}
getItemLayout={undefined}
contentContainerStyle={{
paddingHorizontal: Platform.isTV ? 24 : 16,
paddingVertical: 0,
paddingBottom: 0,
width: '100%',
}}
style={{
flex: 1,
width: '100%',
zIndex: 2,
backgroundColor: 'transparent',
minHeight: Platform.isTV ? 400 : 'auto',
}}
showsVerticalScrollIndicator={false}
bounces={true}
overScrollMode="never"
ItemSeparatorComponent={() => Platform.isTV ? (
<View style={{ height: 12 }} />
) : null}
ListFooterComponent={
(loadingStreams || loadingEpisodeStreams) && hasStremioStreamProviders ? (
<View style={styles.footerLoading}>
<ActivityIndicator size="small" color={colors.primary} />
<Text style={styles.footerLoadingText}>Loading more sources...</Text>
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
padding: Platform.isTV ? 24 : 16,
backgroundColor: Platform.isTV ? 'rgba(0,0,0,0.8)' : 'transparent',
borderRadius: Platform.isTV ? 12 : 0,
marginTop: Platform.isTV ? 20 : 0,
}}>
<ActivityIndicator size={Platform.isTV ? "large" : "small"} color={colors.primary} />
<Text style={{
color: colors.primary,
fontSize: Platform.isTV ? 18 : 12,
marginLeft: Platform.isTV ? 12 : 8,
fontWeight: Platform.isTV ? '700' : '500',
}}>Loading more sources...</Text>
</View>
) : null
}
@ -1628,14 +1942,14 @@ const createStyles = (colors: any) => StyleSheet.create({
streamsMainContent: {
flex: 1,
backgroundColor: colors.darkBackground,
paddingTop: 20,
paddingTop: Platform.isTV ? 0 : 20,
zIndex: 1,
},
streamsMainContentMovie: {
paddingTop: Platform.OS === 'android' ? 10 : 15,
},
filterContainer: {
paddingHorizontal: 16,
paddingHorizontal: Platform.isTV ? 24 : 16,
paddingBottom: 12,
},
filterScroll: {
@ -1643,11 +1957,11 @@ const createStyles = (colors: any) => StyleSheet.create({
},
filterChip: {
backgroundColor: 'transparent',
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 20,
marginRight: 8,
borderWidth: 1,
paddingHorizontal: Platform.isTV ? 22 : 16,
paddingVertical: Platform.isTV ? 12 : 8,
borderRadius: Platform.isTV ? 28 : 20,
marginRight: Platform.isTV ? 12 : 8,
borderWidth: Platform.isTV ? 2 : 1,
borderColor: colors.border,
},
filterChipSelected: {
@ -1656,11 +1970,12 @@ const createStyles = (colors: any) => StyleSheet.create({
},
filterChipText: {
color: colors.mediumEmphasis,
fontWeight: '500',
fontWeight: '600',
fontSize: Platform.isTV ? 18 : undefined,
},
filterChipTextSelected: {
color: colors.white,
fontWeight: '600',
fontWeight: '700',
},
streamsContent: {
flex: 1,
@ -1687,12 +2002,12 @@ const createStyles = (colors: any) => StyleSheet.create({
streamCard: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
padding: Platform.isTV ? 20 : 16,
borderRadius: 10,
marginBottom: 12,
minHeight: 70,
marginBottom: Platform.isTV ? 16 : 12,
minHeight: Platform.isTV ? 90 : 70,
backgroundColor: colors.card,
borderWidth: 1,
borderWidth: Platform.isTV ? 2 : 1,
borderColor: colors.cardHighlight,
width: '100%',
zIndex: 1,
@ -1728,15 +2043,15 @@ const createStyles = (colors: any) => StyleSheet.create({
flex: 1,
},
streamName: {
fontSize: 14,
fontSize: Platform.isTV ? 18 : 14,
fontWeight: '600',
marginBottom: 2,
lineHeight: 20,
lineHeight: Platform.isTV ? 24 : 20,
color: colors.highEmphasis,
},
streamAddonName: {
fontSize: 13,
lineHeight: 18,
fontSize: Platform.isTV ? 16 : 13,
lineHeight: Platform.isTV ? 22 : 18,
color: colors.mediumEmphasis,
marginBottom: 6,
},
@ -1748,17 +2063,17 @@ const createStyles = (colors: any) => StyleSheet.create({
alignItems: 'center',
},
chip: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 4,
marginRight: 4,
marginBottom: 4,
paddingHorizontal: Platform.isTV ? 14 : 10,
paddingVertical: Platform.isTV ? 6 : 4,
borderRadius: Platform.isTV ? 8 : 4,
marginRight: Platform.isTV ? 6 : 4,
marginBottom: Platform.isTV ? 6 : 4,
backgroundColor: colors.surfaceVariant,
},
chipText: {
color: colors.highEmphasis,
fontSize: 12,
fontWeight: '600',
fontSize: Platform.isTV ? 14 : 12,
fontWeight: '700',
},
progressContainer: {
height: 20,
@ -1778,9 +2093,9 @@ const createStyles = (colors: any) => StyleSheet.create({
marginLeft: 8,
},
streamAction: {
width: 36,
height: 36,
borderRadius: 18,
width: Platform.isTV ? 48 : 36,
height: Platform.isTV ? 48 : 36,
borderRadius: Platform.isTV ? 24 : 18,
backgroundColor: colors.card,
justifyContent: 'center',
alignItems: 'center',
@ -1828,7 +2143,7 @@ const createStyles = (colors: any) => StyleSheet.create({
},
streamsHeroContainer: {
width: '100%',
height: 220,
height: Platform.isTV ? Math.round(height * 0.45) : 220,
marginBottom: 0,
position: 'relative',
backgroundColor: colors.black,
@ -1842,7 +2157,7 @@ const createStyles = (colors: any) => StyleSheet.create({
streamsHeroGradient: {
flex: 1,
justifyContent: 'flex-end',
padding: 16,
padding: Platform.isTV ? 24 : 16,
paddingBottom: 0,
},
streamsHeroContent: {
@ -1853,27 +2168,27 @@ const createStyles = (colors: any) => StyleSheet.create({
},
streamsHeroEpisodeNumber: {
color: colors.primary,
fontSize: 14,
fontSize: Platform.isTV ? 50 : 24,
fontWeight: 'bold',
marginBottom: 2,
marginBottom: Platform.isTV ? 8 : 2,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
streamsHeroTitle: {
color: colors.highEmphasis,
fontSize: 24,
fontSize: Platform.isTV ? 60 : 24,
fontWeight: 'bold',
marginBottom: 4,
marginBottom: Platform.isTV ? 12 : 4,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 3,
},
streamsHeroOverview: {
color: colors.mediumEmphasis,
fontSize: 14,
lineHeight: 20,
marginBottom: 2,
fontSize: Platform.isTV ? 30 : 14,
lineHeight: Platform.isTV ? 28 : 20,
marginBottom: Platform.isTV ? 10 : 2,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
@ -1881,12 +2196,12 @@ const createStyles = (colors: any) => StyleSheet.create({
streamsHeroMeta: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
gap: Platform.isTV ? 20 : 12,
marginTop: 0,
},
streamsHeroReleased: {
color: colors.mediumEmphasis,
fontSize: 14,
fontSize: Platform.isTV ? 25 : 14,
textShadowColor: 'rgba(0,0,0,0.75)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
@ -1895,18 +2210,18 @@ const createStyles = (colors: any) => StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(0,0,0,0.7)',
paddingHorizontal: 6,
paddingVertical: 3,
paddingHorizontal: Platform.isTV ? 8 : 6,
paddingVertical: Platform.isTV ? 4 : 3,
borderRadius: 4,
marginTop: 0,
},
tmdbLogo: {
width: 20,
height: 14,
width: Platform.isTV ? 28 : 20,
height: Platform.isTV ? 18 : 14,
},
streamsHeroRatingText: {
color: colors.accent,
fontSize: 13,
fontSize: Platform.isTV ? 18 : 13,
fontWeight: '700',
marginLeft: 4,
},
@ -1957,11 +2272,11 @@ const createStyles = (colors: any) => StyleSheet.create({
},
movieTitleContainer: {
width: '100%',
height: 140,
height: Platform.isTV ? 200 : 140,
backgroundColor: colors.darkBackground,
pointerEvents: 'box-none',
justifyContent: 'center',
paddingTop: Platform.OS === 'android' ? 65 : 35,
paddingTop: Platform.isTV ? 40 : (Platform.OS === 'android' ? 65 : 35),
},
movieTitleContent: {
width: '100%',
@ -1972,11 +2287,11 @@ const createStyles = (colors: any) => StyleSheet.create({
movieLogo: {
width: '100%',
height: '100%',
maxWidth: width * 0.85,
maxWidth: Platform.isTV ? width * 0.9 : width * 0.85,
},
movieTitle: {
color: colors.highEmphasis,
fontSize: 28,
fontSize: Platform.isTV ? 36 : 28,
fontWeight: '900',
textAlign: 'center',
letterSpacing: -0.5,
@ -2065,35 +2380,35 @@ const createStyles = (colors: any) => StyleSheet.create({
fontWeight: '600',
},
activeScrapersContainer: {
paddingHorizontal: 16,
paddingHorizontal: Platform.isTV ? 24 : 16,
paddingVertical: 8,
backgroundColor: 'transparent',
marginHorizontal: 16,
marginHorizontal: Platform.isTV ? 0 : 16,
marginBottom: 4,
},
activeScrapersTitle: {
color: colors.mediumEmphasis,
fontSize: 12,
fontWeight: '500',
fontSize: Platform.isTV ? 14 : 12,
fontWeight: '600',
marginBottom: 6,
opacity: 0.8,
},
activeScrapersRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 4,
gap: Platform.isTV ? 6 : 4,
},
activeScraperChip: {
backgroundColor: colors.elevation2,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: 6,
paddingHorizontal: Platform.isTV ? 12 : 8,
paddingVertical: Platform.isTV ? 6 : 3,
borderRadius: Platform.isTV ? 10 : 6,
borderWidth: 0,
},
activeScraperText: {
color: colors.mediumEmphasis,
fontSize: 11,
fontWeight: '400',
fontSize: Platform.isTV ? 13 : 11,
fontWeight: '500',
},
});

View file

@ -15,7 +15,7 @@ import {
Switch,
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { makeRedirectUri, useAuthRequest, ResponseType, Prompt, CodeChallengeMethod } from 'expo-auth-session';
// expo-auth-session not supported on tvOS - web browser authentication unavailable
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { traktService, TraktUser } from '../services/traktService';
import { useSettings } from '../hooks/useSettings';
@ -35,11 +35,8 @@ const discovery = {
tokenEndpoint: 'https://api.trakt.tv/oauth/token',
};
// For use with deep linking
const redirectUri = makeRedirectUri({
scheme: 'stremioexpo',
path: 'auth/trakt',
});
// Trakt authentication not available on tvOS
// Web browser authentication is not supported on TV platforms
const TraktSettingsScreen: React.FC = () => {
const { settings } = useSettings();
@ -88,67 +85,21 @@ const TraktSettingsScreen: React.FC = () => {
checkAuthStatus();
}, [checkAuthStatus]);
// Setup expo-auth-session hook with PKCE
const [request, response, promptAsync] = useAuthRequest(
{
clientId: TRAKT_CLIENT_ID,
scopes: [],
redirectUri: redirectUri,
responseType: ResponseType.Code,
usePKCE: true,
codeChallengeMethod: CodeChallengeMethod.S256,
},
discovery
);
// Trakt authentication not supported on tvOS
// Web browser-based OAuth flow is not available on TV platforms
const [isExchangingCode, setIsExchangingCode] = useState(false);
// Placeholder for TV-compatible authentication
const promptAsync = () => {
Alert.alert(
'Not Available on TV',
'Trakt authentication requires a web browser which is not available on TV platforms. Please use the mobile version of the app to authenticate with Trakt.',
[{ text: 'OK' }]
);
};
// Handle the response from the auth request
useEffect(() => {
if (response) {
setIsExchangingCode(true);
if (response.type === 'success' && request?.codeVerifier) {
const { code } = response.params;
logger.log('[TraktSettingsScreen] Auth code received:', code);
traktService.exchangeCodeForToken(code, request.codeVerifier)
.then(success => {
if (success) {
logger.log('[TraktSettingsScreen] Token exchange successful');
checkAuthStatus().then(() => {
// Show success message
Alert.alert(
'Successfully Connected',
'Your Trakt account has been connected successfully.',
[
{
text: 'OK',
onPress: () => navigation.goBack()
}
]
);
});
} else {
logger.error('[TraktSettingsScreen] Token exchange failed');
Alert.alert('Authentication Error', 'Failed to complete authentication with Trakt.');
}
})
.catch(error => {
logger.error('[TraktSettingsScreen] Token exchange error:', error);
Alert.alert('Authentication Error', 'An error occurred during authentication.');
})
.finally(() => {
setIsExchangingCode(false);
});
} else if (response.type === 'error') {
logger.error('[TraktSettingsScreen] Authentication error:', response.error);
Alert.alert('Authentication Error', response.error?.message || 'An error occurred during authentication.');
setIsExchangingCode(false);
} else {
logger.log('[TraktSettingsScreen] Auth response type:', response.type);
setIsExchangingCode(false);
}
}
}, [response, checkAuthStatus, request?.codeVerifier, navigation]);
// Auth response handling removed - not supported on tvOS
// Web browser-based OAuth flow is not available on TV platforms
const handleSignIn = () => {
promptAsync(); // Trigger the authentication flow
@ -300,7 +251,7 @@ const TraktSettingsScreen: React.FC = () => {
{ backgroundColor: isDarkMode ? currentTheme.colors.primary : currentTheme.colors.primary }
]}
onPress={handleSignIn}
disabled={!request || isExchangingCode} // Disable while waiting for response or exchanging code
disabled={isExchangingCode} // Disable while processing
>
{isExchangingCode ? (
<ActivityIndicator size="small" color="white" />
@ -566,4 +517,4 @@ const styles = StyleSheet.create({
},
});
export default TraktSettingsScreen;
export default TraktSettingsScreen;

View file

@ -647,16 +647,8 @@ class CatalogService {
this.saveLibrary();
this.notifyLibrarySubscribers();
// Auto-setup notifications for series when added to library
if (content.type === 'series') {
try {
const { notificationService } = await import('./notificationService');
await notificationService.updateNotificationsForSeries(content.id);
console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`);
} catch (error) {
console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error);
}
}
// Notifications not supported on tvOS
// Notification updates skipped for TV platform
}
public async removeFromLibrary(type: string, id: string): Promise<void> {
@ -665,23 +657,8 @@ class CatalogService {
this.saveLibrary();
this.notifyLibrarySubscribers();
// Cancel notifications for series when removed from library
if (type === 'series') {
try {
const { notificationService } = await import('./notificationService');
// Cancel all notifications for this series
const scheduledNotifications = await notificationService.getScheduledNotifications();
const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id);
for (const notification of seriesToCancel) {
await notificationService.cancelNotification(notification.id);
}
console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`);
} catch (error) {
console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error);
}
}
// Notifications not supported on tvOS
// Notification cancellation skipped for TV platform
}
private addToRecentContent(content: StreamingContent): void {
@ -823,4 +800,4 @@ class CatalogService {
}
export const catalogService = CatalogService.getInstance();
export default catalogService;
export default catalogService;

View file

@ -1,676 +0,0 @@
import * as Notifications from 'expo-notifications';
import { Platform, AppState, AppStateStatus } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { parseISO, differenceInHours, isToday, addDays, isAfter, startOfToday } from 'date-fns';
import { stremioService } from './stremioService';
import { catalogService } from './catalogService';
import { traktService } from './traktService';
import { tmdbService } from './tmdbService';
import { logger } from '../utils/logger';
// Define notification storage keys
const NOTIFICATION_STORAGE_KEY = 'stremio-notifications';
const NOTIFICATION_SETTINGS_KEY = 'stremio-notification-settings';
// Import the correct type from Notifications
const { SchedulableTriggerInputTypes } = Notifications;
// Notification settings interface
export interface NotificationSettings {
enabled: boolean;
newEpisodeNotifications: boolean;
reminderNotifications: boolean;
upcomingShowsNotifications: boolean;
timeBeforeAiring: number; // in hours
}
// Default notification settings
const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
enabled: true,
newEpisodeNotifications: true,
reminderNotifications: true,
upcomingShowsNotifications: true,
timeBeforeAiring: 24, // 24 hours before airing
};
// Episode notification item
export interface NotificationItem {
id: string;
seriesId: string;
seriesName: string;
episodeTitle: string;
season: number;
episode: number;
releaseDate: string;
notified: boolean;
poster?: string;
}
class NotificationService {
private static instance: NotificationService;
private settings: NotificationSettings = DEFAULT_NOTIFICATION_SETTINGS;
private scheduledNotifications: NotificationItem[] = [];
private backgroundSyncInterval: NodeJS.Timeout | null = null;
private librarySubscription: (() => void) | null = null;
private appStateSubscription: any = null;
private lastSyncTime: number = 0;
private readonly MIN_SYNC_INTERVAL = 5 * 60 * 1000; // 5 minutes minimum between syncs
private constructor() {
// Initialize notifications
this.configureNotifications();
this.loadSettings();
this.loadScheduledNotifications();
this.setupLibraryIntegration();
this.setupBackgroundSync();
this.setupAppStateHandling();
}
static getInstance(): NotificationService {
if (!NotificationService.instance) {
NotificationService.instance = new NotificationService();
}
return NotificationService.instance;
}
private async configureNotifications() {
// Configure notification behavior
await Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: true,
}),
});
// Request permissions if needed
const { status: existingStatus } = await Notifications.getPermissionsAsync();
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') {
// Handle permission denied
this.settings.enabled = false;
await this.saveSettings();
}
}
}
private async loadSettings(): Promise<void> {
try {
const storedSettings = await AsyncStorage.getItem(NOTIFICATION_SETTINGS_KEY);
if (storedSettings) {
this.settings = { ...DEFAULT_NOTIFICATION_SETTINGS, ...JSON.parse(storedSettings) };
}
} catch (error) {
logger.error('Error loading notification settings:', error);
}
}
private async saveSettings(): Promise<void> {
try {
await AsyncStorage.setItem(NOTIFICATION_SETTINGS_KEY, JSON.stringify(this.settings));
} catch (error) {
logger.error('Error saving notification settings:', error);
}
}
private async loadScheduledNotifications(): Promise<void> {
try {
const storedNotifications = await AsyncStorage.getItem(NOTIFICATION_STORAGE_KEY);
if (storedNotifications) {
this.scheduledNotifications = JSON.parse(storedNotifications);
}
} catch (error) {
logger.error('Error loading scheduled notifications:', error);
}
}
private async saveScheduledNotifications(): Promise<void> {
try {
await AsyncStorage.setItem(NOTIFICATION_STORAGE_KEY, JSON.stringify(this.scheduledNotifications));
} catch (error) {
logger.error('Error saving scheduled notifications:', error);
}
}
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> {
this.settings = { ...this.settings, ...settings };
await this.saveSettings();
return this.settings;
}
async getSettings(): Promise<NotificationSettings> {
return this.settings;
}
async scheduleEpisodeNotification(item: NotificationItem): Promise<string | null> {
if (!this.settings.enabled || !this.settings.newEpisodeNotifications) {
return null;
}
// Check if notification already exists for this episode
const existingNotification = this.scheduledNotifications.find(
notification => notification.seriesId === item.seriesId &&
notification.season === item.season &&
notification.episode === item.episode
);
if (existingNotification) {
return null; // Don't schedule duplicate notifications
}
const releaseDate = parseISO(item.releaseDate);
const now = new Date();
// If release date has already passed, don't schedule
if (releaseDate < now) {
return null;
}
try {
// Calculate notification time (default to 24h before air time)
const notificationTime = new Date(releaseDate);
notificationTime.setHours(notificationTime.getHours() - this.settings.timeBeforeAiring);
// If notification time has already passed, don't schedule the notification
if (notificationTime < now) {
return null;
}
// Schedule the notification
const notificationId = await Notifications.scheduleNotificationAsync({
content: {
title: `New Episode: ${item.seriesName}`,
body: `S${item.season}:E${item.episode} - ${item.episodeTitle} is airing soon!`,
data: {
seriesId: item.seriesId,
episodeId: item.id,
},
},
trigger: {
date: notificationTime,
type: SchedulableTriggerInputTypes.DATE,
},
});
// Add to scheduled notifications
this.scheduledNotifications.push({
...item,
notified: false,
});
// Save to storage
await this.saveScheduledNotifications();
return notificationId;
} catch (error) {
logger.error('Error scheduling notification:', error);
return null;
}
}
async scheduleMultipleEpisodeNotifications(items: NotificationItem[]): Promise<number> {
if (!this.settings.enabled) {
return 0;
}
let scheduledCount = 0;
for (const item of items) {
const notificationId = await this.scheduleEpisodeNotification(item);
if (notificationId) {
scheduledCount++;
}
}
return scheduledCount;
}
async cancelNotification(id: string): Promise<void> {
try {
// Cancel with Expo
await Notifications.cancelScheduledNotificationAsync(id);
// Remove from our tracked notifications
this.scheduledNotifications = this.scheduledNotifications.filter(
notification => notification.id !== id
);
// Save updated list
await this.saveScheduledNotifications();
} catch (error) {
logger.error('Error canceling notification:', error);
}
}
async cancelAllNotifications(): Promise<void> {
try {
await Notifications.cancelAllScheduledNotificationsAsync();
this.scheduledNotifications = [];
await this.saveScheduledNotifications();
} catch (error) {
logger.error('Error canceling all notifications:', error);
}
}
getScheduledNotifications(): NotificationItem[] {
return [...this.scheduledNotifications];
}
// Setup library integration - automatically sync notifications when library changes
private setupLibraryIntegration(): void {
try {
// Subscribe to library updates from catalog service
this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => {
if (!this.settings.enabled) return;
const now = Date.now();
const timeSinceLastSync = now - this.lastSyncTime;
// Only sync if enough time has passed since last sync
if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) {
// Reduced logging verbosity
// logger.log('[NotificationService] Library updated, syncing notifications for', libraryItems.length, 'items');
await this.syncNotificationsForLibrary(libraryItems);
} else {
// logger.log(`[NotificationService] Library updated, but skipping sync (last sync ${Math.round(timeSinceLastSync / 1000)}s ago)`);
}
});
} catch (error) {
logger.error('[NotificationService] Error setting up library integration:', error);
}
}
// Setup background sync for notifications
private setupBackgroundSync(): void {
// Sync notifications every 6 hours
this.backgroundSyncInterval = setInterval(async () => {
if (this.settings.enabled) {
// Reduced logging verbosity
// logger.log('[NotificationService] Running background notification sync');
await this.performBackgroundSync();
}
}, 6 * 60 * 60 * 1000); // 6 hours
}
// Setup app state handling for foreground sync
private setupAppStateHandling(): void {
const subscription = AppState.addEventListener('change', this.handleAppStateChange);
// Store subscription for cleanup
this.appStateSubscription = subscription;
}
private handleAppStateChange = async (nextAppState: AppStateStatus) => {
if (nextAppState === 'active' && this.settings.enabled) {
const now = Date.now();
const timeSinceLastSync = now - this.lastSyncTime;
// Only sync if enough time has passed since last sync
if (timeSinceLastSync >= this.MIN_SYNC_INTERVAL) {
// App came to foreground, sync notifications
// Reduced logging verbosity
// logger.log('[NotificationService] App became active, syncing notifications');
await this.performBackgroundSync();
} else {
// logger.log(`[NotificationService] App became active, but skipping sync (last sync ${Math.round(timeSinceLastSync / 1000)}s ago)`);
}
}
};
// Sync notifications for all library items
private async syncNotificationsForLibrary(libraryItems: any[]): Promise<void> {
try {
const seriesItems = libraryItems.filter(item => item.type === 'series');
for (const series of seriesItems) {
await this.updateNotificationsForSeries(series.id);
// Small delay to prevent overwhelming the API
await new Promise(resolve => setTimeout(resolve, 100));
}
// Reduced logging verbosity
// logger.log(`[NotificationService] Synced notifications for ${seriesItems.length} series from library`);
} catch (error) {
logger.error('[NotificationService] Error syncing library notifications:', error);
}
}
// Perform comprehensive background sync including Trakt integration
private async performBackgroundSync(): Promise<void> {
try {
// Update last sync time at the start
this.lastSyncTime = Date.now();
// Reduced logging verbosity
// logger.log('[NotificationService] Starting comprehensive background sync');
// Get library items
const libraryItems = catalogService.getLibraryItems();
await this.syncNotificationsForLibrary(libraryItems);
// Sync Trakt items if authenticated
await this.syncTraktNotifications();
// Clean up old notifications
await this.cleanupOldNotifications();
// Reduced logging verbosity
// logger.log('[NotificationService] Background sync completed');
} catch (error) {
logger.error('[NotificationService] Error in background sync:', error);
}
}
// Sync notifications for comprehensive Trakt data (same as calendar screen)
private async syncTraktNotifications(): Promise<void> {
try {
const isAuthenticated = await traktService.isAuthenticated();
if (!traktService.isAuthenticated()) {
// Reduced logging verbosity
// logger.log('[NotificationService] Trakt not authenticated, skipping Trakt sync');
return;
}
// Reduced logging verbosity
// logger.log('[NotificationService] Syncing comprehensive Trakt notifications');
// Get all Trakt data sources (same as calendar screen uses)
const [watchlistShows, continueWatching, watchedShows, collectionShows] = await Promise.all([
traktService.getWatchlistShows(),
traktService.getPlaybackProgress('shows'), // This is the continue watching data
traktService.getWatchedShows(),
traktService.getCollectionShows()
]);
// Combine and deduplicate shows using the same logic as calendar screen
const allTraktShows = new Map();
// Add watchlist shows
if (watchlistShows) {
watchlistShows.forEach((item: any) => {
if (item.show && item.show.ids.imdb) {
allTraktShows.set(item.show.ids.imdb, {
id: item.show.ids.imdb,
name: item.show.title,
type: 'series',
year: item.show.year,
source: 'trakt-watchlist'
});
}
});
}
// Add continue watching shows (in-progress shows)
if (continueWatching) {
continueWatching.forEach((item: any) => {
if (item.type === 'episode' && item.show && item.show.ids.imdb) {
const imdbId = item.show.ids.imdb;
if (!allTraktShows.has(imdbId)) {
allTraktShows.set(imdbId, {
id: imdbId,
name: item.show.title,
type: 'series',
year: item.show.year,
source: 'trakt-continue-watching'
});
}
}
});
}
// Add recently watched shows (top 20, same as calendar)
if (watchedShows) {
const recentWatched = watchedShows.slice(0, 20);
recentWatched.forEach((item: any) => {
if (item.show && item.show.ids.imdb) {
const imdbId = item.show.ids.imdb;
if (!allTraktShows.has(imdbId)) {
allTraktShows.set(imdbId, {
id: imdbId,
name: item.show.title,
type: 'series',
year: item.show.year,
source: 'trakt-watched'
});
}
}
});
}
// Add collection shows
if (collectionShows) {
collectionShows.forEach((item: any) => {
if (item.show && item.show.ids.imdb) {
const imdbId = item.show.ids.imdb;
if (!allTraktShows.has(imdbId)) {
allTraktShows.set(imdbId, {
id: imdbId,
name: item.show.title,
type: 'series',
year: item.show.year,
source: 'trakt-collection'
});
}
}
});
}
// Reduced logging verbosity
// logger.log(`[NotificationService] Found ${allTraktShows.size} unique Trakt shows from all sources`);
// Sync notifications for each Trakt show
let syncedCount = 0;
for (const show of allTraktShows.values()) {
try {
await this.updateNotificationsForSeries(show.id);
syncedCount++;
// Small delay to prevent API rate limiting
await new Promise(resolve => setTimeout(resolve, 200));
} catch (error) {
logger.error(`[NotificationService] Failed to sync notifications for ${show.name}:`, error);
}
}
// Reduced logging verbosity
// logger.log(`[NotificationService] Successfully synced notifications for ${syncedCount}/${allTraktShows.size} Trakt shows`);
} catch (error) {
logger.error('[NotificationService] Error syncing Trakt notifications:', error);
}
}
// Enhanced series notification update with TMDB fallback
async updateNotificationsForSeries(seriesId: string): Promise<void> {
try {
// Reduced logging verbosity - only log for debug purposes
// logger.log(`[NotificationService] Updating notifications for series: ${seriesId}`);
// Try Stremio first
let metadata = await stremioService.getMetaDetails('series', seriesId);
let upcomingEpisodes: any[] = [];
if (metadata && metadata.videos) {
const now = new Date();
const fourWeeksLater = addDays(now, 28);
upcomingEpisodes = metadata.videos.filter(video => {
if (!video.released) return false;
const releaseDate = parseISO(video.released);
return releaseDate > now && releaseDate < fourWeeksLater;
}).map(video => ({
id: video.id,
title: (video as any).title || (video as any).name || `Episode ${video.episode}`,
season: video.season || 0,
episode: video.episode || 0,
released: video.released,
}));
}
// If no upcoming episodes from Stremio, try TMDB
if (upcomingEpisodes.length === 0) {
try {
// Extract TMDB ID if it's a TMDB format ID
let tmdbId = seriesId;
if (seriesId.startsWith('tmdb:')) {
tmdbId = seriesId.split(':')[1];
}
const tmdbDetails = await tmdbService.getTVShowDetails(parseInt(tmdbId));
if (tmdbDetails) {
metadata = {
id: seriesId,
type: 'series' as const,
name: tmdbDetails.name,
poster: tmdbService.getImageUrl(tmdbDetails.poster_path) || '',
};
// Get upcoming episodes from TMDB
const now = new Date();
const fourWeeksLater = addDays(now, 28);
// Check current and next seasons for upcoming episodes
for (let seasonNum = tmdbDetails.number_of_seasons; seasonNum >= Math.max(1, tmdbDetails.number_of_seasons - 2); seasonNum--) {
try {
const seasonDetails = await tmdbService.getSeasonDetails(parseInt(tmdbId), seasonNum);
if (seasonDetails && seasonDetails.episodes) {
const seasonUpcoming = seasonDetails.episodes.filter((episode: any) => {
if (!episode.air_date) return false;
const airDate = parseISO(episode.air_date);
return airDate > now && airDate < fourWeeksLater;
});
upcomingEpisodes.push(...seasonUpcoming.map((episode: any) => ({
id: `${tmdbId}-s${seasonNum}e${episode.episode_number}`,
title: episode.name,
season: seasonNum,
episode: episode.episode_number,
released: episode.air_date,
})));
}
} catch (seasonError) {
// Continue with other seasons if one fails
}
}
}
} catch (tmdbError) {
logger.warn(`[NotificationService] TMDB fallback failed for ${seriesId}:`, tmdbError);
}
}
if (!metadata) {
logger.warn(`[NotificationService] No metadata found for series: ${seriesId}`);
return;
}
// Cancel existing notifications for this series
const existingNotifications = await Notifications.getAllScheduledNotificationsAsync();
for (const notification of existingNotifications) {
if (notification.content.data?.seriesId === seriesId) {
await Notifications.cancelScheduledNotificationAsync(notification.identifier);
}
}
// Remove from our tracked notifications
this.scheduledNotifications = this.scheduledNotifications.filter(
notification => notification.seriesId !== seriesId
);
// Schedule new notifications for upcoming episodes
if (upcomingEpisodes.length > 0) {
const notificationItems: NotificationItem[] = upcomingEpisodes.map(episode => ({
id: episode.id,
seriesId,
seriesName: metadata.name,
episodeTitle: episode.title,
season: episode.season || 0,
episode: episode.episode || 0,
releaseDate: episode.released,
notified: false,
poster: metadata.poster,
}));
const scheduledCount = await this.scheduleMultipleEpisodeNotifications(notificationItems);
// Reduced logging verbosity
// logger.log(`[NotificationService] Scheduled ${scheduledCount} notifications for ${metadata.name}`);
} else {
// logger.log(`[NotificationService] No upcoming episodes found for ${metadata.name}`);
}
} catch (error) {
logger.error(`[NotificationService] Error updating notifications for series ${seriesId}:`, error);
}
}
// Clean up old and expired notifications
private async cleanupOldNotifications(): Promise<void> {
try {
const now = new Date();
const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
// Remove notifications for episodes that have already aired
const validNotifications = this.scheduledNotifications.filter(notification => {
const releaseDate = parseISO(notification.releaseDate);
return releaseDate > oneDayAgo;
});
if (validNotifications.length !== this.scheduledNotifications.length) {
this.scheduledNotifications = validNotifications;
await this.saveScheduledNotifications();
// Reduced logging verbosity
// logger.log(`[NotificationService] Cleaned up ${this.scheduledNotifications.length - validNotifications.length} old notifications`);
}
} catch (error) {
logger.error('[NotificationService] Error cleaning up notifications:', error);
}
}
// Public method to manually trigger sync for all library items
public async syncAllNotifications(): Promise<void> {
// Reduced logging verbosity
// logger.log('[NotificationService] Manual sync triggered');
await this.performBackgroundSync();
}
// Public method to get notification stats
public getNotificationStats(): { total: number; upcoming: number; thisWeek: number } {
const now = new Date();
const oneWeekLater = addDays(now, 7);
const upcoming = this.scheduledNotifications.filter(notification => {
const releaseDate = parseISO(notification.releaseDate);
return releaseDate > now;
});
const thisWeek = upcoming.filter(notification => {
const releaseDate = parseISO(notification.releaseDate);
return releaseDate < oneWeekLater;
});
return {
total: this.scheduledNotifications.length,
upcoming: upcoming.length,
thisWeek: thisWeek.length
};
}
// Cleanup method for proper disposal
public destroy(): void {
if (this.backgroundSyncInterval) {
clearInterval(this.backgroundSyncInterval);
this.backgroundSyncInterval = null;
}
if (this.librarySubscription) {
this.librarySubscription();
this.librarySubscription = null;
}
if (this.appStateSubscription) {
this.appStateSubscription.remove();
this.appStateSubscription = null;
}
}
}
// Export singleton instance
export const notificationService = NotificationService.getInstance();

View file

@ -4,8 +4,9 @@ const { width, height } = Dimensions.get('window');
// Dynamic poster calculation based on screen width
const calculatePosterLayout = (screenWidth: number) => {
const MIN_POSTER_WIDTH = 110; // Minimum poster width for readability
const MAX_POSTER_WIDTH = 140; // Maximum poster width to prevent oversized posters
// TV gets larger posters
const MIN_POSTER_WIDTH = Platform.isTV ? 140 : 110;
const MAX_POSTER_WIDTH = Platform.isTV ? 180 : 140;
const HORIZONTAL_PADDING = 50; // Total horizontal padding/margins
// Calculate how many posters can fit
@ -62,4 +63,4 @@ export default {
POSTER_WIDTH,
POSTER_HEIGHT,
HORIZONTAL_PADDING,
};
};

BIN
src/utils/.logger.ts.swp Normal file

Binary file not shown.

View file

@ -15,10 +15,12 @@ export interface PosterLayout {
spacing: number;
}
import { Platform } from 'react-native';
// Default configuration for main home sections
export const DEFAULT_POSTER_CONFIG: PosterLayoutConfig = {
minPosterWidth: 110,
maxPosterWidth: 140,
minPosterWidth: Platform.isTV ? 140 : 110,
maxPosterWidth: Platform.isTV ? 180 : 140,
horizontalPadding: 50,
minColumns: 3,
maxColumns: 6,
@ -27,8 +29,8 @@ export const DEFAULT_POSTER_CONFIG: PosterLayoutConfig = {
// Configuration for More Like This section (smaller posters, more items)
export const MORE_LIKE_THIS_CONFIG: PosterLayoutConfig = {
minPosterWidth: 100,
maxPosterWidth: 130,
minPosterWidth: Platform.isTV ? 140 : 100,
maxPosterWidth: Platform.isTV ? 170 : 130,
horizontalPadding: 48,
minColumns: 3,
maxColumns: 7,
@ -37,8 +39,8 @@ export const MORE_LIKE_THIS_CONFIG: PosterLayoutConfig = {
// Configuration for Continue Watching section (larger posters, fewer items)
export const CONTINUE_WATCHING_CONFIG: PosterLayoutConfig = {
minPosterWidth: 120,
maxPosterWidth: 160,
minPosterWidth: Platform.isTV ? 160 : 120,
maxPosterWidth: Platform.isTV ? 200 : 160,
horizontalPadding: 40,
minColumns: 2,
maxColumns: 5,
@ -79,4 +81,4 @@ export const calculatePosterLayout = (
export const getCurrentPosterLayout = (config?: PosterLayoutConfig): PosterLayout => {
const { width } = Dimensions.get('window');
return calculatePosterLayout(width, config);
};
};