initial commit
This commit is contained in:
parent
2d3ece7dc4
commit
604b38ba20
19 changed files with 1221 additions and 5783 deletions
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": {
|
||||
|
|
|
|||
5276
package-lock.json
generated
5276
package-lock.json
generated
File diff suppressed because it is too large
Load diff
30
package.json
30
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,7 +18,6 @@
|
|||
"@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",
|
||||
|
|
@ -28,27 +31,25 @@
|
|||
"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 +59,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -183,22 +183,20 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) =
|
|||
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; },
|
||||
// Direct shared value references for compatibility
|
||||
heroHeight: heroHeightValue,
|
||||
logoOpacity: uiElementsOpacity,
|
||||
buttonsOpacity: uiElementsOpacity,
|
||||
buttonsTranslateY: uiElementsTranslateY,
|
||||
contentTranslateY: uiElementsTranslateY,
|
||||
watchProgressOpacity: progressOpacity,
|
||||
watchProgressWidth: progressOpacity, // Reuse for width animation
|
||||
headerOpacity: headerProgress,
|
||||
headerElementsY: staticHeaderElementsY,
|
||||
headerElementsOpacity: headerProgress,
|
||||
|
||||
// Functions
|
||||
scrollHandler,
|
||||
animateLogo: () => {}, // Simplified - no separate logo animation
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -28,7 +28,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 +96,7 @@ export type RootStackParamList = {
|
|||
About: undefined;
|
||||
Addons: undefined;
|
||||
CatalogSettings: undefined;
|
||||
NotificationSettings: undefined;
|
||||
|
||||
MDBListSettings: undefined;
|
||||
TMDBSettings: undefined;
|
||||
HomeScreenSettings: undefined;
|
||||
|
|
@ -497,6 +497,14 @@ const MainTabs = () => {
|
|||
key={route.key}
|
||||
activeOpacity={0.7}
|
||||
onPress={onPress}
|
||||
hasTVPreferredFocus={index === 0 && Platform.isTV}
|
||||
tvParallaxProperties={Platform.isTV ? {
|
||||
enabled: true,
|
||||
shiftDistanceX: 2.0,
|
||||
shiftDistanceY: 2.0,
|
||||
tiltAngle: 0.05,
|
||||
magnification: 1.1,
|
||||
} : undefined}
|
||||
style={{
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
|
|
@ -911,17 +919,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}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -388,18 +388,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,
|
||||
|
|
@ -1131,4 +1124,4 @@ const styles = StyleSheet.create<any>({
|
|||
},
|
||||
});
|
||||
|
||||
export default React.memo(HomeScreen);
|
||||
export default React.memo(HomeScreen);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import {
|
|||
Clipboard,
|
||||
} 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';
|
||||
|
|
@ -887,16 +887,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]);
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
Loading…
Reference in a new issue