diff --git a/.gitignore b/.gitignore index c55810d..be2b4be 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,7 @@ release_announcement.md ALPHA_BUILD_2_ANNOUNCEMENT.md CHANGELOG.md .env.local -android/ \ No newline at end of file +android/ +HEATING_OPTIMIZATIONS.md +ios +android \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..7a73a41 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,2 @@ +{ +} \ No newline at end of file diff --git a/NOTIFICATION_INTEGRATION_SUMMARY.md b/NOTIFICATION_INTEGRATION_SUMMARY.md new file mode 100644 index 0000000..cb96d94 --- /dev/null +++ b/NOTIFICATION_INTEGRATION_SUMMARY.md @@ -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 { + // ... 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 { + // 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. \ No newline at end of file diff --git a/android/app/build.gradle b/android/app/build.gradle index 602b94c..0a09bd6 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -126,15 +126,6 @@ android { androidResources { ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:!CVS:!thumbs.db:!picasa.ini:!*~' } - - splits { - abi { - enable true - reset() - include "armeabi-v7a", "arm64-v8a", "x86", "x86_64" - universalApk true - } - } } // Apply static values from `gradle.properties` to the `android.packagingOptions` diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index d6dc75a..3941bea 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,5 +2,4 @@ - \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index d6dc75a..3941bea 100644 --- a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,5 +2,4 @@ - \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 8fcaeb1..4f55492 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index 131029a..acff384 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp deleted file mode 100644 index 131029a..0000000 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 197f852..68b2b6b 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index 52ee6cb..7ce788c 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index c2cf9ff..695874a 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp deleted file mode 100644 index c2cf9ff..0000000 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index 3399831..de65e82 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index a187a4a..f48cd8b 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index ce37113..b9693cb 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp deleted file mode 100644 index ce37113..0000000 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 947a182..d48403b 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index b2f46bc..eebf7ad 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index 2deadd0..5f71916 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp deleted file mode 100644 index 2deadd0..0000000 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_monochrome.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index ddccf17..4f6de84 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 418ff36..bee06ef 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index dc8d099..4208368 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp deleted file mode 100644 index dc8d099..0000000 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_monochrome.webp and /dev/null differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index d70702d..34afa47 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/android/gradlew.bat b/android/gradlew.bat index 9d21a21..9b42019 100644 --- a/android/gradlew.bat +++ b/android/gradlew.bat @@ -1,94 +1,94 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem -@rem SPDX-License-Identifier: Apache-2.0 -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. 1>&2 -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 -echo. 1>&2 -echo Please set the JAVA_HOME variable in your environment to match the 1>&2 -echo location of your Java installation. 1>&2 - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/app.json b/app.json index 15d5d0b..341906e 100644 --- a/app.json +++ b/app.json @@ -32,19 +32,7 @@ "UIFileSharingEnabled": true }, "bundleIdentifier": "com.nuvio.app", - "associatedDomains": [], - "documentTypes": [ - { - "name": "Matroska Video", - "role": "viewer", - "utis": [ - "org.matroska.mkv" - ], - "extensions": [ - "mkv" - ] - } - ] + "associatedDomains": [] }, "android": { "adaptiveIcon": { @@ -57,12 +45,7 @@ "WAKE_LOCK" ], "package": "com.nuvio.app", - "enableSplitAPKs": true, - "versionCode": 1, - "enableProguardInReleaseBuilds": true, - "enableHermes": true, - "enableSeparateBuildPerCPUArchitecture": true, - "enableVectorDrawables": true + "versionCode": 1 }, "web": { "favicon": "./assets/favicon.png" diff --git a/src/components/common/OptimizedImage.tsx b/src/components/common/OptimizedImage.tsx new file mode 100644 index 0000000..d8c50cc --- /dev/null +++ b/src/components/common/OptimizedImage.tsx @@ -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 = ({ + 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(''); + const mountedRef = useRef(true); + const loadTimeoutRef = useRef(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 ; + } + + // Show placeholder while loading or on error + if (!isLoaded || hasError) { + return ( + + ); + } + + return ( + { + setIsLoaded(true); + onLoad?.(); + }} + onError={(error) => { + setHasError(true); + onError?.(error); + }} + /> + ); +}; + +const styles = StyleSheet.create({ + placeholder: { + backgroundColor: '#1a1a1a', + }, +}); + +export default OptimizedImage; \ No newline at end of file diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 0509b05..4b928de 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -390,8 +390,8 @@ const ContinueWatchingSection = React.forwardRef((props, re } }; } else { - // Fallback: poll for updates every 30 seconds - const intervalId = setInterval(() => loadContinueWatching(true), 30000); + // Reduced polling frequency from 30s to 2 minutes to reduce heating + const intervalId = setInterval(() => loadContinueWatching(true), 120000); return () => { subscription.remove(); clearInterval(intervalId); diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx index b8ac3b5..8a6dc27 100644 --- a/src/components/home/FeaturedContent.tsx +++ b/src/components/home/FeaturedContent.tsx @@ -17,10 +17,10 @@ import { RootStackParamList } from '../../navigation/AppNavigator'; import { LinearGradient } from 'expo-linear-gradient'; import { Image as ExpoImage } from 'expo-image'; import { MaterialIcons } from '@expo/vector-icons'; -import Animated, { - FadeIn, - useAnimatedStyle, - useSharedValue, +import Animated, { + FadeIn, + useAnimatedStyle, + useSharedValue, withTiming, Easing, withDelay @@ -32,6 +32,7 @@ import { useSettings } from '../../hooks/useSettings'; import { TMDBService } from '../../services/tmdbService'; import { logger } from '../../utils/logger'; import { useTheme } from '../../contexts/ThemeContext'; +import { imageCacheService } from '../../services/imageCacheService'; interface FeaturedContentProps { featuredContent: StreamingContent | null; @@ -137,12 +138,12 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const [logoLoadError, setLogoLoadError] = useState(false); // Add a ref to track logo fetch in progress const logoFetchInProgress = useRef(false); - + // Enhanced poster transition animations const posterScale = useSharedValue(1); const posterTranslateY = useSharedValue(0); const overlayOpacity = useSharedValue(0.15); - + // Animation values const posterAnimatedStyle = useAnimatedStyle(() => ({ opacity: posterOpacity.value, @@ -151,14 +152,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat { translateY: posterTranslateY.value } ], })); - + const logoAnimatedStyle = useAnimatedStyle(() => ({ opacity: logoOpacity.value, })); - + const contentOpacity = useSharedValue(1); // Start visible const buttonsOpacity = useSharedValue(1); - + const contentAnimatedStyle = useAnimatedStyle(() => ({ opacity: contentOpacity.value, })); @@ -175,52 +176,13 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat const preloadImage = async (url: string): Promise => { // Skip if already cached to prevent redundant prefetch if (imageCache[url]) return true; - + try { - // Basic URL validation + // Simplified validation to reduce CPU overhead if (!url || typeof url !== 'string') return false; - - // Check if URL appears to be a valid image URL - const urlLower = url.toLowerCase(); - const hasImageExtension = /\.(jpg|jpeg|png|webp|svg)(\?.*)?$/i.test(url); - const isImageService = urlLower.includes('image') || urlLower.includes('poster') || urlLower.includes('banner') || urlLower.includes('logo'); - - if (!hasImageExtension && !isImageService) { - try { - // For URLs without clear image extensions, do a quick HEAD request - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); - - const response = await fetch(url, { - method: 'HEAD', - signal: controller.signal - }); - - clearTimeout(timeoutId); - - if (!response.ok) return false; - - const contentType = response.headers.get('content-type'); - if (!contentType || !contentType.startsWith('image/')) { - return false; - } - } catch (validationError) { - // If validation fails, still try to load the image - } - } - - // Always attempt to prefetch the image regardless of format validation - // Add timeout and retry logic for prefetch - const prefetchWithTimeout = () => { - return Promise.race([ - ExpoImage.prefetch(url), - new Promise((_, reject) => - setTimeout(() => reject(new Error('Prefetch timeout')), 5000) - ) - ]); - }; - - await prefetchWithTimeout(); + + // Use our optimized cache service instead of direct prefetch + await imageCacheService.getCachedImageUrl(url); imageCache[url] = true; return true; } catch (error) { @@ -234,27 +196,27 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat useEffect(() => { setLogoLoadError(false); }, [featuredContent?.id]); - + // Fetch logo based on preference useEffect(() => { if (!featuredContent || logoFetchInProgress.current) return; - + const fetchLogo = async () => { logoFetchInProgress.current = true; - + try { const contentId = featuredContent.id; const contentData = featuredContent; // Use a clearer variable name const currentLogo = contentData.logo; - + // Get preferences const logoPreference = settings.logoSourcePreference || 'metahub'; const preferredLanguage = settings.tmdbLanguagePreference || 'en'; - + // Reset state for new fetch setLogoUrl(null); setLogoLoadError(false); - + // Extract IDs let imdbId: string | null = null; if (contentData.id.startsWith('tt')) { @@ -264,14 +226,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } else if ((contentData as any).externalIds?.imdb_id) { imdbId = (contentData as any).externalIds.imdb_id; } - + let tmdbId: string | null = null; if (contentData.id.startsWith('tmdb:')) { tmdbId = contentData.id.split(':')[1]; } else if ((contentData as any).tmdb_id) { - tmdbId = String((contentData as any).tmdb_id); + tmdbId = String((contentData as any).tmdb_id); } - + // If we only have IMDB ID, try to find TMDB ID proactively if (imdbId && !tmdbId) { try { @@ -284,14 +246,14 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat // logger.warn(`[FeaturedContent] Failed to find TMDB ID for ${imdbId}:`, findError); } } - + const tmdbType = contentData.type === 'series' ? 'tv' : 'movie'; let finalLogoUrl: string | null = null; let primaryAttempted = false; let fallbackAttempted = false; - + // --- Logo Fetching Logic --- - + if (logoPreference === 'metahub') { // Primary: Metahub (needs imdbId) if (imdbId) { @@ -304,7 +266,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } catch (error) { /* Log if needed */ } } - + // Fallback: TMDB (needs tmdbId) if (!finalLogoUrl && tmdbId) { fallbackAttempted = true; @@ -316,7 +278,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } catch (error) { /* Log if needed */ } } - + } else { // logoPreference === 'tmdb' // Primary: TMDB (needs tmdbId) if (tmdbId) { @@ -329,7 +291,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } catch (error) { /* Log if needed */ } } - + // Fallback: Metahub (needs imdbId) if (!finalLogoUrl && imdbId) { fallbackAttempted = true; @@ -342,7 +304,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } catch (error) { /* Log if needed */ } } } - + // --- Set Final Logo --- if (finalLogoUrl) { setLogoUrl(finalLogoUrl); @@ -354,7 +316,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat setLogoLoadError(true); // logger.warn(`[FeaturedContent] No logo found for ${contentData.name} (${contentId}) with preference ${logoPreference}. Primary attempted: ${primaryAttempted}, Fallback attempted: ${fallbackAttempted}`); } - + } catch (error) { // logger.error('[FeaturedContent] Error in fetchLogo:', error); setLogoLoadError(true); @@ -362,7 +324,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat logoFetchInProgress.current = false; } }; - + // Trigger fetch when content changes fetchLogo(); }, [featuredContent, settings.logoSourcePreference, settings.tmdbLanguagePreference]); @@ -370,11 +332,11 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat // Load poster and logo useEffect(() => { if (!featuredContent) return; - + const posterUrl = featuredContent.banner || featuredContent.poster; const contentId = featuredContent.id; const isContentChange = contentId !== prevContentIdRef.current; - + // Enhanced content change detection and animations if (isContentChange) { // Animate out current content @@ -409,17 +371,17 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } logoOpacity.value = 0; } - + prevContentIdRef.current = contentId; - + // Set poster URL for immediate display if (posterUrl) setBannerUrl(posterUrl); - + // Load images with enhanced animations const loadImages = async () => { // Small delay to allow fade out animation to complete await new Promise(resolve => setTimeout(resolve, isContentChange && prevContentIdRef.current ? 300 : 0)); - + // Load poster with enhanced transition if (posterUrl) { const posterSuccess = await preloadImage(posterUrl); @@ -437,7 +399,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat duration: 600, easing: Easing.out(Easing.cubic) }); - + // Animate content back in with delay contentOpacity.value = withDelay(200, withTiming(1, { duration: 600, @@ -449,7 +411,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat })); } } - + // Load logo if available with enhanced timing if (logoUrl) { const logoSuccess = await preloadImage(logoUrl); @@ -463,7 +425,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat } } }; - + loadImages(); }, [featuredContent?.id, logoUrl]); @@ -489,7 +451,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - { navigation.navigate('Metadata', { @@ -507,7 +469,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat > {/* Subtle content overlay for better readability */} - + - {logoUrl && !logoLoadError ? ( - - - {isSaved ? "Saved" : "Save"} - - { if (featuredContent) { - navigation.navigate('Streams', { - id: featuredContent.id, + navigation.navigate('Streams', { + id: featuredContent.id, type: featuredContent.type }); } @@ -587,7 +549,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat - { clearInterval(progressSaveInterval); } - // Use the user's configured sync frequency instead of hard-coded 5000ms - // But ensure we have a minimum interval of 5 seconds - const syncInterval = Math.max(5000, traktSettings.syncFrequency); + // Use the user's configured sync frequency with increased minimum to reduce heating + // Minimum interval increased from 5s to 30s to reduce CPU usage + const syncInterval = Math.max(30000, traktSettings.syncFrequency); const interval = setInterval(() => { saveWatchProgress(); diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx index b8c526f..1b4c71b 100644 --- a/src/components/player/VideoPlayer.tsx +++ b/src/components/player/VideoPlayer.tsx @@ -391,9 +391,9 @@ const VideoPlayer: React.FC = () => { clearInterval(progressSaveInterval); } - // Use the user's configured sync frequency instead of hard-coded 5000ms - // But ensure we have a minimum interval of 5 seconds - const syncInterval = Math.max(5000, traktSettings.syncFrequency); + // Use the user's configured sync frequency with increased minimum to reduce heating + // Minimum interval increased from 5s to 30s to reduce CPU usage + const syncInterval = Math.max(30000, traktSettings.syncFrequency); const interval = setInterval(() => { saveWatchProgress(); diff --git a/src/hooks/useFeaturedContent.ts b/src/hooks/useFeaturedContent.ts index 5989d6a..a013082 100644 --- a/src/hooks/useFeaturedContent.ts +++ b/src/hooks/useFeaturedContent.ts @@ -304,7 +304,8 @@ export function useFeaturedContent() { } }; - const intervalId = setInterval(rotateContent, 15000); + // Increased rotation interval from 15s to 45s to reduce heating + const intervalId = setInterval(rotateContent, 45000); return () => clearInterval(intervalId); }, [allFeaturedContent]); diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 4e85c86..2048dd5 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -64,6 +64,7 @@ import type { Theme } from '../contexts/ThemeContext'; import * as ScreenOrientation from 'expo-screen-orientation'; import AsyncStorage from '@react-native-async-storage/async-storage'; import FirstTimeWelcome from '../components/FirstTimeWelcome'; +import { imageCacheService } from '../services/imageCacheService'; // Constants const CATALOG_SETTINGS_KEY = 'catalog_settings'; @@ -324,21 +325,22 @@ const HomeScreen = () => { }; }, [currentTheme.colors.darkBackground]); - // Preload images function - memoized to avoid recreating on every render + // Optimized preload images function with better memory management const preloadImages = useCallback(async (content: StreamingContent[]) => { if (!content.length) return; try { - // Limit concurrent prefetching to prevent memory pressure - const MAX_CONCURRENT_PREFETCH = 5; - const BATCH_SIZE = 3; + // Significantly reduced concurrent prefetching to prevent heating + const BATCH_SIZE = 2; // Reduced from 3 to 2 + const MAX_IMAGES = 5; // Reduced from 10 to 5 - const allImages = content.slice(0, 10) // Limit total images to prefetch - .map(item => [item.poster, item.banner, item.logo]) + // Only preload the most important images (poster and banner, skip logo) + const allImages = content.slice(0, MAX_IMAGES) + .map(item => [item.poster, item.banner]) .flat() .filter(Boolean) as string[]; - // Process in small batches to prevent memory pressure + // Process in smaller batches with longer delays for (let i = 0; i < allImages.length; i += BATCH_SIZE) { const batch = allImages.slice(i, i + BATCH_SIZE); @@ -346,18 +348,19 @@ const HomeScreen = () => { await Promise.all( batch.map(async (imageUrl) => { try { - await ExpoImage.prefetch(imageUrl); - // Small delay between prefetches to reduce memory pressure - await new Promise(resolve => setTimeout(resolve, 10)); + // Use our cache service instead of direct prefetch + await imageCacheService.getCachedImageUrl(imageUrl); + // Increased delay between prefetches to reduce CPU load + await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { // Silently handle individual prefetch errors } }) ); - // Delay between batches to allow GC + // Longer delay between batches to allow GC and reduce heating if (i + BATCH_SIZE < allImages.length) { - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise(resolve => setTimeout(resolve, 200)); } } catch (error) { // Continue with next batch if current batch fails diff --git a/src/screens/NotificationSettingsScreen.tsx b/src/screens/NotificationSettingsScreen.tsx index dda3dca..469c2dd 100644 --- a/src/screens/NotificationSettingsScreen.tsx +++ b/src/screens/NotificationSettingsScreen.tsx @@ -9,6 +9,7 @@ import { Alert, SafeAreaView, StatusBar, + Platform, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { useTheme } from '../contexts/ThemeContext'; @@ -17,6 +18,8 @@ import Animated, { FadeIn, FadeOut } from 'react-native-reanimated'; import { useNavigation } from '@react-navigation/native'; import { logger } from '../utils/logger'; +const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0; + const NotificationSettingsScreen = () => { const navigation = useNavigation(); const { currentTheme } = useTheme(); @@ -30,13 +33,19 @@ const NotificationSettingsScreen = () => { const [loading, setLoading] = useState(true); const [countdown, setCountdown] = useState(null); const [testNotificationId, setTestNotificationId] = useState(null); + const [isSyncing, setIsSyncing] = useState(false); + const [notificationStats, setNotificationStats] = useState({ total: 0, upcoming: 0, thisWeek: 0 }); - // Load settings on mount + // Load settings and stats on mount useEffect(() => { const loadSettings = async () => { try { const savedSettings = await notificationService.getSettings(); setSettings(savedSettings); + + // Load notification stats + const stats = notificationService.getNotificationStats(); + setNotificationStats(stats); } catch (error) { logger.error('Error loading notification settings:', error); } finally { @@ -47,6 +56,14 @@ const NotificationSettingsScreen = () => { loadSettings(); }, []); + // Refresh stats when settings change + useEffect(() => { + if (!loading) { + const stats = notificationService.getNotificationStats(); + setNotificationStats(stats); + } + }, [settings, loading]); + // Add countdown effect useEffect(() => { let intervalId: NodeJS.Timeout; @@ -122,6 +139,29 @@ const NotificationSettingsScreen = () => { ); }; + const handleSyncNotifications = async () => { + if (isSyncing) return; + + setIsSyncing(true); + try { + await notificationService.syncAllNotifications(); + + // Refresh stats after sync + const stats = notificationService.getNotificationStats(); + setNotificationStats(stats); + + Alert.alert( + 'Sync Complete', + `Successfully synced notifications for your library and Trakt items.\n\nScheduled: ${stats.upcoming} upcoming episodes\nThis week: ${stats.thisWeek} episodes` + ); + } catch (error) { + logger.error('Error syncing notifications:', error); + Alert.alert('Error', 'Failed to sync notifications. Please try again.'); + } finally { + setIsSyncing(false); + } + }; + const handleTestNotification = async () => { try { // Cancel previous test notification if exists @@ -295,6 +335,54 @@ const NotificationSettingsScreen = () => { + + Notification Status + + + + + Upcoming + {notificationStats.upcoming} + + + + This Week + {notificationStats.thisWeek} + + + + Total + {notificationStats.total} + + + + + + + {isSyncing ? 'Syncing...' : 'Sync Library & Trakt'} + + + + + Automatically syncs notifications for all shows in your library and Trakt watchlist/collection. + + + Advanced @@ -368,6 +456,7 @@ const styles = StyleSheet.create({ justifyContent: 'space-between', paddingHorizontal: 16, paddingVertical: 12, + paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 12, borderBottomWidth: 1, }, backButton: { @@ -465,6 +554,27 @@ const styles = StyleSheet.create({ countdownText: { fontSize: 14, }, + statsContainer: { + flexDirection: 'row', + justifyContent: 'space-around', + padding: 16, + borderRadius: 8, + marginBottom: 16, + }, + statItem: { + alignItems: 'center', + flex: 1, + }, + statLabel: { + fontSize: 12, + marginTop: 4, + textAlign: 'center', + }, + statValue: { + fontSize: 18, + fontWeight: 'bold', + marginTop: 2, + }, }); export default NotificationSettingsScreen; \ No newline at end of file diff --git a/src/services/catalogService.ts b/src/services/catalogService.ts index 8769b20..e256735 100644 --- a/src/services/catalogService.ts +++ b/src/services/catalogService.ts @@ -641,18 +641,47 @@ class CatalogService { }; } - public addToLibrary(content: StreamingContent): void { + public async addToLibrary(content: StreamingContent): Promise { const key = `${content.type}:${content.id}`; this.library[key] = content; this.saveLibrary(); this.notifyLibrarySubscribers(); + + // Auto-setup notifications for series when added to library + if (content.type === 'series') { + try { + const { notificationService } = await import('./notificationService'); + await notificationService.updateNotificationsForSeries(content.id); + console.log(`[CatalogService] Auto-setup notifications for series: ${content.name}`); + } catch (error) { + console.error(`[CatalogService] Failed to setup notifications for ${content.name}:`, error); + } + } } - public removeFromLibrary(type: string, id: string): void { + public async removeFromLibrary(type: string, id: string): Promise { const key = `${type}:${id}`; delete this.library[key]; this.saveLibrary(); this.notifyLibrarySubscribers(); + + // Cancel notifications for series when removed from library + if (type === 'series') { + try { + const { notificationService } = await import('./notificationService'); + // Cancel all notifications for this series + const scheduledNotifications = await notificationService.getScheduledNotifications(); + const seriesToCancel = scheduledNotifications.filter(notification => notification.seriesId === id); + + for (const notification of seriesToCancel) { + await notificationService.cancelNotification(notification.id); + } + + console.log(`[CatalogService] Cancelled ${seriesToCancel.length} notifications for removed series: ${id}`); + } catch (error) { + console.error(`[CatalogService] Failed to cancel notifications for removed series ${id}:`, error); + } + } } private addToRecentContent(content: StreamingContent): void { diff --git a/src/services/imageCacheService.ts b/src/services/imageCacheService.ts index e5a8bca..be0028b 100644 --- a/src/services/imageCacheService.ts +++ b/src/services/imageCacheService.ts @@ -1,16 +1,30 @@ import { logger } from '../utils/logger'; +import { Image as ExpoImage } from 'expo-image'; interface CachedImage { url: string; localPath: string; timestamp: number; expiresAt: number; + size?: number; // Track approximate memory usage + accessCount: number; // Track usage frequency + lastAccessed: number; // Track last access time } class ImageCacheService { private cache = new Map(); - private readonly CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours - private readonly MAX_CACHE_SIZE = 100; // Maximum number of cached images + private readonly CACHE_DURATION = 12 * 60 * 60 * 1000; // Reduced to 12 hours + private readonly MAX_CACHE_SIZE = 50; // Reduced maximum number of cached images + private readonly MAX_MEMORY_MB = 100; // Maximum memory usage in MB + private currentMemoryUsage = 0; + private cleanupInterval: NodeJS.Timeout | null = null; + + constructor() { + // Start cleanup interval every 10 minutes + this.cleanupInterval = setInterval(() => { + this.performCleanup(); + }, 10 * 60 * 1000); + } /** * Get a cached image URL or cache the original if not present @@ -23,24 +37,38 @@ class ImageCacheService { // Check if we have a valid cached version const cached = this.cache.get(originalUrl); if (cached && cached.expiresAt > Date.now()) { - logger.log(`[ImageCache] Retrieved from cache: ${originalUrl}`); + // Update access tracking + cached.accessCount++; + cached.lastAccessed = Date.now(); + logger.log(`[ImageCache] Retrieved from cache: ${originalUrl.substring(0, 50)}...`); return cached.localPath; } + // Check memory pressure before adding new entries + if (this.shouldSkipCaching()) { + logger.log(`[ImageCache] Skipping cache due to memory pressure`); + return originalUrl; + } + try { - // For now, return the original URL but mark it as cached - // In a production app, you would implement actual local caching here + // Estimate image size (rough approximation) + const estimatedSize = this.estimateImageSize(originalUrl); + const cachedImage: CachedImage = { url: originalUrl, localPath: originalUrl, // In production, this would be a local file path timestamp: Date.now(), expiresAt: Date.now() + this.CACHE_DURATION, + size: estimatedSize, + accessCount: 1, + lastAccessed: Date.now() }; this.cache.set(originalUrl, cachedImage); - this.enforceMaxCacheSize(); + this.currentMemoryUsage += estimatedSize; + this.enforceMemoryLimits(); - logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl} (Cache size: ${this.cache.size})`); + logger.log(`[ImageCache] ✅ NEW CACHE ENTRY: ${originalUrl.substring(0, 50)}... (Cache: ${this.cache.size}/${this.MAX_CACHE_SIZE}, Memory: ${(this.currentMemoryUsage / 1024 / 1024).toFixed(1)}MB)`); return cachedImage.localPath; } catch (error) { logger.error('[ImageCache] Failed to cache image:', error); @@ -62,7 +90,7 @@ class ImageCacheService { public logCacheStatus(): void { const stats = this.getCacheStats(); logger.log(`[ImageCache] 📊 Cache Status: ${stats.size} total, ${stats.expired} expired`); - + // Log first 5 cached URLs for debugging const entries = Array.from(this.cache.entries()).slice(0, 5); entries.forEach(([url, cached]) => { @@ -98,7 +126,7 @@ class ImageCacheService { public getCacheStats(): { size: number; expired: number } { const now = Date.now(); let expired = 0; - + for (const cached of this.cache.values()) { if (cached.expiresAt <= now) { expired++; @@ -132,6 +160,112 @@ class ImageCacheService { logger.log(`[ImageCache] Removed ${toRemove} old entries to enforce cache size limit`); } + + /** + * Enforce memory limits using LRU eviction + */ + private enforceMemoryLimits(): void { + const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024; + + if (this.currentMemoryUsage <= maxMemoryBytes) { + return; + } + + // Sort by access frequency and recency (LRU) + const entries = Array.from(this.cache.entries()).sort((a, b) => { + const scoreA = a[1].accessCount * 0.3 + (Date.now() - a[1].lastAccessed) * 0.7; + const scoreB = b[1].accessCount * 0.3 + (Date.now() - b[1].lastAccessed) * 0.7; + return scoreB - scoreA; // Higher score = more likely to be evicted + }); + + let removedCount = 0; + for (const [url, cached] of entries) { + if (this.currentMemoryUsage <= maxMemoryBytes * 0.8) { // Leave 20% buffer + break; + } + + this.cache.delete(url); + this.currentMemoryUsage -= cached.size || 0; + removedCount++; + } + + if (removedCount > 0) { + logger.log(`[ImageCache] Evicted ${removedCount} entries to free memory. Current usage: ${(this.currentMemoryUsage / 1024 / 1024).toFixed(1)}MB`); + } + } + + /** + * Estimate image size based on URL patterns + */ + private estimateImageSize(url: string): number { + // Rough estimates in bytes based on common image types + if (url.includes('poster')) return 150 * 1024; // 150KB for posters + if (url.includes('banner') || url.includes('backdrop')) return 300 * 1024; // 300KB for banners + if (url.includes('logo')) return 50 * 1024; // 50KB for logos + if (url.includes('thumb')) return 75 * 1024; // 75KB for thumbnails + return 200 * 1024; // Default 200KB + } + + /** + * Check if we should skip caching due to memory pressure + */ + private shouldSkipCaching(): boolean { + const maxMemoryBytes = this.MAX_MEMORY_MB * 1024 * 1024; + return this.currentMemoryUsage > maxMemoryBytes * 0.9 || this.cache.size >= this.MAX_CACHE_SIZE; + } + + /** + * Perform comprehensive cleanup + */ + private performCleanup(): void { + const initialSize = this.cache.size; + const initialMemory = this.currentMemoryUsage; + + // Remove expired entries + this.clearExpiredCache(); + + // Recalculate memory usage + this.recalculateMemoryUsage(); + + // Enforce limits + this.enforceMemoryLimits(); + this.enforceMaxCacheSize(); + + // Clear Expo image memory cache periodically + try { + ExpoImage.clearMemoryCache(); + } catch (error) { + // Ignore errors from clearing memory cache + } + + const finalSize = this.cache.size; + const finalMemory = this.currentMemoryUsage; + + if (initialSize !== finalSize || Math.abs(initialMemory - finalMemory) > 1024 * 1024) { + logger.log(`[ImageCache] Cleanup completed: ${initialSize}→${finalSize} entries, ${(initialMemory / 1024 / 1024).toFixed(1)}→${(finalMemory / 1024 / 1024).toFixed(1)}MB`); + } + } + + /** + * Recalculate memory usage from cache entries + */ + private recalculateMemoryUsage(): void { + this.currentMemoryUsage = 0; + for (const cached of this.cache.values()) { + this.currentMemoryUsage += cached.size || 0; + } + } + + /** + * Cleanup resources + */ + public destroy(): void { + if (this.cleanupInterval) { + clearInterval(this.cleanupInterval); + this.cleanupInterval = null; + } + this.clearAllCache(); + } } export const imageCacheService = new ImageCacheService(); \ No newline at end of file diff --git a/src/services/notificationService.ts b/src/services/notificationService.ts index 10e03f9..ebdbd80 100644 --- a/src/services/notificationService.ts +++ b/src/services/notificationService.ts @@ -1,8 +1,11 @@ import * as Notifications from 'expo-notifications'; -import { Platform } from 'react-native'; +import { Platform, AppState, AppStateStatus } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { parseISO, differenceInHours, isToday, addDays } from 'date-fns'; +import { parseISO, differenceInHours, isToday, addDays, isAfter, startOfToday } from 'date-fns'; import { stremioService } from './stremioService'; +import { catalogService } from './catalogService'; +import { traktService } from './traktService'; +import { tmdbService } from './tmdbService'; import { logger } from '../utils/logger'; // Define notification storage keys @@ -47,12 +50,18 @@ class NotificationService { private static instance: NotificationService; private settings: NotificationSettings = DEFAULT_NOTIFICATION_SETTINGS; private scheduledNotifications: NotificationItem[] = []; + private backgroundSyncInterval: NodeJS.Timeout | null = null; + private librarySubscription: (() => void) | null = null; + private appStateSubscription: any = null; private constructor() { // Initialize notifications this.configureNotifications(); this.loadSettings(); this.loadScheduledNotifications(); + this.setupLibraryIntegration(); + this.setupBackgroundSync(); + this.setupAppStateHandling(); } static getInstance(): NotificationService { @@ -238,47 +247,376 @@ class NotificationService { return [...this.scheduledNotifications]; } - // Update notifications for a library item + // Setup library integration - automatically sync notifications when library changes + private setupLibraryIntegration(): void { + try { + // Subscribe to library updates from catalog service + this.librarySubscription = catalogService.subscribeToLibraryUpdates(async (libraryItems) => { + if (!this.settings.enabled) return; + + logger.log('[NotificationService] Library updated, syncing notifications for', libraryItems.length, 'items'); + await this.syncNotificationsForLibrary(libraryItems); + }); + } catch (error) { + logger.error('[NotificationService] Error setting up library integration:', error); + } + } + + // Setup background sync for notifications + private setupBackgroundSync(): void { + // Sync notifications every 6 hours + this.backgroundSyncInterval = setInterval(async () => { + if (this.settings.enabled) { + logger.log('[NotificationService] Running background notification sync'); + await this.performBackgroundSync(); + } + }, 6 * 60 * 60 * 1000); // 6 hours + } + + // Setup app state handling for foreground sync + private setupAppStateHandling(): void { + const subscription = AppState.addEventListener('change', this.handleAppStateChange); + // Store subscription for cleanup + this.appStateSubscription = subscription; + } + + private handleAppStateChange = async (nextAppState: AppStateStatus) => { + if (nextAppState === 'active' && this.settings.enabled) { + // App came to foreground, sync notifications + logger.log('[NotificationService] App became active, syncing notifications'); + await this.performBackgroundSync(); + } + }; + + // Sync notifications for all library items + private async syncNotificationsForLibrary(libraryItems: any[]): Promise { + 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 { + 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 { + 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 { try { - // Get metadata for the series - const metadata = await stremioService.getMetaDetails('series', seriesId); + logger.log(`[NotificationService] Updating notifications for series: ${seriesId}`); - if (!metadata || !metadata.videos) { + // Try Stremio first + let metadata = await stremioService.getMetaDetails('series', seriesId); + let upcomingEpisodes: any[] = []; + + if (metadata && metadata.videos) { + const now = new Date(); + const fourWeeksLater = addDays(now, 28); + + upcomingEpisodes = metadata.videos.filter(video => { + if (!video.released) return false; + const releaseDate = parseISO(video.released); + return releaseDate > now && releaseDate < fourWeeksLater; + }); + } + + // If no upcoming episodes from Stremio, try TMDB + if (upcomingEpisodes.length === 0) { + try { + // Extract TMDB ID if it's a TMDB format ID + let tmdbId = seriesId; + if (seriesId.startsWith('tmdb:')) { + tmdbId = seriesId.split(':')[1]; + } + + const tmdbDetails = await tmdbService.getTVShowDetails(parseInt(tmdbId)); + if (tmdbDetails) { + metadata = { + id: seriesId, + type: 'series' as const, + name: tmdbDetails.name, + poster: tmdbService.getImageUrl(tmdbDetails.poster_path) || '', + }; + + // Get upcoming episodes from TMDB + const now = new Date(); + const fourWeeksLater = addDays(now, 28); + + // Check current and next seasons for upcoming episodes + for (let seasonNum = tmdbDetails.number_of_seasons; seasonNum >= Math.max(1, tmdbDetails.number_of_seasons - 2); seasonNum--) { + try { + const seasonDetails = await tmdbService.getSeasonDetails(parseInt(tmdbId), seasonNum); + if (seasonDetails && seasonDetails.episodes) { + const seasonUpcoming = seasonDetails.episodes.filter((episode: any) => { + if (!episode.air_date) return false; + const airDate = parseISO(episode.air_date); + return airDate > now && airDate < fourWeeksLater; + }); + + upcomingEpisodes.push(...seasonUpcoming.map((episode: any) => ({ + id: `${tmdbId}-s${seasonNum}e${episode.episode_number}`, + title: episode.name, + season: seasonNum, + episode: episode.episode_number, + released: episode.air_date, + }))); + } + } catch (seasonError) { + // Continue with other seasons if one fails + } + } + } + } catch (tmdbError) { + logger.warn(`[NotificationService] TMDB fallback failed for ${seriesId}:`, tmdbError); + } + } + + if (!metadata) { + logger.warn(`[NotificationService] No metadata found for series: ${seriesId}`); return; } - // Get upcoming episodes - const now = new Date(); - const fourWeeksLater = addDays(now, 28); - - const upcomingEpisodes = metadata.videos.filter(video => { - if (!video.released) return false; - const releaseDate = parseISO(video.released); - return releaseDate > now && releaseDate < fourWeeksLater; - }); - // Cancel existing notifications for this series + const existingNotifications = await Notifications.getAllScheduledNotificationsAsync(); + for (const notification of existingNotifications) { + if (notification.content.data?.seriesId === seriesId) { + await Notifications.cancelScheduledNotificationAsync(notification.identifier); + } + } + + // Remove from our tracked notifications this.scheduledNotifications = this.scheduledNotifications.filter( notification => notification.seriesId !== seriesId ); - // Schedule new notifications - const notificationItems: NotificationItem[] = upcomingEpisodes.map(episode => ({ - id: episode.id, - seriesId, - seriesName: metadata.name, - episodeTitle: episode.title, - season: episode.season || 0, - episode: episode.episode || 0, - releaseDate: episode.released, - notified: false, - poster: metadata.poster, - })); - - await this.scheduleMultipleEpisodeNotifications(notificationItems); + // Schedule new notifications for upcoming episodes + if (upcomingEpisodes.length > 0) { + const notificationItems: NotificationItem[] = upcomingEpisodes.map(episode => ({ + id: episode.id, + seriesId, + seriesName: metadata.name, + episodeTitle: episode.title, + season: episode.season || 0, + episode: episode.episode || 0, + releaseDate: episode.released, + notified: false, + poster: metadata.poster, + })); + + const scheduledCount = await this.scheduleMultipleEpisodeNotifications(notificationItems); + logger.log(`[NotificationService] Scheduled ${scheduledCount} notifications for ${metadata.name}`); + } else { + logger.log(`[NotificationService] No upcoming episodes found for ${metadata.name}`); + } } catch (error) { - logger.error(`Error updating notifications for series ${seriesId}:`, error); + logger.error(`[NotificationService] Error updating notifications for series ${seriesId}:`, error); + } + } + + // Clean up old and expired notifications + private async cleanupOldNotifications(): Promise { + 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 { + 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; } } } diff --git a/src/services/traktService.ts b/src/services/traktService.ts index 46c875a..2b3d3ee 100644 --- a/src/services/traktService.ts +++ b/src/services/traktService.ts @@ -282,8 +282,8 @@ export class TraktService { private readonly DEFAULT_COMPLETION_THRESHOLD = 80; // 80% private constructor() { - // Initialize the cleanup interval for old stop calls - setInterval(() => this.cleanupOldStopCalls(), 5 * 60 * 1000); // Clean up every 5 minutes + // Increased cleanup interval from 5 minutes to 15 minutes to reduce heating + setInterval(() => this.cleanupOldStopCalls(), 15 * 60 * 1000); // Clean up every 15 minutes // Load user settings this.loadCompletionThreshold();