some fixes with updating notifications
5
.gitignore
vendored
|
|
@ -39,4 +39,7 @@ release_announcement.md
|
||||||
ALPHA_BUILD_2_ANNOUNCEMENT.md
|
ALPHA_BUILD_2_ANNOUNCEMENT.md
|
||||||
CHANGELOG.md
|
CHANGELOG.md
|
||||||
.env.local
|
.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 {
|
androidResources {
|
||||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~'
|
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`
|
// 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">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/iconBackground"/>
|
<background android:drawable="@color/iconBackground"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
|
|
@ -2,5 +2,4 @@
|
||||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/iconBackground"/>
|
<background android:drawable="@color/iconBackground"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
|
||||||
</adaptive-icon>
|
</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
|
||||||
@rem Copyright 2015 the original author or authors.
|
@rem Copyright 2015 the original author or authors.
|
||||||
@rem
|
@rem
|
||||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
@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 not use this file except in compliance with the License.
|
||||||
@rem You may obtain a copy of the License at
|
@rem You may obtain a copy of the License at
|
||||||
@rem
|
@rem
|
||||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||||
@rem
|
@rem
|
||||||
@rem Unless required by applicable law or agreed to in writing, software
|
@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 distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
@rem SPDX-License-Identifier: Apache-2.0
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
@rem
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem Gradle startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables with windows NT shell
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@rem This is normally unused
|
@rem This is normally unused
|
||||||
set APP_BASE_NAME=%~n0
|
set APP_BASE_NAME=%~n0
|
||||||
set APP_HOME=%DIRNAME%
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
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.
|
@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"
|
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||||
|
|
||||||
@rem Find java.exe
|
@rem Find java.exe
|
||||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
set JAVA_EXE=java.exe
|
set JAVA_EXE=java.exe
|
||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 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. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 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
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
echo. 1>&2
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the 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
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
goto fail
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute Gradle
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||||
|
|
||||||
:end
|
:end
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem End local scope for the variables with windows NT shell
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||||
|
|
||||||
:fail
|
:fail
|
||||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
rem the _cmd.exe /c_ return code!
|
rem the _cmd.exe /c_ return code!
|
||||||
set EXIT_CODE=%ERRORLEVEL%
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
exit /b %EXIT_CODE%
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
:mainEnd
|
:mainEnd
|
||||||
if "%OS%"=="Windows_NT" endlocal
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
:omega
|
:omega
|
||||||
|
|
|
||||||
21
app.json
|
|
@ -32,19 +32,7 @@
|
||||||
"UIFileSharingEnabled": true
|
"UIFileSharingEnabled": true
|
||||||
},
|
},
|
||||||
"bundleIdentifier": "com.nuvio.app",
|
"bundleIdentifier": "com.nuvio.app",
|
||||||
"associatedDomains": [],
|
"associatedDomains": []
|
||||||
"documentTypes": [
|
|
||||||
{
|
|
||||||
"name": "Matroska Video",
|
|
||||||
"role": "viewer",
|
|
||||||
"utis": [
|
|
||||||
"org.matroska.mkv"
|
|
||||||
],
|
|
||||||
"extensions": [
|
|
||||||
"mkv"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
|
|
@ -57,12 +45,7 @@
|
||||||
"WAKE_LOCK"
|
"WAKE_LOCK"
|
||||||
],
|
],
|
||||||
"package": "com.nuvio.app",
|
"package": "com.nuvio.app",
|
||||||
"enableSplitAPKs": true,
|
"versionCode": 1
|
||||||
"versionCode": 1,
|
|
||||||
"enableProguardInReleaseBuilds": true,
|
|
||||||
"enableHermes": true,
|
|
||||||
"enableSeparateBuildPerCPUArchitecture": true,
|
|
||||||
"enableVectorDrawables": true
|
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"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 {
|
} else {
|
||||||
// Fallback: poll for updates every 30 seconds
|
// Reduced polling frequency from 30s to 2 minutes to reduce heating
|
||||||
const intervalId = setInterval(() => loadContinueWatching(true), 30000);
|
const intervalId = setInterval(() => loadContinueWatching(true), 120000);
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
clearInterval(intervalId);
|
clearInterval(intervalId);
|
||||||
|
|
|
||||||
|
|
@ -17,10 +17,10 @@ import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import Animated, {
|
import Animated, {
|
||||||
FadeIn,
|
FadeIn,
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
withTiming,
|
withTiming,
|
||||||
Easing,
|
Easing,
|
||||||
withDelay
|
withDelay
|
||||||
|
|
@ -32,6 +32,7 @@ import { useSettings } from '../../hooks/useSettings';
|
||||||
import { TMDBService } from '../../services/tmdbService';
|
import { TMDBService } from '../../services/tmdbService';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { useTheme } from '../../contexts/ThemeContext';
|
import { useTheme } from '../../contexts/ThemeContext';
|
||||||
|
import { imageCacheService } from '../../services/imageCacheService';
|
||||||
|
|
||||||
interface FeaturedContentProps {
|
interface FeaturedContentProps {
|
||||||
featuredContent: StreamingContent | null;
|
featuredContent: StreamingContent | null;
|
||||||
|
|
@ -137,12 +138,12 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
const [logoLoadError, setLogoLoadError] = useState(false);
|
const [logoLoadError, setLogoLoadError] = useState(false);
|
||||||
// Add a ref to track logo fetch in progress
|
// Add a ref to track logo fetch in progress
|
||||||
const logoFetchInProgress = useRef<boolean>(false);
|
const logoFetchInProgress = useRef<boolean>(false);
|
||||||
|
|
||||||
// Enhanced poster transition animations
|
// Enhanced poster transition animations
|
||||||
const posterScale = useSharedValue(1);
|
const posterScale = useSharedValue(1);
|
||||||
const posterTranslateY = useSharedValue(0);
|
const posterTranslateY = useSharedValue(0);
|
||||||
const overlayOpacity = useSharedValue(0.15);
|
const overlayOpacity = useSharedValue(0.15);
|
||||||
|
|
||||||
// Animation values
|
// Animation values
|
||||||
const posterAnimatedStyle = useAnimatedStyle(() => ({
|
const posterAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
opacity: posterOpacity.value,
|
opacity: posterOpacity.value,
|
||||||
|
|
@ -151,14 +152,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
{ translateY: posterTranslateY.value }
|
{ translateY: posterTranslateY.value }
|
||||||
],
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
const logoAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
opacity: logoOpacity.value,
|
opacity: logoOpacity.value,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const contentOpacity = useSharedValue(1); // Start visible
|
const contentOpacity = useSharedValue(1); // Start visible
|
||||||
const buttonsOpacity = useSharedValue(1);
|
const buttonsOpacity = useSharedValue(1);
|
||||||
|
|
||||||
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
const contentAnimatedStyle = useAnimatedStyle(() => ({
|
||||||
opacity: contentOpacity.value,
|
opacity: contentOpacity.value,
|
||||||
}));
|
}));
|
||||||
|
|
@ -175,52 +176,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
const preloadImage = async (url: string): Promise<boolean> => {
|
const preloadImage = async (url: string): Promise<boolean> => {
|
||||||
// Skip if already cached to prevent redundant prefetch
|
// Skip if already cached to prevent redundant prefetch
|
||||||
if (imageCache[url]) return true;
|
if (imageCache[url]) return true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Basic URL validation
|
// Simplified validation to reduce CPU overhead
|
||||||
if (!url || typeof url !== 'string') return false;
|
if (!url || typeof url !== 'string') return false;
|
||||||
|
|
||||||
// Check if URL appears to be a valid image URL
|
// Use our optimized cache service instead of direct prefetch
|
||||||
const urlLower = url.toLowerCase();
|
await imageCacheService.getCachedImageUrl(url);
|
||||||
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();
|
|
||||||
imageCache[url] = true;
|
imageCache[url] = true;
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -234,27 +196,27 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLogoLoadError(false);
|
setLogoLoadError(false);
|
||||||
}, [featuredContent?.id]);
|
}, [featuredContent?.id]);
|
||||||
|
|
||||||
// Fetch logo based on preference
|
// Fetch logo based on preference
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!featuredContent || logoFetchInProgress.current) return;
|
if (!featuredContent || logoFetchInProgress.current) return;
|
||||||
|
|
||||||
const fetchLogo = async () => {
|
const fetchLogo = async () => {
|
||||||
logoFetchInProgress.current = true;
|
logoFetchInProgress.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const contentId = featuredContent.id;
|
const contentId = featuredContent.id;
|
||||||
const contentData = featuredContent; // Use a clearer variable name
|
const contentData = featuredContent; // Use a clearer variable name
|
||||||
const currentLogo = contentData.logo;
|
const currentLogo = contentData.logo;
|
||||||
|
|
||||||
// Get preferences
|
// Get preferences
|
||||||
const logoPreference = settings.logoSourcePreference || 'metahub';
|
const logoPreference = settings.logoSourcePreference || 'metahub';
|
||||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||||
|
|
||||||
// Reset state for new fetch
|
// Reset state for new fetch
|
||||||
setLogoUrl(null);
|
setLogoUrl(null);
|
||||||
setLogoLoadError(false);
|
setLogoLoadError(false);
|
||||||
|
|
||||||
// Extract IDs
|
// Extract IDs
|
||||||
let imdbId: string | null = null;
|
let imdbId: string | null = null;
|
||||||
if (contentData.id.startsWith('tt')) {
|
if (contentData.id.startsWith('tt')) {
|
||||||
|
|
@ -264,14 +226,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
} else if ((contentData as any).externalIds?.imdb_id) {
|
} else if ((contentData as any).externalIds?.imdb_id) {
|
||||||
imdbId = (contentData as any).externalIds.imdb_id;
|
imdbId = (contentData as any).externalIds.imdb_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
let tmdbId: string | null = null;
|
let tmdbId: string | null = null;
|
||||||
if (contentData.id.startsWith('tmdb:')) {
|
if (contentData.id.startsWith('tmdb:')) {
|
||||||
tmdbId = contentData.id.split(':')[1];
|
tmdbId = contentData.id.split(':')[1];
|
||||||
} else if ((contentData as any).tmdb_id) {
|
} 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 we only have IMDB ID, try to find TMDB ID proactively
|
||||||
if (imdbId && !tmdbId) {
|
if (imdbId && !tmdbId) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -284,14 +246,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
// logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError);
|
// logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tmdbType = contentData.type === 'series' ? 'tv' : 'movie';
|
const tmdbType = contentData.type === 'series' ? 'tv' : 'movie';
|
||||||
let finalLogoUrl: string | null = null;
|
let finalLogoUrl: string | null = null;
|
||||||
let primaryAttempted = false;
|
let primaryAttempted = false;
|
||||||
let fallbackAttempted = false;
|
let fallbackAttempted = false;
|
||||||
|
|
||||||
// --- Logo Fetching Logic ---
|
// --- Logo Fetching Logic ---
|
||||||
|
|
||||||
if (logoPreference === 'metahub') {
|
if (logoPreference === 'metahub') {
|
||||||
// Primary: Metahub (needs imdbId)
|
// Primary: Metahub (needs imdbId)
|
||||||
if (imdbId) {
|
if (imdbId) {
|
||||||
|
|
@ -304,7 +266,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
}
|
}
|
||||||
} catch (error) { /* Log if needed */ }
|
} catch (error) { /* Log if needed */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: TMDB (needs tmdbId)
|
// Fallback: TMDB (needs tmdbId)
|
||||||
if (!finalLogoUrl && tmdbId) {
|
if (!finalLogoUrl && tmdbId) {
|
||||||
fallbackAttempted = true;
|
fallbackAttempted = true;
|
||||||
|
|
@ -316,7 +278,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
}
|
}
|
||||||
} catch (error) { /* Log if needed */ }
|
} catch (error) { /* Log if needed */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
} else { // logoPreference === 'tmdb'
|
} else { // logoPreference === 'tmdb'
|
||||||
// Primary: TMDB (needs tmdbId)
|
// Primary: TMDB (needs tmdbId)
|
||||||
if (tmdbId) {
|
if (tmdbId) {
|
||||||
|
|
@ -329,7 +291,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
}
|
}
|
||||||
} catch (error) { /* Log if needed */ }
|
} catch (error) { /* Log if needed */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Metahub (needs imdbId)
|
// Fallback: Metahub (needs imdbId)
|
||||||
if (!finalLogoUrl && imdbId) {
|
if (!finalLogoUrl && imdbId) {
|
||||||
fallbackAttempted = true;
|
fallbackAttempted = true;
|
||||||
|
|
@ -342,7 +304,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
} catch (error) { /* Log if needed */ }
|
} catch (error) { /* Log if needed */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Set Final Logo ---
|
// --- Set Final Logo ---
|
||||||
if (finalLogoUrl) {
|
if (finalLogoUrl) {
|
||||||
setLogoUrl(finalLogoUrl);
|
setLogoUrl(finalLogoUrl);
|
||||||
|
|
@ -354,7 +316,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
setLogoLoadError(true);
|
setLogoLoadError(true);
|
||||||
// logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`);
|
// logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// logger.error('[FeaturedContent] Error in fetchLogo:', error);
|
// logger.error('[FeaturedContent] Error in fetchLogo:', error);
|
||||||
setLogoLoadError(true);
|
setLogoLoadError(true);
|
||||||
|
|
@ -362,7 +324,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
logoFetchInProgress.current = false;
|
logoFetchInProgress.current = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Trigger fetch when content changes
|
// Trigger fetch when content changes
|
||||||
fetchLogo();
|
fetchLogo();
|
||||||
}, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
|
}, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]);
|
||||||
|
|
@ -370,11 +332,11 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
// Load poster and logo
|
// Load poster and logo
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!featuredContent) return;
|
if (!featuredContent) return;
|
||||||
|
|
||||||
const posterUrl = featuredContent.banner || featuredContent.poster;
|
const posterUrl = featuredContent.banner || featuredContent.poster;
|
||||||
const contentId = featuredContent.id;
|
const contentId = featuredContent.id;
|
||||||
const isContentChange = contentId !== prevContentIdRef.current;
|
const isContentChange = contentId !== prevContentIdRef.current;
|
||||||
|
|
||||||
// Enhanced content change detection and animations
|
// Enhanced content change detection and animations
|
||||||
if (isContentChange) {
|
if (isContentChange) {
|
||||||
// Animate out current content
|
// Animate out current content
|
||||||
|
|
@ -409,17 +371,17 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
}
|
}
|
||||||
logoOpacity.value = 0;
|
logoOpacity.value = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
prevContentIdRef.current = contentId;
|
prevContentIdRef.current = contentId;
|
||||||
|
|
||||||
// Set poster URL for immediate display
|
// Set poster URL for immediate display
|
||||||
if (posterUrl) setBannerUrl(posterUrl);
|
if (posterUrl) setBannerUrl(posterUrl);
|
||||||
|
|
||||||
// Load images with enhanced animations
|
// Load images with enhanced animations
|
||||||
const loadImages = async () => {
|
const loadImages = async () => {
|
||||||
// Small delay to allow fade out animation to complete
|
// Small delay to allow fade out animation to complete
|
||||||
await new Promise(resolve => setTimeout(resolve, isContentChange && prevContentIdRef.current ? 300 : 0));
|
await new Promise(resolve => setTimeout(resolve, isContentChange && prevContentIdRef.current ? 300 : 0));
|
||||||
|
|
||||||
// Load poster with enhanced transition
|
// Load poster with enhanced transition
|
||||||
if (posterUrl) {
|
if (posterUrl) {
|
||||||
const posterSuccess = await preloadImage(posterUrl);
|
const posterSuccess = await preloadImage(posterUrl);
|
||||||
|
|
@ -437,7 +399,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
duration: 600,
|
duration: 600,
|
||||||
easing: Easing.out(Easing.cubic)
|
easing: Easing.out(Easing.cubic)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animate content back in with delay
|
// Animate content back in with delay
|
||||||
contentOpacity.value = withDelay(200, withTiming(1, {
|
contentOpacity.value = withDelay(200, withTiming(1, {
|
||||||
duration: 600,
|
duration: 600,
|
||||||
|
|
@ -449,7 +411,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load logo if available with enhanced timing
|
// Load logo if available with enhanced timing
|
||||||
if (logoUrl) {
|
if (logoUrl) {
|
||||||
const logoSuccess = await preloadImage(logoUrl);
|
const logoSuccess = await preloadImage(logoUrl);
|
||||||
|
|
@ -463,7 +425,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
loadImages();
|
loadImages();
|
||||||
}, [featuredContent?.id, logoUrl]);
|
}, [featuredContent?.id, logoUrl]);
|
||||||
|
|
||||||
|
|
@ -489,7 +451,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))}
|
entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))}
|
||||||
>
|
>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
activeOpacity={0.95}
|
activeOpacity={0.95}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
navigation.navigate('Metadata', {
|
navigation.navigate('Metadata', {
|
||||||
|
|
@ -507,7 +469,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
>
|
>
|
||||||
{/* Subtle content overlay for better readability */}
|
{/* Subtle content overlay for better readability */}
|
||||||
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
|
<Animated.View style={[styles.contentOverlay, overlayAnimatedStyle]} />
|
||||||
|
|
||||||
<LinearGradient
|
<LinearGradient
|
||||||
colors={[
|
colors={[
|
||||||
'rgba(0,0,0,0.1)',
|
'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]}
|
locations={[0, 0.2, 0.5, 0.8, 1]}
|
||||||
style={styles.featuredGradient as ViewStyle}
|
style={styles.featuredGradient as ViewStyle}
|
||||||
>
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
style={[styles.featuredContentContainer as ViewStyle, contentAnimatedStyle]}
|
||||||
>
|
>
|
||||||
{logoUrl && !logoLoadError ? (
|
{logoUrl && !logoLoadError ? (
|
||||||
<Animated.View style={logoAnimatedStyle}>
|
<Animated.View style={logoAnimatedStyle}>
|
||||||
<ExpoImage
|
<ExpoImage
|
||||||
source={{ uri: logoUrl }}
|
source={{ uri: logoUrl }}
|
||||||
style={styles.featuredLogo as ImageStyle}
|
style={styles.featuredLogo as ImageStyle}
|
||||||
contentFit="contain"
|
contentFit="contain"
|
||||||
cachePolicy="memory"
|
cachePolicy="memory"
|
||||||
|
|
@ -554,27 +516,27 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
|
<Animated.View style={[styles.featuredButtons as ViewStyle, buttonsAnimatedStyle]}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.myListButton as ViewStyle}
|
style={styles.myListButton as ViewStyle}
|
||||||
onPress={handleSaveToLibrary}
|
onPress={handleSaveToLibrary}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<MaterialIcons
|
<MaterialIcons
|
||||||
name={isSaved ? "bookmark" : "bookmark-border"}
|
name={isSaved ? "bookmark" : "bookmark-border"}
|
||||||
size={24}
|
size={24}
|
||||||
color={currentTheme.colors.white}
|
color={currentTheme.colors.white}
|
||||||
/>
|
/>
|
||||||
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||||
{isSaved ? "Saved" : "Save"}
|
{isSaved ? "Saved" : "Save"}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
|
style={[styles.playButton as ViewStyle, { backgroundColor: currentTheme.colors.white }]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (featuredContent) {
|
if (featuredContent) {
|
||||||
navigation.navigate('Streams', {
|
navigation.navigate('Streams', {
|
||||||
id: featuredContent.id,
|
id: featuredContent.id,
|
||||||
type: featuredContent.type
|
type: featuredContent.type
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -587,7 +549,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.infoButton as ViewStyle}
|
style={styles.infoButton as ViewStyle}
|
||||||
onPress={handleInfoPress}
|
onPress={handleInfoPress}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.7}
|
||||||
|
|
|
||||||
|
|
@ -396,9 +396,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
||||||
clearInterval(progressSaveInterval);
|
clearInterval(progressSaveInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the user's configured sync frequency instead of hard-coded 5000ms
|
// Use the user's configured sync frequency with increased minimum to reduce heating
|
||||||
// But ensure we have a minimum interval of 5 seconds
|
// Minimum interval increased from 5s to 30s to reduce CPU usage
|
||||||
const syncInterval = Math.max(5000, traktSettings.syncFrequency);
|
const syncInterval = Math.max(30000, traktSettings.syncFrequency);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
saveWatchProgress();
|
saveWatchProgress();
|
||||||
|
|
|
||||||
|
|
@ -391,9 +391,9 @@ const VideoPlayer: React.FC = () => {
|
||||||
clearInterval(progressSaveInterval);
|
clearInterval(progressSaveInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the user's configured sync frequency instead of hard-coded 5000ms
|
// Use the user's configured sync frequency with increased minimum to reduce heating
|
||||||
// But ensure we have a minimum interval of 5 seconds
|
// Minimum interval increased from 5s to 30s to reduce CPU usage
|
||||||
const syncInterval = Math.max(5000, traktSettings.syncFrequency);
|
const syncInterval = Math.max(30000, traktSettings.syncFrequency);
|
||||||
|
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
saveWatchProgress();
|
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);
|
return () => clearInterval(intervalId);
|
||||||
}, [allFeaturedContent]);
|
}, [allFeaturedContent]);
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import type { Theme } from '../contexts/ThemeContext';
|
||||||
import * as ScreenOrientation from 'expo-screen-orientation';
|
import * as ScreenOrientation from 'expo-screen-orientation';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import FirstTimeWelcome from '../components/FirstTimeWelcome';
|
import FirstTimeWelcome from '../components/FirstTimeWelcome';
|
||||||
|
import { imageCacheService } from '../services/imageCacheService';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
const CATALOG_SETTINGS_KEY = 'catalog_settings';
|
||||||
|
|
@ -324,21 +325,22 @@ const HomeScreen = () => {
|
||||||
};
|
};
|
||||||
}, [currentTheme.colors.darkBackground]);
|
}, [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[]) => {
|
const preloadImages = useCallback(async (content: StreamingContent[]) => {
|
||||||
if (!content.length) return;
|
if (!content.length) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Limit concurrent prefetching to prevent memory pressure
|
// Significantly reduced concurrent prefetching to prevent heating
|
||||||
const MAX_CONCURRENT_PREFETCH = 5;
|
const BATCH_SIZE = 2; // Reduced from 3 to 2
|
||||||
const BATCH_SIZE = 3;
|
const MAX_IMAGES = 5; // Reduced from 10 to 5
|
||||||
|
|
||||||
const allImages = content.slice(0, 10) // Limit total images to prefetch
|
// Only preload the most important images (poster and banner, skip logo)
|
||||||
.map(item => [item.poster, item.banner, item.logo])
|
const allImages = content.slice(0, MAX_IMAGES)
|
||||||
|
.map(item => [item.poster, item.banner])
|
||||||
.flat()
|
.flat()
|
||||||
.filter(Boolean) as string[];
|
.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) {
|
for (let i = 0; i < allImages.length; i += BATCH_SIZE) {
|
||||||
const batch = allImages.slice(i, i + BATCH_SIZE);
|
const batch = allImages.slice(i, i + BATCH_SIZE);
|
||||||
|
|
||||||
|
|
@ -346,18 +348,19 @@ const HomeScreen = () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async (imageUrl) => {
|
batch.map(async (imageUrl) => {
|
||||||
try {
|
try {
|
||||||
await ExpoImage.prefetch(imageUrl);
|
// Use our cache service instead of direct prefetch
|
||||||
// Small delay between prefetches to reduce memory pressure
|
await imageCacheService.getCachedImageUrl(imageUrl);
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
// Increased delay between prefetches to reduce CPU load
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Silently handle individual prefetch errors
|
// 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) {
|
if (i + BATCH_SIZE < allImages.length) {
|
||||||
await new Promise(resolve => setTimeout(resolve, 50));
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Continue with next batch if current batch fails
|
// Continue with next batch if current batch fails
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Alert,
|
Alert,
|
||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { MaterialIcons } from '@expo/vector-icons';
|
import { MaterialIcons } from '@expo/vector-icons';
|
||||||
import { useTheme } from '../contexts/ThemeContext';
|
import { useTheme } from '../contexts/ThemeContext';
|
||||||
|
|
@ -17,6 +18,8 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
|
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
|
||||||
|
|
||||||
const NotificationSettingsScreen = () => {
|
const NotificationSettingsScreen = () => {
|
||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { currentTheme } = useTheme();
|
const { currentTheme } = useTheme();
|
||||||
|
|
@ -30,13 +33,19 @@ const NotificationSettingsScreen = () => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [countdown, setCountdown] = useState<number | null>(null);
|
const [countdown, setCountdown] = useState<number | null>(null);
|
||||||
const [testNotificationId, setTestNotificationId] = useState<string | 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(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const savedSettings = await notificationService.getSettings();
|
const savedSettings = await notificationService.getSettings();
|
||||||
setSettings(savedSettings);
|
setSettings(savedSettings);
|
||||||
|
|
||||||
|
// Load notification stats
|
||||||
|
const stats = notificationService.getNotificationStats();
|
||||||
|
setNotificationStats(stats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error loading notification settings:', error);
|
logger.error('Error loading notification settings:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -47,6 +56,14 @@ const NotificationSettingsScreen = () => {
|
||||||
loadSettings();
|
loadSettings();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Refresh stats when settings change
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading) {
|
||||||
|
const stats = notificationService.getNotificationStats();
|
||||||
|
setNotificationStats(stats);
|
||||||
|
}
|
||||||
|
}, [settings, loading]);
|
||||||
|
|
||||||
// Add countdown effect
|
// Add countdown effect
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let intervalId: NodeJS.Timeout;
|
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 () => {
|
const handleTestNotification = async () => {
|
||||||
try {
|
try {
|
||||||
// Cancel previous test notification if exists
|
// Cancel previous test notification if exists
|
||||||
|
|
@ -295,6 +335,54 @@ const NotificationSettingsScreen = () => {
|
||||||
</View>
|
</View>
|
||||||
</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 }]}>
|
<View style={[styles.section, { borderBottomColor: currentTheme.colors.border }]}>
|
||||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Advanced</Text>
|
<Text style={[styles.sectionTitle, { color: currentTheme.colors.text }]}>Advanced</Text>
|
||||||
|
|
||||||
|
|
@ -368,6 +456,7 @@ const styles = StyleSheet.create({
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: 16,
|
||||||
paddingVertical: 12,
|
paddingVertical: 12,
|
||||||
|
paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 12,
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
},
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
|
|
@ -465,6 +554,27 @@ const styles = StyleSheet.create({
|
||||||
countdownText: {
|
countdownText: {
|
||||||
fontSize: 14,
|
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;
|
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}`;
|
const key = `${content.type}:${content.id}`;
|
||||||
this.library[key] = content;
|
this.library[key] = content;
|
||||||
this.saveLibrary();
|
this.saveLibrary();
|
||||||
this.notifyLibrarySubscribers();
|
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}`;
|
const key = `${type}:${id}`;
|
||||||
delete this.library[key];
|
delete this.library[key];
|
||||||
this.saveLibrary();
|
this.saveLibrary();
|
||||||
this.notifyLibrarySubscribers();
|
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 {
|
private addToRecentContent(content: StreamingContent): void {
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,30 @@
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
|
|
||||||
interface CachedImage {
|
interface CachedImage {
|
||||||
url: string;
|
url: string;
|
||||||
localPath: string;
|
localPath: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
expiresAt: number;
|
expiresAt: number;
|
||||||
|
size?: number; // Track approximate memory usage
|
||||||
|
accessCount: number; // Track usage frequency
|
||||||
|
lastAccessed: number; // Track last access time
|
||||||
}
|
}
|
||||||
|
|
||||||
class ImageCacheService {
|
class ImageCacheService {
|
||||||
private cache = new Map<string, CachedImage>();
|
private cache = new Map<string, CachedImage>();
|
||||||
private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
|
private readonly CACHE_DURATION = 12 * 60 * 60 * 1000; // Reduced to 12 hours
|
||||||
private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images
|
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
|
* 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
|
// Check if we have a valid cached version
|
||||||
const cached = this.cache.get(originalUrl);
|
const cached = this.cache.get(originalUrl);
|
||||||
if (cached && cached.expiresAt > Date.now()) {
|
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;
|
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 {
|
try {
|
||||||
// For now, return the original URL but mark it as cached
|
// Estimate image size (rough approximation)
|
||||||
// In a production app, you would implement actual local caching here
|
const estimatedSize = this.estimateImageSize(originalUrl);
|
||||||
|
|
||||||
const cachedImage: CachedImage = {
|
const cachedImage: CachedImage = {
|
||||||
url: originalUrl,
|
url: originalUrl,
|
||||||
localPath: originalUrl, // In production, this would be a local file path
|
localPath: originalUrl, // In production, this would be a local file path
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
expiresAt: Date.now() + this.CACHE_DURATION,
|
expiresAt: Date.now() + this.CACHE_DURATION,
|
||||||
|
size: estimatedSize,
|
||||||
|
accessCount: 1,
|
||||||
|
lastAccessed: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
this.cache.set(originalUrl, cachedImage);
|
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;
|
return cachedImage.localPath;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('[ImageCache] Failed to cache image:', error);
|
logger.error('[ImageCache] Failed to cache image:', error);
|
||||||
|
|
@ -62,7 +90,7 @@ class ImageCacheService {
|
||||||
public logCacheStatus(): void {
|
public logCacheStatus(): void {
|
||||||
const stats = this.getCacheStats();
|
const stats = this.getCacheStats();
|
||||||
logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`);
|
logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`);
|
||||||
|
|
||||||
// Log first 5 cached URLs for debugging
|
// Log first 5 cached URLs for debugging
|
||||||
const entries = Array.from(this.cache.entries()).slice(0, 5);
|
const entries = Array.from(this.cache.entries()).slice(0, 5);
|
||||||
entries.forEach(([url, cached]) => {
|
entries.forEach(([url, cached]) => {
|
||||||
|
|
@ -98,7 +126,7 @@ class ImageCacheService {
|
||||||
public getCacheStats(): { size: number; expired: number } {
|
public getCacheStats(): { size: number; expired: number } {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
let expired = 0;
|
let expired = 0;
|
||||||
|
|
||||||
for (const cached of this.cache.values()) {
|
for (const cached of this.cache.values()) {
|
||||||
if (cached.expiresAt <= now) {
|
if (cached.expiresAt <= now) {
|
||||||
expired++;
|
expired++;
|
||||||
|
|
@ -132,6 +160,112 @@ class ImageCacheService {
|
||||||
|
|
||||||
logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`);
|
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();
|
export const imageCacheService = new ImageCacheService();
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import * as Notifications from 'expo-notifications';
|
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 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 { stremioService } from './stremioService';
|
||||||
|
import { catalogService } from './catalogService';
|
||||||
|
import { traktService } from './traktService';
|
||||||
|
import { tmdbService } from './tmdbService';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
|
|
||||||
// Define notification storage keys
|
// Define notification storage keys
|
||||||
|
|
@ -47,12 +50,18 @@ class NotificationService {
|
||||||
private static instance: NotificationService;
|
private static instance: NotificationService;
|
||||||
private settings: NotificationSettings = DEFAULT_NOTIFICATION_SETTINGS;
|
private settings: NotificationSettings = DEFAULT_NOTIFICATION_SETTINGS;
|
||||||
private scheduledNotifications: NotificationItem[] = [];
|
private scheduledNotifications: NotificationItem[] = [];
|
||||||
|
private backgroundSyncInterval: NodeJS.Timeout | null = null;
|
||||||
|
private librarySubscription: (() => void) | null = null;
|
||||||
|
private appStateSubscription: any = null;
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// Initialize notifications
|
// Initialize notifications
|
||||||
this.configureNotifications();
|
this.configureNotifications();
|
||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
this.loadScheduledNotifications();
|
this.loadScheduledNotifications();
|
||||||
|
this.setupLibraryIntegration();
|
||||||
|
this.setupBackgroundSync();
|
||||||
|
this.setupAppStateHandling();
|
||||||
}
|
}
|
||||||
|
|
||||||
static getInstance(): NotificationService {
|
static getInstance(): NotificationService {
|
||||||
|
|
@ -238,47 +247,376 @@ class NotificationService {
|
||||||
return [...this.scheduledNotifications];
|
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> {
|
async updateNotificationsForSeries(seriesId: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Get metadata for the series
|
logger.log(`[NotificationService] Updating notifications for series: ${seriesId}`);
|
||||||
const metadata = await stremioService.getMetaDetails('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;
|
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
|
// 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(
|
this.scheduledNotifications = this.scheduledNotifications.filter(
|
||||||
notification => notification.seriesId !== seriesId
|
notification => notification.seriesId !== seriesId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Schedule new notifications
|
// Schedule new notifications for upcoming episodes
|
||||||
const notificationItems: NotificationItem[] = upcomingEpisodes.map(episode => ({
|
if (upcomingEpisodes.length > 0) {
|
||||||
id: episode.id,
|
const notificationItems: NotificationItem[] = upcomingEpisodes.map(episode => ({
|
||||||
seriesId,
|
id: episode.id,
|
||||||
seriesName: metadata.name,
|
seriesId,
|
||||||
episodeTitle: episode.title,
|
seriesName: metadata.name,
|
||||||
season: episode.season || 0,
|
episodeTitle: episode.title,
|
||||||
episode: episode.episode || 0,
|
season: episode.season || 0,
|
||||||
releaseDate: episode.released,
|
episode: episode.episode || 0,
|
||||||
notified: false,
|
releaseDate: episode.released,
|
||||||
poster: metadata.poster,
|
notified: false,
|
||||||
}));
|
poster: metadata.poster,
|
||||||
|
}));
|
||||||
await this.scheduleMultipleEpisodeNotifications(notificationItems);
|
|
||||||
|
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) {
|
} 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 readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80%
|
||||||
|
|
||||||
private constructor() {
|
private constructor() {
|
||||||
// Initialize the cleanup interval for old stop calls
|
// Increased cleanup interval from 5 minutes to 15 minutes to reduce heating
|
||||||
setInterval(() => this.cleanupOldStopCalls(), 5 * 60 * 1000); // Clean up every 5 minutes
|
setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes
|
||||||
|
|
||||||
// Load user settings
|
// Load user settings
|
||||||
this.loadCompletionThreshold();
|
this.loadCompletionThreshold();
|
||||||
|
|
|
||||||