initial commit

This commit is contained in:
tapframe 2025-08-02 13:51:15 +05:30
parent 2d3ece7dc4
commit 604b38ba20
19 changed files with 1221 additions and 5783 deletions

131
TV_SETUP.md Normal file
View file

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

View file

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

View file

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

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

View file

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

View file

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

5276
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -4,9 +4,13 @@
"main": "index.ts",
"scripts": {
"start": "expo start",
"start:tv": "EXPO_TV=1 expo start",
"android": "expo run:android",
"android:tv": "EXPO_TV=1 expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
"ios:tv": "EXPO_TV=1 expo run:ios",
"web": "expo start --web",
"prebuild:tv": "EXPO_TV=1 expo prebuild --clean"
},
"dependencies": {
"@expo/metro-runtime": "~4.0.1",
@ -14,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
}

View file

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

View file

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

View file

@ -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
};
};
};

View file

@ -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}

View file

@ -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);

View file

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

View file

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

View file

@ -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]);

View file

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

View file

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

View file

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