Compare commits
7 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9f02adb98 | ||
|
|
e912149ff6 | ||
|
|
99dc34cb65 | ||
|
|
11030c5601 | ||
|
|
8da78d1b0d | ||
|
|
332cf99f67 | ||
|
|
604b38ba20 |
42 changed files with 3682 additions and 8980 deletions
2
App.tsx
2
App.tsx
|
|
@ -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
131
TV_SETUP.md
Normal 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)
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
0
android/gradlew
vendored
Normal file → Executable file
1
app.json
1
app.json
|
|
@ -58,6 +58,7 @@
|
|||
},
|
||||
"owner": "nayifleo",
|
||||
"plugins": [
|
||||
"@react-native-tvos/config-tv",
|
||||
[
|
||||
"@sentry/react-native/expo",
|
||||
{
|
||||
|
|
|
|||
30
eas.json
30
eas.json
|
|
@ -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
1
nuvio-providers
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 96be1f53604182cb53f027160db9fc969ed3bdcc
|
||||
5501
package-lock.json
generated
5501
package-lock.json
generated
File diff suppressed because it is too large
Load diff
27
package.json
27
package.json
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
};
|
||||
};
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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();
|
||||
|
|
@ -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
BIN
src/utils/.logger.ts.swp
Normal file
Binary file not shown.
|
|
@ -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);
|
||||
};
|
||||
};
|
||||
Loading…
Reference in a new issue