some fixes with updating notifications
5
.gitignore
vendored
|
|
@ -39,4 +39,7 @@ release_announcement.md
|
|||
ALPHA_BUILD_2_ANNOUNCEMENT.md
|
||||
CHANGELOG.md
|
||||
.env.local
|
||||
android/
|
||||
android/
|
||||
HEATING_OPTIMIZATIONS.md
|
||||
ios
|
||||
android
|
||||
2
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{
|
||||
}
|
||||
232
NOTIFICATION_INTEGRATION_SUMMARY.md
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
# 🔔 Comprehensive Notification Integration - Implementation Summary
|
||||
|
||||
## ✅ **What Was Implemented**
|
||||
|
||||
I've successfully integrated notifications with your library and Trakt system, adding automatic background notifications for all saved shows. Here's what's now working:
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **1. Library Auto-Integration**
|
||||
|
||||
### **Automatic Notification Setup**
|
||||
- **When adding series to library**: Notifications are automatically scheduled for upcoming episodes
|
||||
- **When removing series from library**: All related notifications are automatically cancelled
|
||||
- **Real-time sync**: Changes to library immediately trigger notification updates
|
||||
|
||||
### **Implementation Details:**
|
||||
```typescript
|
||||
// In catalogService.ts - Auto-setup when adding to library
|
||||
public async addToLibrary(content: StreamingContent): Promise<void> {
|
||||
// ... existing code ...
|
||||
|
||||
// Auto-setup notifications for series when added to library
|
||||
if (content.type === 'series') {
|
||||
await notificationService.updateNotificationsForSeries(content.id);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎬 **2. Trakt Integration**
|
||||
|
||||
### **Comprehensive Trakt Support**
|
||||
- **Trakt Watchlist**: Automatically syncs notifications for shows in your Trakt watchlist
|
||||
- **Trakt Collection**: Syncs notifications for shows in your Trakt collection
|
||||
- **Background Sync**: Periodically checks Trakt for new shows and updates notifications
|
||||
- **Authentication Handling**: Automatically detects when Trakt is connected/disconnected
|
||||
|
||||
### **What Gets Synced:**
|
||||
- All series from your Trakt watchlist
|
||||
- All series from your Trakt collection
|
||||
- Automatic deduplication with local library
|
||||
- IMDB ID mapping for accurate show identification
|
||||
|
||||
---
|
||||
|
||||
## ⏰ **3. Background Notifications**
|
||||
|
||||
### **Automatic Background Processing**
|
||||
- **6-hour sync cycle**: Automatically syncs all notifications every 6 hours
|
||||
- **App foreground sync**: Syncs when app comes to foreground
|
||||
- **Library change sync**: Immediate sync when library changes
|
||||
- **Trakt change detection**: Syncs when Trakt data changes
|
||||
|
||||
### **Smart Episode Detection:**
|
||||
- **4-week window**: Finds episodes airing in the next 4 weeks
|
||||
- **Multiple data sources**: Uses Stremio first, falls back to TMDB
|
||||
- **Duplicate prevention**: Won't schedule same episode twice
|
||||
- **Automatic cleanup**: Removes old/expired notifications
|
||||
|
||||
---
|
||||
|
||||
## 📱 **4. Enhanced Settings Screen**
|
||||
|
||||
### **New Features Added:**
|
||||
- **Notification Stats Display**: Shows upcoming, this week, and total notifications
|
||||
- **Manual Sync Button**: "Sync Library & Trakt" button for immediate sync
|
||||
- **Real-time Stats**: Stats update automatically after sync
|
||||
- **Visual Feedback**: Loading states and success messages
|
||||
|
||||
### **Stats Dashboard:**
|
||||
```
|
||||
📅 Upcoming: 12 📆 This Week: 3 🔔 Total: 15
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **5. Technical Implementation**
|
||||
|
||||
### **Enhanced NotificationService Features:**
|
||||
|
||||
#### **Library Integration:**
|
||||
```typescript
|
||||
private setupLibraryIntegration(): void {
|
||||
// Subscribe to library updates from catalog service
|
||||
this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => {
|
||||
await this.syncNotificationsForLibrary(libraryItems);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### **Trakt Integration:**
|
||||
```typescript
|
||||
private async syncTraktNotifications(): Promise<void> {
|
||||
// Get Trakt watchlist and collection shows
|
||||
const [watchlistShows, collectionShows] = await Promise.all([
|
||||
traktService.getWatchlistShows(),
|
||||
traktService.getCollectionShows()
|
||||
]);
|
||||
// Sync notifications for each show
|
||||
}
|
||||
```
|
||||
|
||||
#### **Background Sync:**
|
||||
```typescript
|
||||
private setupBackgroundSync(): void {
|
||||
// Sync notifications every 6 hours
|
||||
this.backgroundSyncInterval = setInterval(async () => {
|
||||
await this.performBackgroundSync();
|
||||
}, 6 * 60 * 60 * 1000);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📊 **6. Data Sources & Fallbacks**
|
||||
|
||||
### **Multi-Source Episode Detection:**
|
||||
1. **Primary**: Stremio addon metadata
|
||||
2. **Fallback**: TMDB API for episode air dates
|
||||
3. **Smart Mapping**: Handles both IMDB IDs and TMDB IDs
|
||||
4. **Season Detection**: Checks current and upcoming seasons
|
||||
|
||||
### **Notification Content:**
|
||||
```
|
||||
Title: "New Episode: Breaking Bad"
|
||||
Body: "S5:E14 - Ozymandias is airing soon!"
|
||||
Data: { seriesId: "tt0903747", episodeId: "..." }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎯 **7. User Experience Improvements**
|
||||
|
||||
### **Seamless Integration:**
|
||||
- **Zero manual setup**: Works automatically when you add shows
|
||||
- **Cross-platform sync**: Trakt integration keeps notifications in sync across devices
|
||||
- **Smart timing**: Respects user's preferred notification timing (1h, 6h, 12h, 24h)
|
||||
- **Battery optimized**: Efficient background processing
|
||||
|
||||
### **Visual Feedback:**
|
||||
- **Stats dashboard**: See exactly how many notifications are scheduled
|
||||
- **Sync status**: Clear feedback when syncing completes
|
||||
- **Error handling**: Graceful handling of API failures
|
||||
|
||||
---
|
||||
|
||||
## 🔄 **8. Automatic Workflows**
|
||||
|
||||
### **When You Add a Show to Library:**
|
||||
1. Show is added to local library
|
||||
2. Notification service automatically triggered
|
||||
3. Upcoming episodes detected (next 4 weeks)
|
||||
4. Notifications scheduled based on your timing preference
|
||||
5. Stats updated in settings screen
|
||||
|
||||
### **When You Add a Show to Trakt:**
|
||||
1. Background sync detects new Trakt show (within 6 hours or on app open)
|
||||
2. Show metadata fetched
|
||||
3. Notifications scheduled automatically
|
||||
4. No manual intervention required
|
||||
|
||||
### **When Episodes Air:**
|
||||
1. Notification delivered at your preferred time
|
||||
2. Old notifications automatically cleaned up
|
||||
3. Stats updated to reflect current state
|
||||
|
||||
---
|
||||
|
||||
## 📈 **9. Performance Optimizations**
|
||||
|
||||
### **Efficient Processing:**
|
||||
- **Batch operations**: Processes multiple shows efficiently
|
||||
- **API rate limiting**: Includes delays to prevent overwhelming APIs
|
||||
- **Memory management**: Cleans up old notifications automatically
|
||||
- **Error resilience**: Continues processing even if individual shows fail
|
||||
|
||||
### **Background Processing:**
|
||||
- **Non-blocking**: Doesn't interfere with app performance
|
||||
- **Intelligent scheduling**: Only syncs when necessary
|
||||
- **Resource conscious**: Optimized for battery life
|
||||
|
||||
---
|
||||
|
||||
## 🎉 **10. What This Means for Users**
|
||||
|
||||
### **Before:**
|
||||
- Manual notification setup required
|
||||
- No integration with library or Trakt
|
||||
- Limited to manually added shows
|
||||
- No background updates
|
||||
|
||||
### **After:**
|
||||
- ✅ **Automatic**: Add any show to library → notifications work automatically
|
||||
- ✅ **Trakt Sync**: Your Trakt watchlist/collection → automatic notifications
|
||||
- ✅ **Background**: Always up-to-date without manual intervention
|
||||
- ✅ **Smart**: Finds episodes from multiple sources
|
||||
- ✅ **Visual**: Clear stats and sync controls
|
||||
|
||||
---
|
||||
|
||||
## 🔧 **11. How to Use**
|
||||
|
||||
### **For Library Shows:**
|
||||
1. Add any series to your library (heart icon)
|
||||
2. Notifications automatically scheduled
|
||||
3. Check stats in Settings → Notification Settings
|
||||
|
||||
### **For Trakt Shows:**
|
||||
1. Connect your Trakt account
|
||||
2. Add shows to Trakt watchlist or collection
|
||||
3. Notifications sync automatically (within 6 hours or on app open)
|
||||
4. Use "Sync Library & Trakt" button for immediate sync
|
||||
|
||||
### **Manual Control:**
|
||||
- Go to Settings → Notification Settings
|
||||
- View notification stats
|
||||
- Use "Sync Library & Trakt" for immediate sync
|
||||
- Adjust timing preferences (1h, 6h, 12h, 24h before airing)
|
||||
|
||||
---
|
||||
|
||||
## 🚀 **Result**
|
||||
|
||||
Your notification system now provides a **Netflix-like experience** where:
|
||||
- Adding shows automatically sets up notifications
|
||||
- Trakt integration keeps everything in sync
|
||||
- Background processing ensures you never miss episodes
|
||||
- Smart episode detection works across multiple data sources
|
||||
- Visual feedback shows exactly what's scheduled
|
||||
|
||||
The system is now **fully automated** and **user-friendly**, requiring zero manual setup while providing comprehensive coverage of all your shows from both local library and Trakt integration.
|
||||
|
|
@ -126,15 +126,6 @@ android {
|
|||
androidResources {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
enable true
|
||||
reset()
|
||||
include "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
|
||||
universalApk true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply static values from `gradle.properties` to the `android.packagingOptions`
|
||||
|
|
|
|||
|
|
@ -2,5 +2,4 @@
|
|||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
|
|
@ -2,5 +2,4 @@
|
|||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/iconBackground"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 62 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 9 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 27 KiB |
188
android/gradlew.bat
vendored
|
|
@ -1,94 +1,94 @@
|
|||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
|
|
|||
21
app.json
|
|
@ -32,19 +32,7 @@
|
|||
"UIFileSharingEnabled": true
|
||||
},
|
||||
"bundleIdentifier": "com.nuvio.app",
|
||||
"associatedDomains": [],
|
||||
"documentTypes": [
|
||||
{
|
||||
"name": "Matroska Video",
|
||||
"role": "viewer",
|
||||
"utis": [
|
||||
"org.matroska.mkv"
|
||||
],
|
||||
"extensions": [
|
||||
"mkv"
|
||||
]
|
||||
}
|
||||
]
|
||||
"associatedDomains": []
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
|
|
@ -57,12 +45,7 @@
|
|||
"WAKE_LOCK"
|
||||
],
|
||||
"package": "com.nuvio.app",
|
||||
"enableSplitAPKs": true,
|
||||
"versionCode": 1,
|
||||
"enableProguardInReleaseBuilds": true,
|
||||
"enableHermes": true,
|
||||
"enableSeparateBuildPerCPUArchitecture": true,
|
||||
"enableVectorDrawables": true
|
||||
"versionCode": 1
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
|
|
|
|||
195
src/components/common/OptimizedImage.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { View, StyleSheet, Dimensions } from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { imageCacheService } from '../../services/imageCacheService';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
||||
interface OptimizedImageProps {
|
||||
source: { uri: string } | string;
|
||||
style?: any;
|
||||
placeholder?: string;
|
||||
priority?: 'low' | 'normal' | 'high';
|
||||
lazy?: boolean;
|
||||
onLoad?: () => void;
|
||||
onError?: (error: any) => void;
|
||||
contentFit?: 'cover' | 'contain' | 'fill' | 'scale-down';
|
||||
transition?: number;
|
||||
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
// Image size optimization based on container size
|
||||
const getOptimizedImageUrl = (originalUrl: string, containerWidth?: number, containerHeight?: number): string => {
|
||||
if (!originalUrl || originalUrl.includes('placeholder')) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// For TMDB images, we can request specific sizes
|
||||
if (originalUrl.includes('image.tmdb.org')) {
|
||||
const width = containerWidth || 300;
|
||||
let size = 'w300';
|
||||
|
||||
if (width <= 92) size = 'w92';
|
||||
else if (width <= 154) size = 'w154';
|
||||
else if (width <= 185) size = 'w185';
|
||||
else if (width <= 342) size = 'w342';
|
||||
else if (width <= 500) size = 'w500';
|
||||
else if (width <= 780) size = 'w780';
|
||||
else size = 'w1280';
|
||||
|
||||
// Replace the size in the URL
|
||||
return originalUrl.replace(/\/w\d+\//, `/${size}/`);
|
||||
}
|
||||
|
||||
// For other image services, add query parameters if supported
|
||||
if (originalUrl.includes('?')) {
|
||||
return `${originalUrl}&w=${containerWidth || 300}&h=${containerHeight || 450}&q=80`;
|
||||
} else {
|
||||
return `${originalUrl}?w=${containerWidth || 300}&h=${containerHeight || 450}&q=80`;
|
||||
}
|
||||
};
|
||||
|
||||
const OptimizedImage: React.FC<OptimizedImageProps> = ({
|
||||
source,
|
||||
style,
|
||||
placeholder = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=Loading',
|
||||
priority = 'normal',
|
||||
lazy = true,
|
||||
onLoad,
|
||||
onError,
|
||||
contentFit = 'cover',
|
||||
transition = 200,
|
||||
cachePolicy = 'memory-disk'
|
||||
}) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(!lazy);
|
||||
const [optimizedUrl, setOptimizedUrl] = useState<string>('');
|
||||
const mountedRef = useRef(true);
|
||||
const loadTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Extract URL from source
|
||||
const sourceUrl = typeof source === 'string' ? source : source?.uri || '';
|
||||
|
||||
// Calculate container dimensions from style
|
||||
const containerWidth = style?.width || (style?.aspectRatio ? screenWidth * 0.3 : 300);
|
||||
const containerHeight = style?.height || (containerWidth / (style?.aspectRatio || 0.67));
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (loadTimeoutRef.current) {
|
||||
clearTimeout(loadTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Optimize image URL based on container size
|
||||
useEffect(() => {
|
||||
if (sourceUrl) {
|
||||
const optimized = getOptimizedImageUrl(sourceUrl, containerWidth, containerHeight);
|
||||
setOptimizedUrl(optimized);
|
||||
}
|
||||
}, [sourceUrl, containerWidth, containerHeight]);
|
||||
|
||||
// Lazy loading intersection observer simulation
|
||||
useEffect(() => {
|
||||
if (lazy && !isVisible) {
|
||||
// Simple lazy loading - load after a short delay to simulate intersection
|
||||
const timer = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
setIsVisible(true);
|
||||
}
|
||||
}, priority === 'high' ? 100 : priority === 'normal' ? 300 : 500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [lazy, isVisible, priority]);
|
||||
|
||||
// Preload image with caching
|
||||
const preloadImage = useCallback(async () => {
|
||||
if (!optimizedUrl || !isVisible) return;
|
||||
|
||||
try {
|
||||
// Use our cache service to manage the image
|
||||
const cachedUrl = await imageCacheService.getCachedImageUrl(optimizedUrl);
|
||||
|
||||
// Set a timeout for loading
|
||||
loadTimeoutRef.current = setTimeout(() => {
|
||||
if (mountedRef.current && !isLoaded) {
|
||||
logger.warn(`[OptimizedImage] Load timeout for: ${optimizedUrl.substring(0, 50)}...`);
|
||||
setHasError(true);
|
||||
}
|
||||
}, 10000); // 10 second timeout
|
||||
|
||||
// Prefetch the image
|
||||
await ExpoImage.prefetch(cachedUrl);
|
||||
|
||||
if (mountedRef.current) {
|
||||
setIsLoaded(true);
|
||||
if (loadTimeoutRef.current) {
|
||||
clearTimeout(loadTimeoutRef.current);
|
||||
loadTimeoutRef.current = null;
|
||||
}
|
||||
onLoad?.();
|
||||
}
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
logger.error(`[OptimizedImage] Failed to load: ${optimizedUrl.substring(0, 50)}...`, error);
|
||||
setHasError(true);
|
||||
onError?.(error);
|
||||
}
|
||||
}
|
||||
}, [optimizedUrl, isVisible, isLoaded, onLoad, onError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible && optimizedUrl && !isLoaded && !hasError) {
|
||||
preloadImage();
|
||||
}
|
||||
}, [isVisible, optimizedUrl, isLoaded, hasError, preloadImage]);
|
||||
|
||||
// Don't render anything if not visible (lazy loading)
|
||||
if (!isVisible) {
|
||||
return <View style={[style, styles.placeholder]} />;
|
||||
}
|
||||
|
||||
// Show placeholder while loading or on error
|
||||
if (!isLoaded || hasError) {
|
||||
return (
|
||||
<ExpoImage
|
||||
source={{ uri: placeholder }}
|
||||
style={style}
|
||||
contentFit={contentFit}
|
||||
transition={0}
|
||||
cachePolicy="memory"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ExpoImage
|
||||
source={{ uri: optimizedUrl }}
|
||||
style={style}
|
||||
contentFit={contentFit}
|
||||
transition={transition}
|
||||
cachePolicy={cachePolicy}
|
||||
onLoad={() => {
|
||||
setIsLoaded(true);
|
||||
onLoad?.();
|
||||
}}
|
||||
onError={(error) => {
|
||||
setHasError(true);
|
||||
onError?.(error);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
placeholder: {
|
||||
backgroundColor: '#1a1a1a',
|
||||
},
|
||||
});
|
||||
|
||||
export default OptimizedImage;
|
||||
|
|
@ -390,8 +390,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
};
|
||||
} else {
|
||||
// Fallback: poll for updates every 30 seconds
|
||||
const intervalId = setInterval(() => loadContinueWatching(true), 30000);
|
||||
// Reduced polling frequency from 30s to 2 minutes to reduce heating
|
||||
const intervalId = setInterval(() => loadContinueWatching(true), 120000);
|
||||
return () => {
|
||||
subscription.remove();
|
||||
clearInterval(intervalId);
|
||||
|
|
|
|||
|
|
@ -17,10 +17,10 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
|
|||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
Easing,
|
||||
withDelay
|
||||
|
|
@ -32,6 +32,7 @@ import { useSettings } from '../../hooks/useSettings';
|
|||
import { TMDBService } from '../../services/tmdbService';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { imageCacheService } from '../../services/imageCacheService';
|
||||
|
||||
interface FeaturedContentProps {
|
||||
featuredContent: StreamingContent | null;
|
||||
|
|
@ -137,12 +138,12 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
const [logoLoadError, setLogoLoadError] = useState(false);
|
||||
// Add a ref to track logo fetch in progress
|
||||
const logoFetchInProgress = useRef<boolean>(false);
|
||||
|
||||
|
||||
// Enhanced poster transition animations
|
||||
const posterScale = useSharedValue(1);
|
||||
const posterTranslateY = useSharedValue(0);
|
||||
const overlayOpacity = useSharedValue(0.15);
|
||||
|
||||
|
||||
// Animation values
|
||||
const posterAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: posterOpacity.value,
|
||||
|
|
@ -151,14 +152,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
{ translateY: posterTranslateY.value }
|
||||
],
|
||||
}));
|
||||
|
||||
|
||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: logoOpacity.value,
|
||||
}));
|
||||
|
||||
|
||||
const contentOpacity = useSharedValue(1); // Start visible
|
||||
const buttonsOpacity = useSharedValue(1);
|
||||
|
||||
|
||||
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: contentOpacity.value,
|
||||
}));
|
||||
|
|
@ -175,52 +176,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
const preloadImage = async (url: string): Promise<boolean> => {
|
||||
// Skip if already cached to prevent redundant prefetch
|
||||
if (imageCache[url]) return true;
|
||||
|
||||
|
||||
try {
|
||||
// Basic URL validation
|
||||
// Simplified validation to reduce CPU overhead
|
||||
if (!url || typeof url !== 'string') return false;
|
||||
|
||||
// Check if URL appears to be a valid image URL
|
||||
const urlLower = url.toLowerCase();
|
||||
const hasImageExtension = /\.(jpg|jpeg|png|webp|svg)(\?.*)?$/i.test(url);
|
||||
const isImageService = urlLower.includes('image') || urlLower.includes('poster') || urlLower.includes('banner') || urlLower.includes('logo');
|
||||
|
||||
if (!hasImageExtension && !isImageService) {
|
||||
try {
|
||||
// For URLs without clear image extensions, do a quick HEAD request
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) return false;
|
||||
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (!contentType || !contentType.startsWith('image/')) {
|
||||
return false;
|
||||
}
|
||||
} catch (validationError) {
|
||||
// If validation fails, still try to load the image
|
||||
}
|
||||
}
|
||||
|
||||
// Always attempt to prefetch the image regardless of format validation
|
||||
// Add timeout and retry logic for prefetch
|
||||
const prefetchWithTimeout = () => {
|
||||
return Promise.race([
|
||||
ExpoImage.prefetch(url),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Prefetch timeout')), 5000)
|
||||
)
|
||||
]);
|
||||
};
|
||||
|
||||
await prefetchWithTimeout();
|
||||
|
||||
// Use our optimized cache service instead of direct prefetch
|
||||
await imageCacheService.getCachedImageUrl(url);
|
||||
imageCache[url] = true;
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
|
@ -234,27 +196,27 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
useEffect(() => {
|
||||
setLogoLoadError(false);
|
||||
}, [featuredContent?.id]);
|
||||
|
||||
|
||||
// Fetch logo based on preference
|
||||
useEffect(() => {
|
||||
if (!featuredContent || logoFetchInProgress.current) return;
|
||||
|
||||
|
||||
const fetchLogo = async () => {
|
||||
logoFetchInProgress.current = true;
|
||||
|
||||
|
||||
try {
|
||||
const contentId = featuredContent.id;
|
||||
const contentData = featuredContent; // Use a clearer variable name
|
||||
const currentLogo = contentData.logo;
|
||||
|
||||
|
||||
// Get preferences
|
||||
const logoPreference = settings.logoSourcePreference || 'metahub';
|
||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||
|
||||
|
||||
// Reset state for new fetch
|
||||
setLogoUrl(null);
|
||||
setLogoLoadError(false);
|
||||
|
||||
|
||||
// Extract IDs
|
||||
let imdbId: string | null = null;
|
||||
if (contentData.id.startsWith('tt')) {
|
||||
|
|
@ -264,14 +226,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
} else if ((contentData as any).externalIds?.imdb_id) {
|
||||
imdbId = (contentData as any).externalIds.imdb_id;
|
||||
}
|
||||
|
||||
|
||||
let tmdbId: string | null = null;
|
||||
if (contentData.id.startsWith('tmdb:')) {
|
||||
tmdbId = contentData.id.split(':')[1];
|
||||
} else if ((contentData as any).tmdb_id) {
|
||||
tmdbId = String((contentData as any).tmdb_id);
|
||||
tmdbId = String((contentData as any).tmdb_id);
|
||||
}
|
||||
|
||||
|
||||
// If we only have IMDB ID, try to find TMDB ID proactively
|
||||
if (imdbId && !tmdbId) {
|
||||
try {
|
||||
|
|
@ -284,14 +246,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
// logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const tmdbType = contentData.type === 'series' ? 'tv' : 'movie';
|
||||
let finalLogoUrl: string | null = null;
|
||||
let primaryAttempted = false;
|
||||
let fallbackAttempted = false;
|
||||
|
||||
|
||||
// --- Logo Fetching Logic ---
|
||||
|
||||
|
||||
if (logoPreference === 'metahub') {
|
||||
// Primary: Metahub (needs imdbId)
|
||||
if (imdbId) {
|
||||
|
|
@ -304,7 +266,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
}
|
||||
} catch (error) { /* Log if needed */ }
|
||||
}
|
||||
|
||||
|
||||
// Fallback: TMDB (needs tmdbId)
|
||||
if (!finalLogoUrl && tmdbId) {
|
||||
fallbackAttempted = true;
|
||||
|
|
@ -316,7 +278,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
}
|
||||
} catch (error) { /* Log if needed */ }
|
||||
}
|
||||
|
||||
|
||||
} else { // logoPreference === 'tmdb'
|
||||
// Primary: TMDB (needs tmdbId)
|
||||
if (tmdbId) {
|
||||
|
|
@ -329,7 +291,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
}
|
||||
} catch (error) { /* Log if needed */ }
|
||||
}
|
||||
|
||||
|
||||
// Fallback: Metahub (needs imdbId)
|
||||
if (!finalLogoUrl && imdbId) {
|
||||
fallbackAttempted = true;
|
||||
|
|
@ -342,7 +304,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
} catch (error) { /* Log if needed */ }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// --- Set Final Logo ---
|
||||
if (finalLogoUrl) {
|
||||
setLogoUrl(finalLogoUrl);
|
||||
|
|
@ -354,7 +316,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
setLogoLoadError(true);
|
||||
// logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
// logger.error('[FeaturedContent] Error in fetchLogo:', error);
|
||||
setLogoLoadError(true);
|
||||
|
|
@ -362,7 +324,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
logoFetchInProgress.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Trigger fetch when content changes
|
||||
fetchLogo();
|
||||
}, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
|
||||
|
|
@ -370,11 +332,11 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
// Load poster and logo
|
||||
useEffect(() => {
|
||||
if (!featuredContent) return;
|
||||
|
||||
|
||||
const posterUrl = featuredContent.banner || featuredContent.poster;
|
||||
const contentId = featuredContent.id;
|
||||
const isContentChange = contentId !== prevContentIdRef.current;
|
||||
|
||||
|
||||
// Enhanced content change detection and animations
|
||||
if (isContentChange) {
|
||||
// Animate out current content
|
||||
|
|
@ -409,17 +371,17 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
}
|
||||
logoOpacity.value = 0;
|
||||
}
|
||||
|
||||
|
||||
prevContentIdRef.current = contentId;
|
||||
|
||||
|
||||
// Set poster URL for immediate display
|
||||
if (posterUrl) setBannerUrl(posterUrl);
|
||||
|
||||
|
||||
// Load images with enhanced animations
|
||||
const loadImages = async () => {
|
||||
// Small delay to allow fade out animation to complete
|
||||
await new Promise(resolve => setTimeout(resolve, isContentChange && prevContentIdRef.current ? 300 : 0));
|
||||
|
||||
|
||||
// Load poster with enhanced transition
|
||||
if (posterUrl) {
|
||||
const posterSuccess = await preloadImage(posterUrl);
|
||||
|
|
@ -437,7 +399,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
|
||||
// Animate content back in with delay
|
||||
contentOpacity.value = withDelay(200, withTiming(1, {
|
||||
duration: 600,
|
||||
|
|
@ -449,7 +411,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
}));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Load logo if available with enhanced timing
|
||||
if (logoUrl) {
|
||||
const logoSuccess = await preloadImage(logoUrl);
|
||||
|
|
@ -463,7 +425,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
loadImages();
|
||||
}, [featuredContent?.id, logoUrl]);
|
||||
|
||||
|
|
@ -489,7 +451,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
<Animated.View
|
||||
entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))}
|
||||
>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.95}
|
||||
onPress={() => {
|
||||
navigation.navigate('Metadata', {
|
||||
|
|
@ -507,7 +469,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
>
|
||||
{/* Subtle content overlay for better readability */}
|
||||
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
|
||||
|
||||
|
||||
<LinearGradient
|
||||
colors={[
|
||||
'rgba(0,0,0,0.1)',
|
||||
|
|
@ -519,13 +481,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
locations={[0, 0.2, 0.5, 0.8, 1]}
|
||||
style={styles.featuredGradient as ViewStyle}
|
||||
>
|
||||
<Animated.View
|
||||
<Animated.View
|
||||
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
||||
>
|
||||
{logoUrl && !logoLoadError ? (
|
||||
<Animated.View style={logoAnimatedStyle}>
|
||||
<ExpoImage
|
||||
source={{ uri: logoUrl }}
|
||||
<ExpoImage
|
||||
source={{ uri: logoUrl }}
|
||||
style={styles.featuredLogo as ImageStyle}
|
||||
contentFit="contain"
|
||||
cachePolicy="memory"
|
||||
|
|
@ -554,27 +516,27 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
</Animated.View>
|
||||
|
||||
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.myListButton as ViewStyle}
|
||||
onPress={handleSaveToLibrary}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={isSaved ? "bookmark" : "bookmark-border"}
|
||||
size={24}
|
||||
color={currentTheme.colors.white}
|
||||
<MaterialIcons
|
||||
name={isSaved ? "bookmark" : "bookmark-border"}
|
||||
size={24}
|
||||
color={currentTheme.colors.white}
|
||||
/>
|
||||
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{isSaved ? "Saved" : "Save"}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
|
||||
onPress={() => {
|
||||
if (featuredContent) {
|
||||
navigation.navigate('Streams', {
|
||||
id: featuredContent.id,
|
||||
navigation.navigate('Streams', {
|
||||
id: featuredContent.id,
|
||||
type: featuredContent.type
|
||||
});
|
||||
}
|
||||
|
|
@ -587,7 +549,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.infoButton as ViewStyle}
|
||||
onPress={handleInfoPress}
|
||||
activeOpacity={0.7}
|
||||
|
|
|
|||
|
|
@ -396,9 +396,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
clearInterval(progressSaveInterval);
|
||||
}
|
||||
|
||||
// Use the user's configured sync frequency instead of hard-coded 5000ms
|
||||
// But ensure we have a minimum interval of 5 seconds
|
||||
const syncInterval = Math.max(5000, traktSettings.syncFrequency);
|
||||
// Use the user's configured sync frequency with increased minimum to reduce heating
|
||||
// Minimum interval increased from 5s to 30s to reduce CPU usage
|
||||
const syncInterval = Math.max(30000, traktSettings.syncFrequency);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
saveWatchProgress();
|
||||
|
|
|
|||
|
|
@ -391,9 +391,9 @@ const VideoPlayer: React.FC = () => {
|
|||
clearInterval(progressSaveInterval);
|
||||
}
|
||||
|
||||
// Use the user's configured sync frequency instead of hard-coded 5000ms
|
||||
// But ensure we have a minimum interval of 5 seconds
|
||||
const syncInterval = Math.max(5000, traktSettings.syncFrequency);
|
||||
// Use the user's configured sync frequency with increased minimum to reduce heating
|
||||
// Minimum interval increased from 5s to 30s to reduce CPU usage
|
||||
const syncInterval = Math.max(30000, traktSettings.syncFrequency);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
saveWatchProgress();
|
||||
|
|
|
|||
|
|
@ -304,7 +304,8 @@ export function useFeaturedContent() {
|
|||
}
|
||||
};
|
||||
|
||||
const intervalId = setInterval(rotateContent, 15000);
|
||||
// Increased rotation interval from 15s to 45s to reduce heating
|
||||
const intervalId = setInterval(rotateContent, 45000);
|
||||
|
||||
return () => clearInterval(intervalId);
|
||||
}, [allFeaturedContent]);
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ 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';
|
||||
|
||||
// Constants
|
||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||
|
|
@ -324,21 +325,22 @@ const HomeScreen = () => {
|
|||
};
|
||||
}, [currentTheme.colors.darkBackground]);
|
||||
|
||||
// Preload images function - memoized to avoid recreating on every render
|
||||
// Optimized preload images function with better memory management
|
||||
const preloadImages = useCallback(async (content: StreamingContent[]) => {
|
||||
if (!content.length) return;
|
||||
|
||||
try {
|
||||
// Limit concurrent prefetching to prevent memory pressure
|
||||
const MAX_CONCURRENT_PREFETCH = 5;
|
||||
const BATCH_SIZE = 3;
|
||||
// Significantly reduced concurrent prefetching to prevent heating
|
||||
const BATCH_SIZE = 2; // Reduced from 3 to 2
|
||||
const MAX_IMAGES = 5; // Reduced from 10 to 5
|
||||
|
||||
const allImages = content.slice(0, 10) // Limit total images to prefetch
|
||||
.map(item => [item.poster, item.banner, item.logo])
|
||||
// Only preload the most important images (poster and banner, skip logo)
|
||||
const allImages = content.slice(0, MAX_IMAGES)
|
||||
.map(item => [item.poster, item.banner])
|
||||
.flat()
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// Process in small batches to prevent memory pressure
|
||||
// Process in smaller batches with longer delays
|
||||
for (let i = 0; i < allImages.length; i += BATCH_SIZE) {
|
||||
const batch = allImages.slice(i, i + BATCH_SIZE);
|
||||
|
||||
|
|
@ -346,18 +348,19 @@ const HomeScreen = () => {
|
|||
await Promise.all(
|
||||
batch.map(async (imageUrl) => {
|
||||
try {
|
||||
await ExpoImage.prefetch(imageUrl);
|
||||
// Small delay between prefetches to reduce memory pressure
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
// Use our cache service instead of direct prefetch
|
||||
await imageCacheService.getCachedImageUrl(imageUrl);
|
||||
// Increased delay between prefetches to reduce CPU load
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
} catch (error) {
|
||||
// Silently handle individual prefetch errors
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Delay between batches to allow GC
|
||||
// Longer delay between batches to allow GC and reduce heating
|
||||
if (i + BATCH_SIZE < allImages.length) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
} catch (error) {
|
||||
// Continue with next batch if current batch fails
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Alert,
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
|
|
@ -17,6 +18,8 @@ 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();
|
||||
|
|
@ -30,13 +33,19 @@ const NotificationSettingsScreen = () => {
|
|||
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 on mount
|
||||
// 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 {
|
||||
|
|
@ -47,6 +56,14 @@ const NotificationSettingsScreen = () => {
|
|||
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;
|
||||
|
|
@ -122,6 +139,29 @@ const NotificationSettingsScreen = () => {
|
|||
);
|
||||
};
|
||||
|
||||
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
|
||||
|
|
@ -295,6 +335,54 @@ const NotificationSettingsScreen = () => {
|
|||
</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>
|
||||
|
||||
|
|
@ -368,6 +456,7 @@ const styles = StyleSheet.create({
|
|||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 12,
|
||||
borderBottomWidth: 1,
|
||||
},
|
||||
backButton: {
|
||||
|
|
@ -465,6 +554,27 @@ const styles = StyleSheet.create({
|
|||
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;
|
||||
|
|
@ -641,18 +641,47 @@ class CatalogService {
|
|||
};
|
||||
}
|
||||
|
||||
public addToLibrary(content: StreamingContent): void {
|
||||
public async addToLibrary(content: StreamingContent): Promise<void> {
|
||||
const key = `${content.type}:${content.id}`;
|
||||
this.library[key] = content;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public removeFromLibrary(type: string, id: string): void {
|
||||
public async removeFromLibrary(type: string, id: string): Promise<void> {
|
||||
const key = `${type}:${id}`;
|
||||
delete this.library[key];
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private addToRecentContent(content: StreamingContent): void {
|
||||
|
|
|
|||
|
|
@ -1,16 +1,30 @@
|
|||
import { logger } from '../utils/logger';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
|
||||
interface CachedImage {
|
||||
url: string;
|
||||
localPath: string;
|
||||
timestamp: number;
|
||||
expiresAt: number;
|
||||
size?: number; // Track approximate memory usage
|
||||
accessCount: number; // Track usage frequency
|
||||
lastAccessed: number; // Track last access time
|
||||
}
|
||||
|
||||
class ImageCacheService {
|
||||
private cache = new Map<string, CachedImage>();
|
||||
private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
||||
private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images
|
||||
private readonly CACHE_DURATION = 12 * 60 * 60 * 1000; // Reduced to 12 hours
|
||||
private readonly MAX_CACHE_SIZE = 50; // Reduced maximum number of cached images
|
||||
private readonly MAX_MEMORY_MB = 100; // Maximum memory usage in MB
|
||||
private currentMemoryUsage = 0;
|
||||
private cleanupInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor() {
|
||||
// Start cleanup interval every 10 minutes
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.performCleanup();
|
||||
}, 10 * 60 * 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached image URL or cache the original if not present
|
||||
|
|
@ -23,24 +37,38 @@ class ImageCacheService {
|
|||
// Check if we have a valid cached version
|
||||
const cached = this.cache.get(originalUrl);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`);
|
||||
// Update access tracking
|
||||
cached.accessCount++;
|
||||
cached.lastAccessed = Date.now();
|
||||
logger.log(`[ImageCache] Retrieved from cache: ${originalUrl.substring(0, 50)}...`);
|
||||
return cached.localPath;
|
||||
}
|
||||
|
||||
// Check memory pressure before adding new entries
|
||||
if (this.shouldSkipCaching()) {
|
||||
logger.log(`[ImageCache] Skipping cache due to memory pressure`);
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
// For now, return the original URL but mark it as cached
|
||||
// In a production app, you would implement actual local caching here
|
||||
// Estimate image size (rough approximation)
|
||||
const estimatedSize = this.estimateImageSize(originalUrl);
|
||||
|
||||
const cachedImage: CachedImage = {
|
||||
url: originalUrl,
|
||||
localPath: originalUrl, // In production, this would be a local file path
|
||||
timestamp: Date.now(),
|
||||
expiresAt: Date.now() + this.CACHE_DURATION,
|
||||
size: estimatedSize,
|
||||
accessCount: 1,
|
||||
lastAccessed: Date.now()
|
||||
};
|
||||
|
||||
this.cache.set(originalUrl, cachedImage);
|
||||
this.enforceMaxCacheSize();
|
||||
this.currentMemoryUsage += estimatedSize;
|
||||
this.enforceMemoryLimits();
|
||||
|
||||
logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Cache size: ${this.cache.size})`);
|
||||
logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl.substring(0, 50)}... (Cache: ${this.cache.size}/${this.MAX_CACHE_SIZE}, Memory: ${(this.currentMemoryUsage / 1024 / 1024).toFixed(1)}MB)`);
|
||||
return cachedImage.localPath;
|
||||
} catch (error) {
|
||||
logger.error('[ImageCache] Failed to cache image:', error);
|
||||
|
|
@ -62,7 +90,7 @@ class ImageCacheService {
|
|||
public logCacheStatus(): void {
|
||||
const stats = this.getCacheStats();
|
||||
logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`);
|
||||
|
||||
|
||||
// Log first 5 cached URLs for debugging
|
||||
const entries = Array.from(this.cache.entries()).slice(0, 5);
|
||||
entries.forEach(([url, cached]) => {
|
||||
|
|
@ -98,7 +126,7 @@ class ImageCacheService {
|
|||
public getCacheStats(): { size: number; expired: number } {
|
||||
const now = Date.now();
|
||||
let expired = 0;
|
||||
|
||||
|
||||
for (const cached of this.cache.values()) {
|
||||
if (cached.expiresAt <= now) {
|
||||
expired++;
|
||||
|
|
@ -132,6 +160,112 @@ class ImageCacheService {
|
|||
|
||||
logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforce memory limits using LRU eviction
|
||||
*/
|
||||
private enforceMemoryLimits(): void {
|
||||
const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024;
|
||||
|
||||
if (this.currentMemoryUsage <= maxMemoryBytes) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by access frequency and recency (LRU)
|
||||
const entries = Array.from(this.cache.entries()).sort((a, b) => {
|
||||
const scoreA = a[1].accessCount * 0.3 + (Date.now() - a[1].lastAccessed) * 0.7;
|
||||
const scoreB = b[1].accessCount * 0.3 + (Date.now() - b[1].lastAccessed) * 0.7;
|
||||
return scoreB - scoreA; // Higher score = more likely to be evicted
|
||||
});
|
||||
|
||||
let removedCount = 0;
|
||||
for (const [url, cached] of entries) {
|
||||
if (this.currentMemoryUsage <= maxMemoryBytes * 0.8) { // Leave 20% buffer
|
||||
break;
|
||||
}
|
||||
|
||||
this.cache.delete(url);
|
||||
this.currentMemoryUsage -= cached.size || 0;
|
||||
removedCount++;
|
||||
}
|
||||
|
||||
if (removedCount > 0) {
|
||||
logger.log(`[ImageCache] Evicted ${removedCount} entries to free memory. Current usage: ${(this.currentMemoryUsage / 1024 / 1024).toFixed(1)}MB`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Estimate image size based on URL patterns
|
||||
*/
|
||||
private estimateImageSize(url: string): number {
|
||||
// Rough estimates in bytes based on common image types
|
||||
if (url.includes('poster')) return 150 * 1024; // 150KB for posters
|
||||
if (url.includes('banner') || url.includes('backdrop')) return 300 * 1024; // 300KB for banners
|
||||
if (url.includes('logo')) return 50 * 1024; // 50KB for logos
|
||||
if (url.includes('thumb')) return 75 * 1024; // 75KB for thumbnails
|
||||
return 200 * 1024; // Default 200KB
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should skip caching due to memory pressure
|
||||
*/
|
||||
private shouldSkipCaching(): boolean {
|
||||
const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024;
|
||||
return this.currentMemoryUsage > maxMemoryBytes * 0.9 || this.cache.size >= this.MAX_CACHE_SIZE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform comprehensive cleanup
|
||||
*/
|
||||
private performCleanup(): void {
|
||||
const initialSize = this.cache.size;
|
||||
const initialMemory = this.currentMemoryUsage;
|
||||
|
||||
// Remove expired entries
|
||||
this.clearExpiredCache();
|
||||
|
||||
// Recalculate memory usage
|
||||
this.recalculateMemoryUsage();
|
||||
|
||||
// Enforce limits
|
||||
this.enforceMemoryLimits();
|
||||
this.enforceMaxCacheSize();
|
||||
|
||||
// Clear Expo image memory cache periodically
|
||||
try {
|
||||
ExpoImage.clearMemoryCache();
|
||||
} catch (error) {
|
||||
// Ignore errors from clearing memory cache
|
||||
}
|
||||
|
||||
const finalSize = this.cache.size;
|
||||
const finalMemory = this.currentMemoryUsage;
|
||||
|
||||
if (initialSize !== finalSize || Math.abs(initialMemory - finalMemory) > 1024 * 1024) {
|
||||
logger.log(`[ImageCache] Cleanup completed: ${initialSize}→${finalSize} entries, ${(initialMemory / 1024 / 1024).toFixed(1)}→${(finalMemory / 1024 / 1024).toFixed(1)}MB`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recalculate memory usage from cache entries
|
||||
*/
|
||||
private recalculateMemoryUsage(): void {
|
||||
this.currentMemoryUsage = 0;
|
||||
for (const cached of this.cache.values()) {
|
||||
this.currentMemoryUsage += cached.size || 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
public destroy(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval);
|
||||
this.cleanupInterval = null;
|
||||
}
|
||||
this.clearAllCache();
|
||||
}
|
||||
}
|
||||
|
||||
export const imageCacheService = new ImageCacheService();
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
import { Platform, AppState, AppStateStatus } from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { parseISO, differenceInHours, isToday, addDays } from 'date-fns';
|
||||
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
|
||||
|
|
@ -47,12 +50,18 @@ 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 constructor() {
|
||||
// Initialize notifications
|
||||
this.configureNotifications();
|
||||
this.loadSettings();
|
||||
this.loadScheduledNotifications();
|
||||
this.setupLibraryIntegration();
|
||||
this.setupBackgroundSync();
|
||||
this.setupAppStateHandling();
|
||||
}
|
||||
|
||||
static getInstance(): NotificationService {
|
||||
|
|
@ -238,47 +247,376 @@ class NotificationService {
|
|||
return [...this.scheduledNotifications];
|
||||
}
|
||||
|
||||
// Update notifications for a library item
|
||||
// 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;
|
||||
|
||||
logger.log('[NotificationService] Library updated, syncing notifications for', libraryItems.length, 'items');
|
||||
await this.syncNotificationsForLibrary(libraryItems);
|
||||
});
|
||||
} 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) {
|
||||
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) {
|
||||
// App came to foreground, sync notifications
|
||||
logger.log('[NotificationService] App became active, syncing notifications');
|
||||
await this.performBackgroundSync();
|
||||
}
|
||||
};
|
||||
|
||||
// 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));
|
||||
}
|
||||
|
||||
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 {
|
||||
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();
|
||||
|
||||
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 (!isAuthenticated) {
|
||||
logger.log('[NotificationService] Trakt not authenticated, skipping Trakt sync');
|
||||
return;
|
||||
}
|
||||
|
||||
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'
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Get metadata for the series
|
||||
const metadata = await stremioService.getMetaDetails('series', seriesId);
|
||||
logger.log(`[NotificationService] Updating notifications for series: ${seriesId}`);
|
||||
|
||||
if (!metadata || !metadata.videos) {
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Get upcoming episodes
|
||||
const now = new Date();
|
||||
const fourWeeksLater = addDays(now, 28);
|
||||
|
||||
const upcomingEpisodes = metadata.videos.filter(video => {
|
||||
if (!video.released) return false;
|
||||
const releaseDate = parseISO(video.released);
|
||||
return releaseDate > now && releaseDate < fourWeeksLater;
|
||||
});
|
||||
|
||||
// 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
|
||||
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,
|
||||
}));
|
||||
|
||||
await this.scheduleMultipleEpisodeNotifications(notificationItems);
|
||||
// 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);
|
||||
logger.log(`[NotificationService] Scheduled ${scheduledCount} notifications for ${metadata.name}`);
|
||||
} else {
|
||||
logger.log(`[NotificationService] No upcoming episodes found for ${metadata.name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error updating notifications for series ${seriesId}:`, 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();
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -282,8 +282,8 @@ export class TraktService {
|
|||
private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80%
|
||||
|
||||
private constructor() {
|
||||
// Initialize the cleanup interval for old stop calls
|
||||
setInterval(() => this.cleanupOldStopCalls(), 5 * 60 * 1000); // Clean up every 5 minutes
|
||||
// Increased cleanup interval from 5 minutes to 15 minutes to reduce heating
|
||||
setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes
|
||||
|
||||
// Load user settings
|
||||
this.loadCompletionThreshold();
|
||||
|
|
|
|||