diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..48e6889
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,37 @@
+name: Release Build
+
+on:
+ push:
+ tags:
+ - 'v*'
+
+jobs:
+ release:
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v3
+ with:
+ node-version: '18'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build app
+ run: |
+ npm run build
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v1
+ with:
+ files: |
+ android/app/build/outputs/apk/release/*.apk
+ body_path: ALPHA_BUILD_2_ANNOUNCEMENT.md
+ draft: true
+ prerelease: true
+ generate_release_notes: true
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index bf6e7ea..ce1f92c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,4 +37,7 @@ yarn-error.*
# typescript
*.tsbuildinfo
plan.md
-release_announcement.md
\ No newline at end of file
+release_announcement.md
+ALPHA_BUILD_2_ANNOUNCEMENT.md
+CHANGELOG.md
+.env.local
diff --git a/App.tsx b/App.tsx
index bf19654..a0707f5 100644
--- a/App.tsx
+++ b/App.tsx
@@ -25,6 +25,23 @@ import { GenreProvider } from './src/contexts/GenreContext';
import { TraktProvider } from './src/contexts/TraktContext';
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
import SplashScreen from './src/components/SplashScreen';
+import * as Sentry from '@sentry/react-native';
+
+Sentry.init({
+ dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
+
+ // Adds more context data to events (IP address, cookies, user, etc.)
+ // For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
+ sendDefaultPii: true,
+
+ // Configure Session Replay
+ replaysSessionSampleRate: 0.1,
+ replaysOnErrorSampleRate: 1,
+ integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
+
+ // uncomment the line below to enable Spotlight (https://spotlightjs.com)
+ // spotlight: __DEV__,
+});
// This fixes many navigation layout issues by using native screen containers
enableScreens(true);
@@ -99,4 +116,4 @@ const styles = StyleSheet.create({
},
});
-export default App;
+export default Sentry.wrap(App);
\ No newline at end of file
diff --git a/README.md b/README.md
index c3f0be0..7de56a0 100644
--- a/README.md
+++ b/README.md
@@ -1,52 +1,134 @@
+# Nuvio Streaming App
+
-# Nuvio
+
+ A modern streaming app built with React Native and Expo, featuring Stremio addon integration, Trakt synchronization, and a beautiful user interface.
+
-An app I built with React Native/Expo for browsing and watching movies & shows. It uses Stremio-compatible addons to find streaming sources.
+## ⚠️ Alpha Testing
+This app is currently in alpha testing. Please report any bugs or issues you encounter.
-Built for iOS and Android.
+[Download Latest Release](https://github.com/tapframe/NuvioStreaming/releases/latest)
-## Key Features
+## ✨ Key Features
-* **Home Screen:** Highlights new content, your watch history, and content categories.
-* **Discover:** Browse trending and popular movies & TV shows.
-* **Details:** Displays detailed info (descriptions, cast, ratings).
-* **Video Player:** Integrated player(still broken on IOS,supports External PLayer for now).
-* **Stream Finding:** Finds available streams using Stremio addons.
-* **Search:** Quickly find specific movies or shows.
-* **Trakt Sync:** Planned integration (coming soon).
-* **Addon Management:** Add and manage your Stremio addons.
-* **UI:** Focuses on a clean, interactive user experience.
+### Content & Discovery
+- **Smart Home Screen:** Personalized content recommendations and continue watching
+- **Discover Section:** Browse trending and popular movies & TV shows
+- **Rich Metadata:** Detailed information, cast, ratings, and similar content
+- **Powerful Search:** Find content quickly with instant results
+
+### Streaming & Playback
+- **Advanced Video Player:**
+ - Built-in player with gesture controls
+ - External player support
+ - Auto-quality selection
+ - Subtitle customization
+- **Smart Stream Selection:** Automatically finds the best available streams
+- **Auto-Play:** Seamless playback of next episodes
+- **Continue Watching:** Resume from where you left off
+
+### Integration & Sync
+- **Trakt Integration:**
+ - Account synchronization
+ - Watch history tracking
+ - Library management
+ - Progress syncing
+- **Stremio Addons:**
+ - Compatible with Stremio addon system
+ - Easy addon management
+ - Multiple source support
+
+### User Experience
+- **Modern UI/UX:** Clean, intuitive interface with smooth animations
+- **Performance:** Optimized for smooth scrolling and quick loading
+- **Customization:** Theme options and display preferences
+- **Cross-Platform:** Works on both iOS and Android
## 📸 Screenshots
-| Home | Discover | Search |
-| :----------------------------------------- | :----------------------------------------- | :--------------------------------------- |
-|  |  |  |
-| **Metadata** | **Seasons & Episodes** | **Rating** |
-|  | |  |
+| Home & Continue Watching | Discover & Browse | Search & Details |
+|:-----------------------:|:-----------------:|:----------------:|
+|  |  |  |
+| **Content Details** | **Episodes & Seasons** | **Ratings & Info** |
+|  |  |  |
-## Development
+## 🚀 Getting Started
-1. You'll need Node.js, npm/yarn, and the Expo Go app (or native build tools like Android Studio/Xcode).
-2. `git clone https://github.com/nayifleo1/NuvioExpo.git`
-3. `cd NuvioExpo`
-4. `npm install` or `yarn install`
-5. `npx expo start` (Easiest way: Scan QR code with Expo Go app)
- * Or `npx expo run:android` / `npx expo run:ios` for native builds.
+### Prerequisites
+- Node.js 18 or newer
+- npm or yarn
+- Expo Go app (for development)
+- Android Studio (for Android builds)
+- Xcode (for iOS builds)
-## Found a bug or have an idea?
+### Development Setup
+1. Clone the repository:
+ ```bash
+ git clone https://github.com/tapframe/NuvioStreaming.git
+ cd NuvioStreaming
+ ```
-Great! Please open an [Issue on GitHub](https://github.com/nayifleo1/NuvioExpo/issues). Describe the problem or your suggestion.
+2. Install dependencies:
+ ```bash
+ npm install
+ # or
+ yarn install
+ ```
-## Contribution
+3. Start the development server:
+ ```bash
+ npx expo start
+ ```
-Contributions are welcome! Fork the repository, make your changes, and submit a Pull Request.
+4. Run on device/simulator:
+ - Scan QR code with Expo Go app
+ - Or run native builds:
+ ```bash
+ npx expo run:android
+ # or
+ npx expo run:ios
+ ```
+
+## 🤝 Contributing
+
+We welcome contributions! Here's how you can help:
+
+1. Fork the repository
+2. Create your feature branch
+3. Commit your changes
+4. Push to the branch
+5. Open a Pull Request
+
+## 🐛 Bug Reports & Feature Requests
+
+Found a bug or have an idea? Please open an [issue](https://github.com/tapframe/NuvioStreaming/issues) with:
+- Clear description of the problem/suggestion
+- Steps to reproduce (for bugs)
+- Expected behavior
+- Screenshots if applicable
+
+## 📝 Changelog
+
+See [CHANGELOG.md](CHANGELOG.md) for release history and changes.
+
+## 📄 License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## 🙏 Acknowledgments
+
+Built with help from the amazing communities behind:
+- React Native & Expo
+- TMDB API
+- Trakt.tv
+- Stremio
---
-Built with help from the communities and tools behind React Native, Expo, TMDB, Trakt, and the Stremio addon system.
-
-*Happy Streaming!*
\ No newline at end of file
+
+ Happy Streaming! 🎬
+
\ No newline at end of file
diff --git a/app.json b/app.json
index 5cb6c50..e592d6a 100644
--- a/app.json
+++ b/app.json
@@ -24,7 +24,9 @@
],
"NSLocalNetworkUsageDescription": "App uses the local network to discover and connect to devices.",
"NSMicrophoneUsageDescription": "This app does not require microphone access.",
- "UIBackgroundModes": ["audio"],
+ "UIBackgroundModes": [
+ "audio"
+ ],
"LSSupportsOpeningDocumentsInPlace": true,
"UIFileSharingEnabled": true
},
@@ -34,8 +36,12 @@
{
"name": "Matroska Video",
"role": "viewer",
- "utis": ["org.matroska.mkv"],
- "extensions": ["mkv"]
+ "utis": [
+ "org.matroska.mkv"
+ ],
+ "extensions": [
+ "mkv"
+ ]
}
]
},
@@ -65,6 +71,16 @@
"projectId": "909107b8-fe61-45ce-b02f-b02510d306a6"
}
},
- "owner": "nayifleo"
+ "owner": "nayifleo",
+ "plugins": [
+ [
+ "@sentry/react-native/expo",
+ {
+ "url": "https://sentry.io/",
+ "project": "react-native",
+ "organization": "tapframe"
+ }
+ ]
+ ]
}
}
diff --git a/assets/audio/profile-selected.mp3 b/assets/audio/profile-selected.mp3
deleted file mode 100644
index 94bc727..0000000
Binary files a/assets/audio/profile-selected.mp3 and /dev/null differ
diff --git a/assets/gifs/demo.gif b/assets/gifs/demo.gif
deleted file mode 100644
index 395f879..0000000
Binary files a/assets/gifs/demo.gif and /dev/null differ
diff --git a/assets/images/adaptive-icon.png b/assets/images/adaptive-icon.png
deleted file mode 100644
index da1618e..0000000
Binary files a/assets/images/adaptive-icon.png and /dev/null differ
diff --git a/assets/images/app-icon.png b/assets/images/app-icon.png
deleted file mode 100644
index dac4426..0000000
Binary files a/assets/images/app-icon.png and /dev/null differ
diff --git a/assets/images/favicon.png b/assets/images/favicon.png
deleted file mode 100644
index e75f697..0000000
Binary files a/assets/images/favicon.png and /dev/null differ
diff --git a/assets/images/icon.png b/assets/images/icon.png
deleted file mode 100644
index dac4426..0000000
Binary files a/assets/images/icon.png and /dev/null differ
diff --git a/assets/images/partial-react-logo.png b/assets/images/partial-react-logo.png
deleted file mode 100644
index 66fd957..0000000
Binary files a/assets/images/partial-react-logo.png and /dev/null differ
diff --git a/assets/images/react-logo.png b/assets/images/react-logo.png
deleted file mode 100644
index 917bb5b..0000000
Binary files a/assets/images/react-logo.png and /dev/null differ
diff --git a/assets/images/react-logo@2x.png b/assets/images/react-logo@2x.png
deleted file mode 100644
index f7b9229..0000000
Binary files a/assets/images/react-logo@2x.png and /dev/null differ
diff --git a/assets/images/react-logo@3x.png b/assets/images/react-logo@3x.png
deleted file mode 100644
index 5f60bdc..0000000
Binary files a/assets/images/react-logo@3x.png and /dev/null differ
diff --git a/assets/images/replace-these/coming-soon.png b/assets/images/replace-these/coming-soon.png
deleted file mode 100644
index 8837ee4..0000000
Binary files a/assets/images/replace-these/coming-soon.png and /dev/null differ
diff --git a/assets/images/replace-these/download-netflix-icon.png b/assets/images/replace-these/download-netflix-icon.png
deleted file mode 100644
index de406d0..0000000
Binary files a/assets/images/replace-these/download-netflix-icon.png and /dev/null differ
diff --git a/assets/images/replace-these/download-netflix-transparent.png b/assets/images/replace-these/download-netflix-transparent.png
deleted file mode 100644
index 2d96168..0000000
Binary files a/assets/images/replace-these/download-netflix-transparent.png and /dev/null differ
diff --git a/assets/images/replace-these/everyone-watching.webp b/assets/images/replace-these/everyone-watching.webp
deleted file mode 100644
index 0e495e6..0000000
Binary files a/assets/images/replace-these/everyone-watching.webp and /dev/null differ
diff --git a/assets/images/replace-these/new-netflix-outline.png b/assets/images/replace-these/new-netflix-outline.png
deleted file mode 100644
index 62b0e3d..0000000
Binary files a/assets/images/replace-these/new-netflix-outline.png and /dev/null differ
diff --git a/assets/images/replace-these/new-netflix.png b/assets/images/replace-these/new-netflix.png
deleted file mode 100644
index d14c7cb..0000000
Binary files a/assets/images/replace-these/new-netflix.png and /dev/null differ
diff --git a/assets/images/replace-these/top10.png b/assets/images/replace-these/top10.png
deleted file mode 100644
index c723d75..0000000
Binary files a/assets/images/replace-these/top10.png and /dev/null differ
diff --git a/assets/images/splash.png b/assets/images/splash.png
deleted file mode 100644
index 32d01fe..0000000
Binary files a/assets/images/splash.png and /dev/null differ
diff --git a/assets/splash-icon.png b/assets/splash-icon.png
index 03d6f6b..5fa6129 100644
Binary files a/assets/splash-icon.png and b/assets/splash-icon.png differ
diff --git a/assets/titlelogo.png b/assets/titlelogo.png
index f1aebd5..a942923 100644
Binary files a/assets/titlelogo.png and b/assets/titlelogo.png differ
diff --git a/components/AndroidVideoPlayer.tsx b/components/AndroidVideoPlayer.tsx
index 8865071..73fe56f 100644
--- a/components/AndroidVideoPlayer.tsx
+++ b/components/AndroidVideoPlayer.tsx
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react';
import { Platform } from 'react-native';
-import Video, { VideoRef, SelectedTrack, BufferingStrategyType } from 'react-native-video';
+import Video, { VideoRef, SelectedTrack, BufferingStrategyType, ResizeMode } from 'react-native-video';
interface VideoPlayerProps {
src: string;
@@ -9,6 +9,7 @@ interface VideoPlayerProps {
currentTime: number;
selectedAudioTrack?: SelectedTrack;
selectedTextTrack?: SelectedTrack;
+ resizeMode?: ResizeMode;
onProgress?: (data: { currentTime: number; playableDuration: number }) => void;
onLoad?: (data: { duration: number }) => void;
onError?: (error: any) => void;
@@ -24,6 +25,7 @@ export const AndroidVideoPlayer: React.FC = ({
currentTime,
selectedAudioTrack,
selectedTextTrack,
+ resizeMode = 'contain' as ResizeMode,
onProgress,
onLoad,
onError,
@@ -93,7 +95,7 @@ export const AndroidVideoPlayer: React.FC = ({
onBuffer={handleBuffer}
onError={handleError}
onEnd={handleEnd}
- resizeMode="contain"
+ resizeMode={resizeMode}
controls={false}
playInBackground={false}
playWhenInactive={false}
diff --git a/metro.config.js b/metro.config.js
index 79fe23f..dc78200 100644
--- a/metro.config.js
+++ b/metro.config.js
@@ -1,6 +1,8 @@
-const { getDefaultConfig } = require('expo/metro-config');
+const {
+ getSentryExpoConfig
+} = require("@sentry/react-native/metro");
-const config = getDefaultConfig(__dirname);
+const config = getSentryExpoConfig(__dirname);
// Enable tree shaking and better minification
config.transformer = {
@@ -28,4 +30,4 @@ config.resolver = {
resolverMainFields: ['react-native', 'browser', 'main'],
};
-module.exports = config;
\ No newline at end of file
+module.exports = config;
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index b49aae1..0fdb38d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -21,6 +21,7 @@
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
"@react-navigation/stack": "^7.2.10",
+ "@sentry/react-native": "^6.15.1",
"@shopify/flash-list": "1.7.3",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
@@ -61,6 +62,7 @@
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
+ "react-native-shared-element": "^0.8.9",
"react-native-svg": "^15.11.2",
"react-native-tab-view": "^4.0.10",
"react-native-url-polyfill": "^2.0.0",
@@ -68,6 +70,7 @@
"react-native-vlc-media-player": "^1.0.87",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1",
+ "react-navigation-shared-element": "^3.1.3",
"subsrt": "^1.1.1"
},
"devDependencies": {
@@ -4014,6 +4017,349 @@
"join-component": "^1.1.0"
}
},
+ "node_modules/@sentry-internal/browser-utils": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-8.54.0.tgz",
+ "integrity": "sha512-DKWCqb4YQosKn6aD45fhKyzhkdG7N6goGFDeyTaJFREJDFVDXiNDsYZu30nJ6BxMM7uQIaARhPAC5BXfoED3pQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "8.54.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry-internal/feedback": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-8.54.0.tgz",
+ "integrity": "sha512-nQqRacOXoElpE0L0ADxUUII0I3A94niqG9Z4Fmsw6057QvyrV/LvTiMQBop6r5qLjwMqK+T33iR4/NQI5RhsXQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "8.54.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry-internal/replay": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-8.54.0.tgz",
+ "integrity": "sha512-8xuBe06IaYIGJec53wUC12tY2q4z2Z0RPS2s1sLtbA00EvK1YDGuXp96IDD+HB9mnDMrQ/jW5f97g9TvPsPQUg==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "8.54.0",
+ "@sentry/core": "8.54.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry-internal/replay-canvas": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-8.54.0.tgz",
+ "integrity": "sha512-K/On3OAUBeq/TV2n+1EvObKC+WMV9npVXpVyJqCCyn8HYMm8FUGzuxeajzm0mlW4wDTPCQor6mK9/IgOquUzCw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/replay": "8.54.0",
+ "@sentry/core": "8.54.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry/babel-plugin-component-annotate": {
+ "version": "3.5.0",
+ "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-3.5.0.tgz",
+ "integrity": "sha512-s2go8w03CDHbF9luFGtBHKJp4cSpsQzNVqgIa9Pfa4wnjipvrK6CxVT4icpLA3YO6kg5u622Yoa5GF3cJdippw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 14"
+ }
+ },
+ "node_modules/@sentry/browser": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-8.54.0.tgz",
+ "integrity": "sha512-BgUtvxFHin0fS0CmJVKTLXXZcke0Av729IVfi+2fJ4COX8HO7/HAP02RKaSQGmL2HmvWYTfNZ7529AnUtrM4Rg==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry-internal/browser-utils": "8.54.0",
+ "@sentry-internal/feedback": "8.54.0",
+ "@sentry-internal/replay": "8.54.0",
+ "@sentry-internal/replay-canvas": "8.54.0",
+ "@sentry/core": "8.54.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry/cli": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.46.0.tgz",
+ "integrity": "sha512-nqoPl7UCr446QFkylrsRrUXF51x8Z9dGquyf4jaQU+OzbOJMqclnYEvU6iwbwvaw3tu/2DnoZE/Og+Nq1h63sA==",
+ "hasInstallScript": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "https-proxy-agent": "^5.0.0",
+ "node-fetch": "^2.6.7",
+ "progress": "^2.0.3",
+ "proxy-from-env": "^1.1.0",
+ "which": "^2.0.2"
+ },
+ "bin": {
+ "sentry-cli": "bin/sentry-cli"
+ },
+ "engines": {
+ "node": ">= 10"
+ },
+ "optionalDependencies": {
+ "@sentry/cli-darwin": "2.46.0",
+ "@sentry/cli-linux-arm": "2.46.0",
+ "@sentry/cli-linux-arm64": "2.46.0",
+ "@sentry/cli-linux-i686": "2.46.0",
+ "@sentry/cli-linux-x64": "2.46.0",
+ "@sentry/cli-win32-arm64": "2.46.0",
+ "@sentry/cli-win32-i686": "2.46.0",
+ "@sentry/cli-win32-x64": "2.46.0"
+ }
+ },
+ "node_modules/@sentry/cli-darwin": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.46.0.tgz",
+ "integrity": "sha512-5Ll+e5KAdIk9OYiZO8aifMBRNWmNyPjSqdjaHlBC1Qfh7pE3b1zyzoHlsUazG0bv0sNrSGea8e7kF5wIO1hvyg==",
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-linux-arm": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.46.0.tgz",
+ "integrity": "sha512-WRrLNq/TEX/TNJkGqq6Ad0tGyapd5dwlxtsPbVBrIdryuL1mA7VCBoaHBr3kcwJLsgBHFH0lmkMee2ubNZZdkg==",
+ "cpu": [
+ "arm"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-linux-arm64": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.46.0.tgz",
+ "integrity": "sha512-OEJN8yAjI9y5B4telyqzu27Hi3+S4T8VxZCqJz1+z2Mp0Q/MZ622AahVPpcrVq/5bxrnlZR16+lKh8L1QwNFPg==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-linux-i686": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.46.0.tgz",
+ "integrity": "sha512-xko3/BVa4LX8EmRxVOCipV+PwfcK5Xs8lP6lgF+7NeuAHMNL4DqF6iV9rrN8gkGUHCUI9RXSve37uuZnFy55+Q==",
+ "cpu": [
+ "x86",
+ "ia32"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-linux-x64": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.46.0.tgz",
+ "integrity": "sha512-hJ1g5UEboYcOuRia96LxjJ0jhnmk8EWLDvlGnXLnYHkwy3ree/L7sNgdp/QsY8Z4j2PGO5f22Va+UDhSjhzlfQ==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "linux",
+ "freebsd",
+ "android"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-win32-arm64": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.46.0.tgz",
+ "integrity": "sha512-mN7cpPoCv2VExFRGHt+IoK11yx4pM4ADZQGEso5BAUZ5duViXB2WrAXCLd8DrwMnP0OE978a7N8OtzsFqjkbNA==",
+ "cpu": [
+ "arm64"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-win32-i686": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.46.0.tgz",
+ "integrity": "sha512-6F73AUE3lm71BISUO19OmlnkFD5WVe4/wA1YivtLZTc1RU3eUYJLYxhDfaH3P77+ycDppQ2yCgemLRaA4A8mNQ==",
+ "cpu": [
+ "x86",
+ "ia32"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli-win32-x64": {
+ "version": "2.46.0",
+ "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.46.0.tgz",
+ "integrity": "sha512-yuGVcfepnNL84LGA0GjHzdMIcOzMe0bjPhq/rwPsPN+zu11N+nPR2wV2Bum4U0eQdqYH3iAlMdL5/BEQfuLJww==",
+ "cpu": [
+ "x64"
+ ],
+ "license": "BSD-3-Clause",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@sentry/cli/node_modules/agent-base": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
+ "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6.0.0"
+ }
+ },
+ "node_modules/@sentry/cli/node_modules/https-proxy-agent": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
+ "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
+ "license": "MIT",
+ "dependencies": {
+ "agent-base": "6",
+ "debug": "4"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
+ "node_modules/@sentry/core": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry/core/-/core-8.54.0.tgz",
+ "integrity": "sha512-03bWf+D1j28unOocY/5FDB6bUHtYlm6m6ollVejhg45ZmK9iPjdtxNWbrLsjT1WRym0Tjzowu+A3p+eebYEv0Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry/react": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry/react/-/react-8.54.0.tgz",
+ "integrity": "sha512-42T/fp8snYN19Fy/2P0Mwotu4gcdy+1Lx+uYCNcYP1o7wNGigJ7qb27sW7W34GyCCHjoCCfQgeOqDQsyY8LC9w==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/browser": "8.54.0",
+ "@sentry/core": "8.54.0",
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "engines": {
+ "node": ">=14.18"
+ },
+ "peerDependencies": {
+ "react": "^16.14.0 || 17.x || 18.x || 19.x"
+ }
+ },
+ "node_modules/@sentry/react-native": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/@sentry/react-native/-/react-native-6.15.1.tgz",
+ "integrity": "sha512-uNYjkhi7LUeXe+a3ui3N+sUZ4PbBh/P3Q6Pz5esOQOAEV1N7hxkdnHVic1cVHsirEQvy9rUJPBnja47Va7OpQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/babel-plugin-component-annotate": "3.5.0",
+ "@sentry/browser": "8.54.0",
+ "@sentry/cli": "2.46.0",
+ "@sentry/core": "8.54.0",
+ "@sentry/react": "8.54.0",
+ "@sentry/types": "8.54.0",
+ "@sentry/utils": "8.54.0"
+ },
+ "bin": {
+ "sentry-expo-upload-sourcemaps": "scripts/expo-upload-sourcemaps.js"
+ },
+ "peerDependencies": {
+ "expo": ">=49.0.0",
+ "react": ">=17.0.0",
+ "react-native": ">=0.65.0"
+ },
+ "peerDependenciesMeta": {
+ "expo": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@sentry/types": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry/types/-/types-8.54.0.tgz",
+ "integrity": "sha512-wztdtr7dOXQKi0iRvKc8XJhJ7HaAfOv8lGu0yqFOFwBZucO/SHnu87GOPi8mvrTiy1bentQO5l+zXWAaMvG4uw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "8.54.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
+ "node_modules/@sentry/utils": {
+ "version": "8.54.0",
+ "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-8.54.0.tgz",
+ "integrity": "sha512-JL8UDjrsKxKclTdLXfuHfE7B3KbrAPEYP7tMyN/xiO2vsF6D84fjwYyalO0ZMtuFZE6vpSze8ZOLEh6hLnPYsw==",
+ "license": "MIT",
+ "dependencies": {
+ "@sentry/core": "8.54.0"
+ },
+ "engines": {
+ "node": ">=14.18"
+ }
+ },
"node_modules/@shopify/flash-list": {
"version": "1.7.3",
"resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-1.7.3.tgz",
@@ -12247,6 +12593,12 @@
"react-native": "*"
}
},
+ "node_modules/react-native-shared-element": {
+ "version": "0.8.9",
+ "resolved": "https://registry.npmjs.org/react-native-shared-element/-/react-native-shared-element-0.8.9.tgz",
+ "integrity": "sha512-vlzhv3amkJm+8gA0WSeLzcCKNtN/ypZbic3IZ4Bwwr6GeWDrYzZ6k7PdHCioy7fwIVOJ1X9Pi/aYF9HK4Kb0qg==",
+ "license": "MIT"
+ },
"node_modules/react-native-slider": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-native-slider/-/react-native-slider-0.11.0.tgz",
@@ -12627,6 +12979,20 @@
"async-limiter": "~1.0.0"
}
},
+ "node_modules/react-navigation-shared-element": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/react-navigation-shared-element/-/react-navigation-shared-element-3.1.3.tgz",
+ "integrity": "sha512-U1BZp7dEdcTNHggfkq3WEBlJeg4HwFhFdj7a0i0Uql/7mg2IHQg/bZaqM2jQvJITkABge6Hz5fZixIF8jyzpkg==",
+ "license": "MIT",
+ "dependencies": {
+ "hoist-non-react-statics": "^3.3.2"
+ },
+ "peerDependencies": {
+ "react": "*",
+ "react-native": "*",
+ "react-native-shared-element": "*"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.14.2",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
diff --git a/package.json b/package.json
index 6aae894..a6eb7ec 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
"@react-navigation/native": "^7.1.6",
"@react-navigation/native-stack": "^7.3.10",
"@react-navigation/stack": "^7.2.10",
+ "@sentry/react-native": "^6.15.1",
"@shopify/flash-list": "1.7.3",
"@types/lodash": "^4.17.16",
"@types/react-native-video": "^5.0.20",
@@ -62,6 +63,7 @@
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
+ "react-native-shared-element": "^0.8.9",
"react-native-svg": "^15.11.2",
"react-native-tab-view": "^4.0.10",
"react-native-url-polyfill": "^2.0.0",
@@ -69,6 +71,7 @@
"react-native-vlc-media-player": "^1.0.87",
"react-native-web": "~0.19.13",
"react-native-wheel-color-picker": "^1.3.1",
+ "react-navigation-shared-element": "^3.1.3",
"subsrt": "^1.1.1"
},
"devDependencies": {
diff --git a/src/components/discover/CatalogSection.tsx b/src/components/discover/CatalogSection.tsx
index 68b0251..72cb381 100644
--- a/src/components/discover/CatalogSection.tsx
+++ b/src/components/discover/CatalogSection.tsx
@@ -64,11 +64,11 @@ const CatalogSection = ({ catalog, selectedCategory }: CatalogSectionProps) => {
- See All
-
+ View All
+
@@ -119,13 +119,15 @@ const styles = StyleSheet.create({
seeAllButton: {
flexDirection: 'row',
alignItems: 'center',
- gap: 4,
- paddingVertical: 6,
- paddingHorizontal: 4,
+ paddingVertical: 8,
+ paddingHorizontal: 10,
+ borderRadius: 20,
+ marginRight: -10,
},
seeAllText: {
- fontWeight: '600',
fontSize: 14,
+ fontWeight: '600',
+ marginRight: 4,
},
});
diff --git a/src/components/discover/ContentItem.tsx b/src/components/discover/ContentItem.tsx
index de22a61..1f7e85d 100644
--- a/src/components/discover/ContentItem.tsx
+++ b/src/components/discover/ContentItem.tsx
@@ -27,8 +27,11 @@ const ContentItem = ({ item, onPress, width }: ContentItemProps) => {
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
- cachePolicy="memory-disk"
- transition={300}
+ cachePolicy="memory"
+ transition={200}
+ placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
+ placeholderContentFit="cover"
+ recyclingKey={item.id}
/>
{
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
- console.log(`[CatalogSection] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
-
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
- console.log(`[CatalogSection] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
@@ -79,17 +76,12 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
return (
- {catalog.name}
-
+ {catalog.name}
+
@@ -99,10 +91,10 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
addonId: catalog.addon
})
}
- style={styles.seeAllButton}
+ style={styles.viewAllButton}
>
- See More
-
+ View All
+
@@ -133,41 +125,46 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
const styles = StyleSheet.create({
catalogContainer: {
- marginBottom: 24,
+ marginBottom: 28,
},
catalogHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
- marginBottom: 12,
+ marginBottom: 16,
},
titleContainer: {
position: 'relative',
},
catalogTitle: {
- fontSize: 19,
- fontWeight: '700',
- letterSpacing: 0.2,
+ fontSize: 24,
+ fontWeight: '800',
+ letterSpacing: 0.5,
marginBottom: 4,
},
titleUnderline: {
position: 'absolute',
bottom: -2,
left: 0,
- width: 35,
- height: 2,
- borderRadius: 1,
+ width: 40,
+ height: 3,
+ borderRadius: 2,
opacity: 0.8,
},
- seeAllButton: {
+ viewAllButton: {
flexDirection: 'row',
alignItems: 'center',
- gap: 4,
+ paddingVertical: 8,
+ paddingHorizontal: 10,
+ borderRadius: 20,
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ marginRight: -10,
},
- seeAllText: {
+ viewAllText: {
fontSize: 14,
fontWeight: '600',
+ marginRight: 4,
},
catalogList: {
paddingHorizontal: 16,
diff --git a/src/components/home/ContentItem.tsx b/src/components/home/ContentItem.tsx
index 860a301..b222903 100644
--- a/src/components/home/ContentItem.tsx
+++ b/src/components/home/ContentItem.tsx
@@ -4,7 +4,7 @@ import { Image as ExpoImage } from 'expo-image';
import { MaterialIcons } from '@expo/vector-icons';
import { useTheme } from '../../contexts/ThemeContext';
import { catalogService, StreamingContent } from '../../services/catalogService';
-import DropUpMenu from './DropUpMenu';
+import { DropUpMenu } from './DropUpMenu';
interface ContentItemProps {
item: StreamingContent;
@@ -34,11 +34,8 @@ const calculatePosterLayout = (screenWidth: number) => {
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
- console.log(`[ContentItem] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
-
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
- console.log(`[ContentItem] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
@@ -53,9 +50,8 @@ const calculatePosterLayout = (screenWidth: number) => {
const posterLayout = calculatePosterLayout(width);
const POSTER_WIDTH = posterLayout.posterWidth;
-const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
+const ContentItem = React.memo(({ item, onPress }: ContentItemProps) => {
const [menuVisible, setMenuVisible] = useState(false);
- const [localItem, setLocalItem] = useState(initialItem);
const [isWatched, setIsWatched] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
const [imageError, setImageError] = useState(false);
@@ -66,16 +62,16 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
}, []);
const handlePress = useCallback(() => {
- onPress(localItem.id, localItem.type);
- }, [localItem.id, localItem.type, onPress]);
+ onPress(item.id, item.type);
+ }, [item.id, item.type, onPress]);
const handleOptionSelect = useCallback((option: string) => {
switch (option) {
case 'library':
- if (localItem.inLibrary) {
- catalogService.removeFromLibrary(localItem.type, localItem.id);
+ if (item.inLibrary) {
+ catalogService.removeFromLibrary(item.type, item.id);
} else {
- catalogService.addToLibrary(localItem);
+ catalogService.addToLibrary(item);
}
break;
case 'watched':
@@ -86,27 +82,12 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
case 'share':
break;
}
- }, [localItem]);
+ }, [item]);
const handleMenuClose = useCallback(() => {
setMenuVisible(false);
}, []);
- useEffect(() => {
- setLocalItem(initialItem);
- }, [initialItem]);
-
- useEffect(() => {
- const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
- const isInLibrary = libraryItems.some(
- libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type
- );
- setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
- });
-
- return () => unsubscribe();
- }, [localItem.id, localItem.type]);
-
return (
<>
{
>
{
setImageLoaded(false);
setImageError(false);
@@ -148,7 +131,7 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
)}
- {localItem.inLibrary && (
+ {item.inLibrary && (
@@ -159,12 +142,12 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
>
);
-};
+});
const styles = StyleSheet.create({
contentItem: {
diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx
index 7aeda06..1035d8a 100644
--- a/src/components/home/ContinueWatchingSection.tsx
+++ b/src/components/home/ContinueWatchingSection.tsx
@@ -77,10 +77,20 @@ const ContinueWatchingSection = React.forwardRef((props, re
const appState = useRef(AppState.currentState);
const refreshTimerRef = useRef(null);
+ // Use a state to track if a background refresh is in progress
+ const [isRefreshing, setIsRefreshing] = useState(false);
+
// Modified loadContinueWatching to be more efficient
- const loadContinueWatching = useCallback(async () => {
- try {
+ const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
+ // Prevent multiple concurrent refreshes
+ if (isRefreshing) return;
+
+ if (!isBackgroundRefresh) {
setLoading(true);
+ }
+ setIsRefreshing(true);
+
+ try {
const allProgress = await storageService.getAllWatchProgress();
if (Object.keys(allProgress).length === 0) {
@@ -187,16 +197,15 @@ const ContinueWatchingSection = React.forwardRef((props, re
// Sort by last updated time (most recent first)
progressItems.sort((a, b) => b.lastUpdated - a.lastUpdated);
- // Limit to 10 items
- const finalItems = progressItems.slice(0, 10);
-
- setContinueWatchingItems(finalItems);
+ // Show all continue watching items (no limit)
+ setContinueWatchingItems(progressItems);
} catch (error) {
logger.error('Failed to load continue watching items:', error);
} finally {
setLoading(false);
+ setIsRefreshing(false);
}
- }, []);
+ }, [isRefreshing]);
// Function to handle app state changes
const handleAppStateChange = useCallback((nextAppState: AppStateStatus) => {
@@ -204,8 +213,8 @@ const ContinueWatchingSection = React.forwardRef((props, re
appState.current.match(/inactive|background/) &&
nextAppState === 'active'
) {
- // App has come to the foreground - refresh data
- loadContinueWatching();
+ // App has come to the foreground - trigger a background refresh
+ loadContinueWatching(true);
}
appState.current = nextAppState;
}, [loadContinueWatching]);
@@ -222,8 +231,9 @@ const ContinueWatchingSection = React.forwardRef((props, re
clearTimeout(refreshTimerRef.current);
}
refreshTimerRef.current = setTimeout(() => {
- loadContinueWatching();
- }, 300);
+ // Trigger a background refresh
+ loadContinueWatching(true);
+ }, 500); // Increased debounce time slightly
};
// Try to set up a custom event listener or use a timer as fallback
@@ -238,7 +248,7 @@ const ContinueWatchingSection = React.forwardRef((props, re
};
} else {
// Fallback: poll for updates every 30 seconds
- const intervalId = setInterval(loadContinueWatching, 30000);
+ const intervalId = setInterval(() => loadContinueWatching(true), 30000);
return () => {
subscription.remove();
clearInterval(intervalId);
@@ -254,13 +264,12 @@ const ContinueWatchingSection = React.forwardRef((props, re
loadContinueWatching();
}, [loadContinueWatching]);
- // Properly expose the refresh method
+ // Expose the refresh function via the ref
React.useImperativeHandle(ref, () => ({
refresh: async () => {
- await loadContinueWatching();
- // Return whether there are items to help parent determine visibility
- const hasItems = continueWatchingItems.length > 0;
- return hasItems;
+ // Allow manual refresh to show loading indicator
+ await loadContinueWatching(false);
+ return true;
}
}));
@@ -274,16 +283,11 @@ const ContinueWatchingSection = React.forwardRef((props, re
}
return (
-
+
- Continue Watching
-
+ Continue Watching
+
@@ -302,11 +306,14 @@ const ContinueWatchingSection = React.forwardRef((props, re
{/* Poster Image */}
@@ -386,7 +393,7 @@ const ContinueWatchingSection = React.forwardRef((props, re
const styles = StyleSheet.create({
container: {
- marginBottom: 24,
+ marginBottom: 28,
paddingTop: 0,
marginTop: 12,
},
@@ -395,15 +402,15 @@ const styles = StyleSheet.create({
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
- marginBottom: 12,
+ marginBottom: 16,
},
titleContainer: {
position: 'relative',
},
title: {
- fontSize: 20,
- fontWeight: '700',
- letterSpacing: 0.3,
+ fontSize: 24,
+ fontWeight: '800',
+ letterSpacing: 0.5,
marginBottom: 4,
},
titleUnderline: {
@@ -411,8 +418,8 @@ const styles = StyleSheet.create({
bottom: -2,
left: 0,
width: 40,
- height: 2,
- borderRadius: 1,
+ height: 3,
+ borderRadius: 2,
opacity: 0.8,
},
wideList: {
@@ -436,7 +443,7 @@ const styles = StyleSheet.create({
width: 80,
height: '100%',
},
- widePoster: {
+ continueWatchingPoster: {
width: '100%',
height: '100%',
borderTopLeftRadius: 12,
diff --git a/src/components/home/FeaturedContent.tsx b/src/components/home/FeaturedContent.tsx
index 89389d8..64ebe7b 100644
--- a/src/components/home/FeaturedContent.tsx
+++ b/src/components/home/FeaturedContent.tsx
@@ -99,16 +99,35 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
// Preload the image
const preloadImage = async (url: string): Promise => {
- if (!url) return false;
+ // Skip if already cached to prevent redundant prefetch
if (imageCache[url]) return true;
try {
- // For Metahub logos, only do validation if enabled
- // Note: Temporarily disable metahub validation until fixed
- if (false && url.includes('metahub.space')) {
+ // Basic URL validation
+ 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 {
- const isValid = await isValidMetahubLogo(url);
- if (!isValid) {
+ // 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) {
@@ -117,10 +136,22 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
}
// Always attempt to prefetch the image regardless of format validation
- await ExpoImage.prefetch(url);
+ // 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;
return true;
} catch (error) {
+ // Clear any partial cache entry on error
+ delete imageCache[url];
return false;
}
};
@@ -355,7 +386,6 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
}));
} else {
setLogoLoadError(true);
- console.warn(`[FeaturedContent] Logo prefetch failed, falling back to text: ${logoUrl}`);
}
}
};
@@ -363,13 +393,27 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
loadImages();
}, [featuredContent?.id, logoUrl]);
+ const onLogoLoadError = () => {
+ setLogoLoaded(true); // Treat error as "loaded" to stop spinner
+ setLogoError(true);
+ };
+
+ const handleInfoPress = () => {
+ if (featuredContent) {
+ navigation.navigate('Metadata', {
+ id: featuredContent.id,
+ type: featuredContent.type
+ });
+ }
+ };
+
if (!featuredContent) {
return ;
}
return (
{
- console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`);
- setLogoLoadError(true);
- }}
+ cachePolicy="memory"
+ transition={300}
+ recyclingKey={`logo-${featuredContent.id}`}
+ onError={onLogoLoadError}
/>
) : (
@@ -473,14 +515,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
{
- if (featuredContent) {
- navigation.navigate('Metadata', {
- id: featuredContent.id,
- type: featuredContent.type
- });
- }
- }}
+ onPress={handleInfoPress}
activeOpacity={0.7}
>
diff --git a/src/components/home/ThisWeekSection.tsx b/src/components/home/ThisWeekSection.tsx
index 30ebfe4..fbba9d7 100644
--- a/src/components/home/ThisWeekSection.tsx
+++ b/src/components/home/ThisWeekSection.tsx
@@ -20,11 +20,10 @@ import { useLibrary } from '../../hooks/useLibrary';
import { RootStackParamList } from '../../navigation/AppNavigator';
import { parseISO, isThisWeek, format, isAfter, isBefore } from 'date-fns';
import Animated, { FadeIn, FadeInRight } from 'react-native-reanimated';
-import { catalogService } from '../../services/catalogService';
const { width } = Dimensions.get('window');
-const ITEM_WIDTH = width * 0.85;
-const ITEM_HEIGHT = 180;
+const ITEM_WIDTH = width * 0.75; // Reduced width for better spacing
+const ITEM_HEIGHT = 180; // Compact height for cleaner design
interface ThisWeekEpisode {
id: string;
@@ -128,22 +127,13 @@ export const ThisWeekSection = () => {
}
}, [libraryItems]);
- // Subscribe to library updates
- useEffect(() => {
- const unsubscribe = catalogService.subscribeToLibraryUpdates(() => {
- console.log('[ThisWeekSection] Library updated, refreshing episodes');
- fetchThisWeekEpisodes();
- });
-
- return () => unsubscribe();
- }, [fetchThisWeekEpisodes]);
-
- // Initial load
+ // Load episodes when library items change
useEffect(() => {
if (!libraryLoading) {
+ console.log('[ThisWeekSection] Library items changed, refreshing episodes. Items count:', libraryItems.length);
fetchThisWeekEpisodes();
}
- }, [libraryLoading, fetchThisWeekEpisodes]);
+ }, [libraryLoading, libraryItems, fetchThisWeekEpisodes]);
const handleEpisodePress = (episode: ThisWeekEpisode) => {
// For upcoming episodes, go to the metadata screen
@@ -173,7 +163,10 @@ export const ThisWeekSection = () => {
if (loading) {
return (
-
+
+
+ Loading this week's episodes...
+
);
}
@@ -196,72 +189,72 @@ export const ThisWeekSection = () => {
return (
handleEpisodePress(item)}
- activeOpacity={0.7}
+ activeOpacity={0.8}
>
+
+ {/* Enhanced gradient overlay */}
-
-
-
-
- {isReleased ? 'Released' : 'Coming Soon'}
+ locations={[0, 0.4, 0.6, 0.8, 1]}
+ >
+ {/* Content area */}
+
+
+ {item.seriesName}
+
+
+
+ {item.title}
-
- {item.vote_average > 0 && (
-
+ {item.overview && (
+
+ {item.overview}
+
+ )}
+
+
+
+ S{item.season}:E{item.episode} •
+
-
- {item.vote_average.toFixed(1)}
+
+ {formattedDate}
- )}
-
-
-
-
- {item.seriesName}
-
-
- S{item.season}:E{item.episode} - {item.title}
-
- {item.overview ? (
-
- {item.overview}
-
- ) : null}
-
- {formattedDate}
-
+
);
@@ -270,10 +263,13 @@ export const ThisWeekSection = () => {
return (
+
This Week
+
+
- View All
-
+ View All
+
@@ -284,8 +280,10 @@ export const ThisWeekSection = () => {
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContent}
- snapToInterval={ITEM_WIDTH + 12}
+ snapToInterval={ITEM_WIDTH + 16}
decelerationRate="fast"
+ snapToAlignment="start"
+ ItemSeparatorComponent={() => }
/>
);
@@ -293,109 +291,129 @@ export const ThisWeekSection = () => {
const styles = StyleSheet.create({
container: {
- marginVertical: 16,
+ marginVertical: 20,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: 16,
- marginBottom: 12,
+ marginBottom: 16,
+ },
+ titleContainer: {
+ position: 'relative',
},
title: {
- fontSize: 19,
- fontWeight: '700',
- letterSpacing: 0.2,
+ fontSize: 24,
+ fontWeight: '800',
+ letterSpacing: 0.5,
+ marginBottom: 4,
+ },
+ titleUnderline: {
+ position: 'absolute',
+ bottom: -2,
+ left: 0,
+ width: 40,
+ height: 3,
+ borderRadius: 2,
+ opacity: 0.8,
},
viewAllButton: {
flexDirection: 'row',
alignItems: 'center',
+ paddingVertical: 8,
+ paddingHorizontal: 10,
+ borderRadius: 20,
+ backgroundColor: 'rgba(255,255,255,0.1)',
+ marginRight: -10,
},
viewAllText: {
fontSize: 14,
+ fontWeight: '600',
marginRight: 4,
},
listContent: {
- paddingHorizontal: 8,
+ paddingLeft: 16,
+ paddingRight: 16,
+ paddingBottom: 8,
},
loadingContainer: {
- padding: 20,
+ padding: 32,
alignItems: 'center',
},
+ loadingText: {
+ marginTop: 12,
+ fontSize: 16,
+ fontWeight: '500',
+ },
episodeItemContainer: {
width: ITEM_WIDTH,
height: ITEM_HEIGHT,
- marginHorizontal: 6,
},
episodeItem: {
width: '100%',
height: '100%',
- borderRadius: 8,
+ borderRadius: 16,
overflow: 'hidden',
+ shadowOffset: { width: 0, height: 8 },
+ shadowOpacity: 0.3,
+ shadowRadius: 12,
+ elevation: 12,
+ },
+ imageContainer: {
+ width: '100%',
+ height: '100%',
+ position: 'relative',
},
poster: {
width: '100%',
height: '100%',
+ borderRadius: 16,
},
gradient: {
position: 'absolute',
left: 0,
right: 0,
+ top: 0,
bottom: 0,
- height: '80%',
justifyContent: 'flex-end',
- padding: 16,
+ padding: 12,
+ borderRadius: 16,
},
- badgeContainer: {
- flexDirection: 'row',
- alignItems: 'center',
- justifyContent: 'space-between',
- marginBottom: 12,
- },
- badge: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 8,
- paddingVertical: 4,
- borderRadius: 4,
- },
- releasedBadge: {},
- upcomingBadge: {},
- badgeText: {
- fontSize: 10,
- fontWeight: 'bold',
- marginLeft: 4,
- },
- ratingBadge: {
- flexDirection: 'row',
- alignItems: 'center',
- paddingHorizontal: 8,
- paddingVertical: 4,
- borderRadius: 4,
- },
- ratingText: {
- fontSize: 10,
- fontWeight: 'bold',
- marginLeft: 4,
- },
- content: {
+ contentArea: {
width: '100%',
},
seriesName: {
fontSize: 16,
- fontWeight: 'bold',
- marginBottom: 4,
+ fontWeight: '700',
+ marginBottom: 6,
},
episodeTitle: {
fontSize: 14,
+ fontWeight: '600',
marginBottom: 4,
+ lineHeight: 18,
},
overview: {
fontSize: 12,
- marginBottom: 4,
+ lineHeight: 16,
+ marginBottom: 6,
+ opacity: 0.9,
+ },
+ dateContainer: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ marginTop: 4,
+ },
+ episodeInfo: {
+ fontSize: 12,
+ fontWeight: '600',
+ marginRight: 4,
},
releaseDate: {
- fontSize: 12,
- fontWeight: 'bold',
+ fontSize: 13,
+ fontWeight: '600',
+ marginLeft: 6,
+ letterSpacing: 0.3,
},
});
\ No newline at end of file
diff --git a/src/components/metadata/CastSection.tsx b/src/components/metadata/CastSection.tsx
index f904eb7..305f31e 100644
--- a/src/components/metadata/CastSection.tsx
+++ b/src/components/metadata/CastSection.tsx
@@ -10,7 +10,6 @@ import {
import { Image } from 'expo-image';
import Animated, {
FadeIn,
- Layout,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
@@ -42,8 +41,7 @@ export const CastSection: React.FC = ({
return (
Cast
@@ -56,8 +54,7 @@ export const CastSection: React.FC = ({
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
= ({
transition={200}
/>
) : (
-
+
{item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)}
diff --git a/src/components/metadata/HeroSection.tsx b/src/components/metadata/HeroSection.tsx
index 80d15ab..78af079 100644
--- a/src/components/metadata/HeroSection.tsx
+++ b/src/components/metadata/HeroSection.tsx
@@ -21,6 +21,7 @@ import Animated, {
withTiming,
runOnJS,
withRepeat,
+ FadeIn,
} from 'react-native-reanimated';
import { useTheme } from '../../contexts/ThemeContext';
import { useTraktContext } from '../../contexts/TraktContext';
@@ -684,20 +685,24 @@ const HeroSection: React.FC = ({
}]
}), []);
- // Ultra-optimized genre rendering
+ // Ultra-optimized genre rendering with smooth animation
const genreElements = useMemo(() => {
if (!metadata?.genres?.length) return null;
const genresToDisplay = metadata.genres.slice(0, 3); // Reduced to 3 for performance
return genresToDisplay.map((genreName: string, index: number, array: string[]) => (
-
+
{genreName}
{index < array.length - 1 && (
•
)}
-
+
));
}, [metadata.genres, currentTheme.colors.text]);
diff --git a/src/components/metadata/MetadataDetails.tsx b/src/components/metadata/MetadataDetails.tsx
index 1011359..fdc4b43 100644
--- a/src/components/metadata/MetadataDetails.tsx
+++ b/src/components/metadata/MetadataDetails.tsx
@@ -60,7 +60,7 @@ const MetadataDetails: React.FC = ({
{/* Creator/Director Info */}
{metadata.directors && metadata.directors.length > 0 && (
@@ -81,7 +81,7 @@ const MetadataDetails: React.FC = ({
{metadata.description && (
setIsFullDescriptionOpen(!isFullDescriptionOpen)}
diff --git a/src/components/metadata/SeriesContent.tsx b/src/components/metadata/SeriesContent.tsx
index 5f5feb5..6a9e731 100644
--- a/src/components/metadata/SeriesContent.tsx
+++ b/src/components/metadata/SeriesContent.tsx
@@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef } from 'react';
-import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme } from 'react-native';
+import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList } from 'react-native';
import { Image } from 'expo-image';
import MaterialIcons from 'react-native-vector-icons/MaterialIcons';
import { LinearGradient } from 'expo-linear-gradient';
@@ -45,6 +45,8 @@ export const SeriesContent: React.FC = ({
// Add refs for the scroll views
const seasonScrollViewRef = useRef(null);
const episodeScrollViewRef = useRef(null);
+
+
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
@@ -69,18 +71,12 @@ export const SeriesContent: React.FC = ({
// Function to find and scroll to the most recently watched episode
const scrollToMostRecentEpisode = () => {
- if (!metadata?.id || !episodeScrollViewRef.current || settings.episodeLayoutStyle !== 'horizontal') {
- console.log('[SeriesContent] Scroll conditions not met:', {
- hasMetadataId: !!metadata?.id,
- hasScrollRef: !!episodeScrollViewRef.current,
- isHorizontal: settings.episodeLayoutStyle === 'horizontal'
- });
+ if (!metadata?.id || !episodeScrollViewRef.current || !settings?.episodeLayoutStyle || settings.episodeLayoutStyle !== 'horizontal') {
return;
}
const currentSeasonEpisodes = groupedEpisodes[selectedSeason] || [];
if (currentSeasonEpisodes.length === 0) {
- console.log('[SeriesContent] No episodes in current season:', selectedSeason);
return;
}
@@ -100,30 +96,18 @@ export const SeriesContent: React.FC = ({
}
});
- console.log('[SeriesContent] Episode scroll analysis:', {
- totalEpisodes: currentSeasonEpisodes.length,
- mostRecentIndex: mostRecentEpisodeIndex,
- mostRecentEpisode: mostRecentEpisodeName,
- selectedSeason
- });
-
// Scroll to the most recently watched episode if found
if (mostRecentEpisodeIndex >= 0) {
const cardWidth = isTablet ? width * 0.4 + 16 : width * 0.85 + 16;
const scrollPosition = mostRecentEpisodeIndex * cardWidth;
- console.log('[SeriesContent] Scrolling to episode:', {
- index: mostRecentEpisodeIndex,
- cardWidth,
- scrollPosition,
- episodeName: mostRecentEpisodeName
- });
-
setTimeout(() => {
- episodeScrollViewRef.current?.scrollTo({
- x: scrollPosition,
- animated: true
- });
+ if (episodeScrollViewRef.current && typeof (episodeScrollViewRef.current as any).scrollToOffset === 'function') {
+ (episodeScrollViewRef.current as any).scrollToOffset({
+ offset: scrollPosition,
+ animated: true
+ });
+ }
}, 500); // Delay to ensure the season has loaded
}
};
@@ -150,10 +134,12 @@ export const SeriesContent: React.FC = ({
if (selectedIndex !== -1) {
// Wait a small amount of time for layout to be ready
setTimeout(() => {
- seasonScrollViewRef.current?.scrollTo({
- x: selectedIndex * 116, // 100px width + 16px margin
- animated: true
- });
+ if (seasonScrollViewRef.current && typeof (seasonScrollViewRef.current as any).scrollToOffset === 'function') {
+ (seasonScrollViewRef.current as any).scrollToOffset({
+ offset: selectedIndex * 116, // 100px width + 16px margin
+ animated: true
+ });
+ }
}, 300);
}
}
@@ -161,10 +147,12 @@ export const SeriesContent: React.FC = ({
// Add effect to scroll to most recently watched episode when season changes or progress loads
useEffect(() => {
- if (Object.keys(episodeProgress).length > 0 && selectedSeason) {
+ if (Object.keys(episodeProgress).length > 0 && selectedSeason && settings?.episodeLayoutStyle) {
scrollToMostRecentEpisode();
}
- }, [selectedSeason, episodeProgress, settings.episodeLayoutStyle, groupedEpisodes]);
+ }, [selectedSeason, episodeProgress, settings?.episodeLayoutStyle, groupedEpisodes]);
+
+
if (loadingSeasons) {
return (
@@ -185,23 +173,27 @@ export const SeriesContent: React.FC = ({
}
const renderSeasonSelector = () => {
+ // Show selector if we have grouped episodes data or can derive from episodes
if (!groupedEpisodes || Object.keys(groupedEpisodes).length <= 1) {
return null;
}
-
+
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
return (
Seasons
- >}
+ data={seasons}
+ horizontal
showsHorizontalScrollIndicator={false}
style={styles.seasonSelectorContainer}
contentContainerStyle={styles.seasonSelectorContent}
- >
- {seasons.map(season => {
+ initialNumToRender={5}
+ maxToRenderPerBatch={5}
+ windowSize={3}
+ renderItem={({ item: season }) => {
const seasonEpisodes = groupedEpisodes[season] || [];
let seasonPoster = DEFAULT_PLACEHOLDER;
if (seasonEpisodes[0]?.season_poster_path) {
@@ -229,6 +221,12 @@ export const SeriesContent: React.FC = ({
{selectedSeason === season && (
)}
+ {/* Show episode count badge, including when there are no episodes */}
+
+
+ {seasonEpisodes.length} ep{seasonEpisodes.length !== 1 ? 's' : ''}
+
+
= ({
);
- })}
-
+ }}
+ keyExtractor={season => season.toString()}
+ />
);
};
@@ -535,74 +534,92 @@ export const SeriesContent: React.FC = ({
return (
{renderSeasonSelector()}
- {episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
+ {currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
- {settings.episodeLayoutStyle === 'horizontal' ? (
- // Horizontal Layout (Netflix-style)
-
- {currentSeasonEpisodes.map((episode, index) => (
-
- {renderHorizontalEpisodeCard(episode)}
-
- ))}
-
- ) : (
- // Vertical Layout (Traditional)
-
- {isTablet ? (
-
- {currentSeasonEpisodes.map((episode, index) => (
+ {/* Show message when no episodes are available for selected season */}
+ {currentSeasonEpisodes.length === 0 && (
+
+
+
+ No episodes available for Season {selectedSeason}
+
+
+ Episodes may not be released yet
+
+
+ )}
+
+ {/* Only render episode list if there are episodes */}
+ {currentSeasonEpisodes.length > 0 && (
+ (settings?.episodeLayoutStyle === 'horizontal') ? (
+ // Horizontal Layout (Netflix-style)
+ >}
+ data={currentSeasonEpisodes}
+ renderItem={({ item: episode, index }) => (
+
+ {renderHorizontalEpisodeCard(episode)}
+
+ )}
+ keyExtractor={episode => episode.id.toString()}
+ horizontal
+ showsHorizontalScrollIndicator={false}
+ contentContainerStyle={styles.episodeListContentHorizontal}
+ decelerationRate="fast"
+ snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
+ snapToAlignment="start"
+ initialNumToRender={3}
+ maxToRenderPerBatch={3}
+ windowSize={5}
+ />
+ ) : (
+ // Vertical Layout (Traditional)
+
+ {isTablet ? (
+
+ {currentSeasonEpisodes.map((episode, index) => (
+
+ {renderVerticalEpisodeCard(episode)}
+
+ ))}
+
+ ) : (
+ currentSeasonEpisodes.map((episode, index) => (
{renderVerticalEpisodeCard(episode)}
- ))}
-
- ) : (
- currentSeasonEpisodes.map((episode, index) => (
-
- {renderVerticalEpisodeCard(episode)}
-
- ))
- )}
-
+ ))
+ )}
+
+ )
)}
@@ -625,6 +642,12 @@ const styles = StyleSheet.create({
fontSize: 16,
textAlign: 'center',
},
+ centeredSubText: {
+ marginTop: 8,
+ fontSize: 14,
+ textAlign: 'center',
+ opacity: 0.8,
+ },
sectionTitle: {
fontSize: 20,
fontWeight: '700',
@@ -963,4 +986,18 @@ const styles = StyleSheet.create({
selectedSeasonButtonText: {
fontWeight: '700',
},
+ episodeCountBadge: {
+ position: 'absolute',
+ top: 8,
+ right: 8,
+ backgroundColor: 'rgba(0,0,0,0.8)',
+ paddingHorizontal: 4,
+ paddingVertical: 2,
+ borderRadius: 4,
+ },
+ episodeCountText: {
+ color: '#fff',
+ fontSize: 12,
+ fontWeight: '600',
+ },
});
\ No newline at end of file
diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx
index 13d5c96..a7a4d28 100644
--- a/src/components/player/AndroidVideoPlayer.tsx
+++ b/src/components/player/AndroidVideoPlayer.tsx
@@ -95,7 +95,7 @@ const AndroidVideoPlayer: React.FC = () => {
const [selectedAudioTrack, setSelectedAudioTrack] = useState(null);
const [textTracks, setTextTracks] = useState([]);
const [selectedTextTrack, setSelectedTextTrack] = useState(-1);
- const [resizeMode, setResizeMode] = useState('stretch');
+ const [resizeMode, setResizeMode] = useState('contain');
const [buffered, setBuffered] = useState(0);
const [seekTime, setSeekTime] = useState(null);
const videoRef = useRef(null);
@@ -106,8 +106,7 @@ const AndroidVideoPlayer: React.FC = () => {
const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const [resumePosition, setResumePosition] = useState(null);
- const [rememberChoice, setRememberChoice] = useState(false);
- const [resumePreference, setResumePreference] = useState(null);
+ const [savedDuration, setSavedDuration] = useState(null);
const fadeAnim = useRef(new Animated.Value(1)).current;
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
const openingFadeAnim = useRef(new Animated.Value(0)).current;
@@ -152,6 +151,15 @@ const AndroidVideoPlayer: React.FC = () => {
const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState(streamName);
const isMounted = useRef(true);
+ const controlsTimeout = useRef(null);
+
+ const hideControls = () => {
+ Animated.timing(fadeAnim, {
+ toValue: 0,
+ duration: 300,
+ useNativeDriver: true,
+ }).start(() => setShowControls(false));
+ };
const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => {
return {
@@ -270,21 +278,10 @@ const AndroidVideoPlayer: React.FC = () => {
if (progressPercent < 85) {
setResumePosition(savedProgress.currentTime);
- logger.log(`[AndroidVideoPlayer] Set resume position to: ${savedProgress.currentTime}`);
-
- const pref = await AsyncStorage.getItem(RESUME_PREF_KEY);
- logger.log(`[AndroidVideoPlayer] Resume preference: ${pref}`);
-
- if (pref === RESUME_PREF.ALWAYS_RESUME) {
- setInitialPosition(savedProgress.currentTime);
- logger.log(`[AndroidVideoPlayer] Auto-resuming due to preference`);
- } else if (pref === RESUME_PREF.ALWAYS_START_OVER) {
- setInitialPosition(0);
- logger.log(`[AndroidVideoPlayer] Auto-starting over due to preference`);
- } else {
- setShowResumeOverlay(true);
- logger.log(`[AndroidVideoPlayer] Showing resume overlay`);
- }
+ setSavedDuration(savedProgress.duration);
+ logger.log(`[AndroidVideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`);
+ setShowResumeOverlay(true);
+ logger.log(`[AndroidVideoPlayer] Showing resume overlay`);
} else {
logger.log(`[AndroidVideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`);
}
@@ -348,22 +345,25 @@ const AndroidVideoPlayer: React.FC = () => {
const seekToTime = (timeInSeconds: number) => {
if (videoRef.current && duration > 0 && !isSeeking.current) {
if (DEBUG_MODE) {
- logger.log(`[AndroidVideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s`);
+ logger.log(`[AndroidVideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`);
}
isSeeking.current = true;
setSeekTime(timeInSeconds);
- // Clear seek state after seek
+ // Clear seek state after seek with longer timeout
setTimeout(() => {
if (isMounted.current) {
setSeekTime(null);
isSeeking.current = false;
+ if (DEBUG_MODE) {
+ logger.log(`[AndroidVideoPlayer] Seek completed to ${timeInSeconds.toFixed(2)}s`);
}
- }, 100);
+ }
+ }, 500);
} else {
if (DEBUG_MODE) {
- logger.error('[AndroidVideoPlayer] Seek failed: Player not ready, duration is zero, or already seeking.');
+ logger.error(`[AndroidVideoPlayer] Seek failed: videoRef=${!!videoRef.current}, duration=${duration}, seeking=${isSeeking.current}`);
}
}
};
@@ -441,6 +441,17 @@ const AndroidVideoPlayer: React.FC = () => {
const videoDuration = data.duration;
if (data.duration > 0) {
setDuration(videoDuration);
+
+ // Store the actual duration for future reference and update existing progress
+ if (id && type) {
+ storageService.setContentDuration(id, type, videoDuration, episodeId);
+ storageService.updateProgressDuration(id, type, videoDuration, episodeId);
+
+ // Update the saved duration for resume overlay if it was using an estimate
+ if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) {
+ setSavedDuration(videoDuration);
+ }
+ }
}
// Set aspect ratio from video dimensions
@@ -489,6 +500,7 @@ const AndroidVideoPlayer: React.FC = () => {
}, 1000);
}
completeOpeningAnimation();
+ controlsTimeout.current = setTimeout(hideControls, 5000);
}
};
@@ -500,13 +512,14 @@ const AndroidVideoPlayer: React.FC = () => {
};
const cycleAspectRatio = () => {
- const newZoom = zoomScale === 1.1 ? 1 : 1.1;
- setZoomScale(newZoom);
- setZoomTranslateX(0);
- setZoomTranslateY(0);
- setLastZoomScale(newZoom);
- setLastTranslateX(0);
- setLastTranslateY(0);
+ // Android: cycle through native resize modes
+ const resizeModes: ResizeModeType[] = ['contain', 'cover', 'stretch', 'none'];
+ const currentIndex = resizeModes.indexOf(resizeMode);
+ const nextIndex = (currentIndex + 1) % resizeModes.length;
+ setResizeMode(resizeModes[nextIndex]);
+ if (DEBUG_MODE) {
+ logger.log(`[AndroidVideoPlayer] Resize mode changed to: ${resizeModes[nextIndex]}`);
+ }
};
const enableImmersiveMode = () => {
@@ -565,94 +578,41 @@ const AndroidVideoPlayer: React.FC = () => {
});
}, 100);
};
-
- useEffect(() => {
- const loadResumePreference = async () => {
- try {
- logger.log(`[AndroidVideoPlayer] Loading resume preference, resumePosition=${resumePosition}`);
- const pref = await AsyncStorage.getItem(RESUME_PREF_KEY);
- logger.log(`[AndroidVideoPlayer] Resume preference loaded: ${pref}`);
-
- if (pref) {
- setResumePreference(pref);
- if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) {
- logger.log(`[AndroidVideoPlayer] Auto-resuming due to preference`);
- setShowResumeOverlay(false);
- setInitialPosition(resumePosition);
- } else if (pref === RESUME_PREF.ALWAYS_START_OVER) {
- logger.log(`[AndroidVideoPlayer] Auto-starting over due to preference`);
- setShowResumeOverlay(false);
- setInitialPosition(0);
- }
- // Don't override overlay if no specific preference or preference doesn't match
- } else {
- logger.log(`[AndroidVideoPlayer] No resume preference found, keeping overlay state`);
- }
- } catch (error) {
- logger.error('[AndroidVideoPlayer] Error loading resume preference:', error);
- }
- };
- loadResumePreference();
- }, [resumePosition]);
-
- const resetResumePreference = async () => {
- try {
- await AsyncStorage.removeItem(RESUME_PREF_KEY);
- setResumePreference(null);
- } catch (error) {
- logger.error('[AndroidVideoPlayer] Error resetting resume preference:', error);
- }
- };
const handleResume = async () => {
- if (resumePosition !== null && videoRef.current) {
- if (rememberChoice) {
- try {
- await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME);
- } catch (error) {
- logger.error('[AndroidVideoPlayer] Error saving resume preference:', error);
- }
- }
- setInitialPosition(resumePosition);
- setShowResumeOverlay(false);
- setTimeout(() => {
- if (videoRef.current) {
- seekToTime(resumePosition);
- }
- }, 500);
+ if (resumePosition) {
+ seekToTime(resumePosition);
}
+ setShowResumeOverlay(false);
};
const handleStartFromBeginning = async () => {
- if (rememberChoice) {
- try {
- await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER);
- } catch (error) {
- logger.error('[AndroidVideoPlayer] Error saving resume preference:', error);
- }
- }
+ seekToTime(0);
setShowResumeOverlay(false);
- setInitialPosition(0);
- if (videoRef.current) {
- seekToTime(0);
- setCurrentTime(0);
- }
};
const toggleControls = () => {
- setShowControls(previousState => !previousState);
+ if (controlsTimeout.current) {
+ clearTimeout(controlsTimeout.current);
+ controlsTimeout.current = null;
+ }
+
+ setShowControls(prevShowControls => {
+ const newShowControls = !prevShowControls;
+ Animated.timing(fadeAnim, {
+ toValue: newShowControls ? 1 : 0,
+ duration: 300,
+ useNativeDriver: true,
+ }).start();
+ if (newShowControls) {
+ controlsTimeout.current = setTimeout(hideControls, 5000);
+ }
+ return newShowControls;
+ });
};
- useEffect(() => {
- Animated.timing(fadeAnim, {
- toValue: showControls ? 1 : 0,
- duration: 300,
- useNativeDriver: true,
- }).start();
- }, [showControls]);
-
const handleError = (error: any) => {
- logger.error('[AndroidVideoPlayer] Playback Error:', error);
+ logger.error('AndroidVideoPlayer error: ', error);
};
const onBuffer = (data: any) => {
@@ -1046,6 +1006,7 @@ const AndroidVideoPlayer: React.FC = () => {
currentTime={currentTime}
duration={duration}
zoomScale={zoomScale}
+ currentResizeMode={resizeMode}
vlcAudioTracks={rnVideoAudioTracks}
selectedAudioTrack={selectedAudioTrack}
availableStreams={availableStreams}
@@ -1075,14 +1036,10 @@ const AndroidVideoPlayer: React.FC = () => {
diff --git a/src/components/player/VideoPlayer.tsx b/src/components/player/VideoPlayer.tsx
index f5d2857..4bd28e3 100644
--- a/src/components/player/VideoPlayer.tsx
+++ b/src/components/player/VideoPlayer.tsx
@@ -101,8 +101,7 @@ const VideoPlayer: React.FC = () => {
const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const [resumePosition, setResumePosition] = useState(null);
- const [rememberChoice, setRememberChoice] = useState(false);
- const [resumePreference, setResumePreference] = useState(null);
+ const [savedDuration, setSavedDuration] = useState(null);
const fadeAnim = useRef(new Animated.Value(1)).current;
const [isOpeningAnimationComplete, setIsOpeningAnimationComplete] = useState(false);
const openingFadeAnim = useRef(new Animated.Value(0)).current;
@@ -147,6 +146,15 @@ const VideoPlayer: React.FC = () => {
const [currentStreamProvider, setCurrentStreamProvider] = useState(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState(streamName);
const isMounted = useRef(true);
+ const controlsTimeout = useRef(null);
+
+ const hideControls = () => {
+ Animated.timing(fadeAnim, {
+ toValue: 0,
+ duration: 300,
+ useNativeDriver: true,
+ }).start(() => setShowControls(false));
+ };
const calculateVideoStyles = (videoWidth: number, videoHeight: number, screenWidth: number, screenHeight: number) => {
return {
@@ -265,27 +273,10 @@ const VideoPlayer: React.FC = () => {
if (progressPercent < 85) {
setResumePosition(savedProgress.currentTime);
- logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime}`);
-
- const pref = await AsyncStorage.getItem(RESUME_PREF_KEY);
- logger.log(`[VideoPlayer] Resume preference: ${pref}`);
-
- // TEMPORARY: Clear the preference to test overlay
- if (pref) {
- await AsyncStorage.removeItem(RESUME_PREF_KEY);
- logger.log(`[VideoPlayer] CLEARED resume preference for testing`);
- setShowResumeOverlay(true);
- logger.log(`[VideoPlayer] Showing resume overlay after clearing preference`);
- } else if (pref === RESUME_PREF.ALWAYS_RESUME) {
- setInitialPosition(savedProgress.currentTime);
- logger.log(`[VideoPlayer] Auto-resuming due to preference`);
- } else if (pref === RESUME_PREF.ALWAYS_START_OVER) {
- setInitialPosition(0);
- logger.log(`[VideoPlayer] Auto-starting over due to preference`);
- } else {
- setShowResumeOverlay(true);
- logger.log(`[VideoPlayer] Showing resume overlay`);
- }
+ setSavedDuration(savedProgress.duration);
+ logger.log(`[VideoPlayer] Set resume position to: ${savedProgress.currentTime} of ${savedProgress.duration}`);
+ setShowResumeOverlay(true);
+ logger.log(`[VideoPlayer] Showing resume overlay`);
} else {
logger.log(`[VideoPlayer] Progress too high (${progressPercent.toFixed(1)}%), not showing resume overlay`);
}
@@ -366,7 +357,7 @@ const VideoPlayer: React.FC = () => {
const seekToTime = (timeInSeconds: number) => {
if (vlcRef.current && duration > 0 && !isSeeking.current) {
if (DEBUG_MODE) {
- logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s`);
+ logger.log(`[VideoPlayer] Seeking to ${timeInSeconds.toFixed(2)}s out of ${duration.toFixed(2)}s`);
}
isSeeking.current = true;
@@ -381,8 +372,11 @@ const VideoPlayer: React.FC = () => {
setTimeout(() => {
if (isMounted.current) {
isSeeking.current = false;
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] Android seek completed to ${timeInSeconds.toFixed(2)}s`);
}
- }, 300);
+ }
+ }, 500);
} else {
// iOS fallback - use seek prop
const position = timeInSeconds / duration;
@@ -392,12 +386,15 @@ const VideoPlayer: React.FC = () => {
if (isMounted.current) {
setSeekPosition(null);
isSeeking.current = false;
+ if (DEBUG_MODE) {
+ logger.log(`[VideoPlayer] iOS seek completed to ${timeInSeconds.toFixed(2)}s`);
+ }
}
}, 500);
}
} else {
if (DEBUG_MODE) {
- logger.error('[VideoPlayer] Seek failed: Player not ready, duration is zero, or already seeking.');
+ logger.error(`[VideoPlayer] Seek failed: vlcRef=${!!vlcRef.current}, duration=${duration}, seeking=${isSeeking.current}`);
}
}
};
@@ -468,6 +465,17 @@ const VideoPlayer: React.FC = () => {
const videoDuration = data.duration / 1000;
if (data.duration > 0) {
setDuration(videoDuration);
+
+ // Store the actual duration for future reference and update existing progress
+ if (id && type) {
+ storageService.setContentDuration(id, type, videoDuration, episodeId);
+ storageService.updateProgressDuration(id, type, videoDuration, episodeId);
+
+ // Update the saved duration for resume overlay if it was using an estimate
+ if (savedDuration && Math.abs(savedDuration - videoDuration) > 60) {
+ setSavedDuration(videoDuration);
+ }
+ }
}
setVideoAspectRatio(data.videoSize.width / data.videoSize.height);
@@ -499,6 +507,7 @@ const VideoPlayer: React.FC = () => {
}, 1000);
}
completeOpeningAnimation();
+ controlsTimeout.current = setTimeout(hideControls, 5000);
}
};
@@ -583,92 +592,39 @@ const VideoPlayer: React.FC = () => {
});
}, 100);
};
-
- useEffect(() => {
- const loadResumePreference = async () => {
- try {
- logger.log(`[VideoPlayer] Loading resume preference, resumePosition=${resumePosition}`);
- const pref = await AsyncStorage.getItem(RESUME_PREF_KEY);
- logger.log(`[VideoPlayer] Resume preference loaded: ${pref}`);
-
- if (pref) {
- setResumePreference(pref);
- if (pref === RESUME_PREF.ALWAYS_RESUME && resumePosition !== null) {
- logger.log(`[VideoPlayer] Auto-resuming due to preference`);
- setShowResumeOverlay(false);
- setInitialPosition(resumePosition);
- } else if (pref === RESUME_PREF.ALWAYS_START_OVER) {
- logger.log(`[VideoPlayer] Auto-starting over due to preference`);
- setShowResumeOverlay(false);
- setInitialPosition(0);
- }
- // Don't override overlay if no specific preference or preference doesn't match
- } else {
- logger.log(`[VideoPlayer] No resume preference found, keeping overlay state`);
- }
- } catch (error) {
- logger.error('[VideoPlayer] Error loading resume preference:', error);
- }
- };
- loadResumePreference();
- }, [resumePosition]);
-
- const resetResumePreference = async () => {
- try {
- await AsyncStorage.removeItem(RESUME_PREF_KEY);
- setResumePreference(null);
- } catch (error) {
- logger.error('[VideoPlayer] Error resetting resume preference:', error);
- }
- };
const handleResume = async () => {
- if (resumePosition !== null && vlcRef.current) {
- if (rememberChoice) {
- try {
- await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_RESUME);
- } catch (error) {
- logger.error('[VideoPlayer] Error saving resume preference:', error);
- }
- }
- setInitialPosition(resumePosition);
- setShowResumeOverlay(false);
- setTimeout(() => {
- if (vlcRef.current) {
- seekToTime(resumePosition);
- }
- }, 500);
+ if (resumePosition) {
+ seekToTime(resumePosition);
}
+ setShowResumeOverlay(false);
};
const handleStartFromBeginning = async () => {
- if (rememberChoice) {
- try {
- await AsyncStorage.setItem(RESUME_PREF_KEY, RESUME_PREF.ALWAYS_START_OVER);
- } catch (error) {
- logger.error('[VideoPlayer] Error saving resume preference:', error);
- }
- }
+ seekToTime(0);
setShowResumeOverlay(false);
- setInitialPosition(0);
- if (vlcRef.current) {
- seekToTime(0);
- setCurrentTime(0);
- }
};
const toggleControls = () => {
- setShowControls(previousState => !previousState);
+ if (controlsTimeout.current) {
+ clearTimeout(controlsTimeout.current);
+ controlsTimeout.current = null;
+ }
+
+ setShowControls(prevShowControls => {
+ const newShowControls = !prevShowControls;
+ Animated.timing(fadeAnim, {
+ toValue: newShowControls ? 1 : 0,
+ duration: 300,
+ useNativeDriver: true,
+ }).start();
+ if (newShowControls) {
+ controlsTimeout.current = setTimeout(hideControls, 5000);
+ }
+ return newShowControls;
+ });
};
- useEffect(() => {
- Animated.timing(fadeAnim, {
- toValue: showControls ? 1 : 0,
- duration: 300,
- useNativeDriver: true,
- }).start();
- }, [showControls]);
-
const handleError = (error: any) => {
logger.error('[VideoPlayer] Playback Error:', error);
};
@@ -1092,14 +1048,10 @@ const VideoPlayer: React.FC = () => {
diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx
index 3cc6054..12c26d0 100644
--- a/src/components/player/controls/PlayerControls.tsx
+++ b/src/components/player/controls/PlayerControls.tsx
@@ -20,6 +20,7 @@ interface PlayerControlsProps {
currentTime: number;
duration: number;
zoomScale: number;
+ currentResizeMode?: string;
vlcAudioTracks: Array<{id: number, name: string, language?: string}>;
selectedAudioTrack: number | null;
availableStreams?: { [providerId: string]: { streams: any[]; addonName: string } };
@@ -55,6 +56,7 @@ export const PlayerControls: React.FC = ({
currentTime,
duration,
zoomScale,
+ currentResizeMode,
vlcAudioTracks,
selectedAudioTrack,
availableStreams,
@@ -178,7 +180,11 @@ export const PlayerControls: React.FC = ({
- {zoomScale === 1.1 ? 'Fill' : 'Cover'}
+ {currentResizeMode ?
+ (currentResizeMode === 'none' ? 'Original' :
+ currentResizeMode.charAt(0).toUpperCase() + currentResizeMode.slice(1)) :
+ (zoomScale === 1.1 ? 'Fill' : 'Cover')
+ }
diff --git a/src/components/player/modals/AudioTrackModal.tsx b/src/components/player/modals/AudioTrackModal.tsx
index 63805e1..77e3bbb 100644
--- a/src/components/player/modals/AudioTrackModal.tsx
+++ b/src/components/player/modals/AudioTrackModal.tsx
@@ -1,26 +1,13 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, Dimensions } from 'react-native';
-import { Ionicons, MaterialIcons } from '@expo/vector-icons';
+import { MaterialIcons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import Animated, {
FadeIn,
- FadeOut,
- SlideInDown,
- SlideOutDown,
- FadeInDown,
- FadeInUp,
- Layout,
- withSpring,
- withTiming,
+ FadeOut,
useAnimatedStyle,
useSharedValue,
- interpolate,
- Easing,
- withDelay,
- withSequence,
- runOnJS,
- BounceIn,
- ZoomIn
+ withTiming,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { styles } from '../utils/playerStyles';
@@ -36,7 +23,6 @@ interface AudioTrackModalProps {
const { width, height } = Dimensions.get('window');
-// Fixed dimensions for the modal
const MODAL_WIDTH = Math.min(width - 32, 520);
const MODAL_MAX_HEIGHT = height * 0.85;
@@ -44,17 +30,14 @@ const AudioBadge = ({
text,
color,
bgColor,
- icon,
- delay = 0
+ icon
}: {
text: string;
color: string;
bgColor: string;
icon?: string;
- delay?: number;
}) => (
-
{text}
-
+
);
export const AudioTrackModal: React.FC = ({
@@ -92,30 +75,19 @@ export const AudioTrackModal: React.FC = ({
selectedAudioTrack,
selectAudioTrack,
}) => {
- const modalScale = useSharedValue(0.9);
const modalOpacity = useSharedValue(0);
React.useEffect(() => {
if (showAudioModal) {
- modalScale.value = withSpring(1, {
- damping: 20,
- stiffness: 300,
- mass: 0.8,
- });
- modalOpacity.value = withTiming(1, {
- duration: 200,
- easing: Easing.out(Easing.quad),
- });
+ modalOpacity.value = withTiming(1, { duration: 200 });
}
}, [showAudioModal]);
const modalStyle = useAnimatedStyle(() => ({
- transform: [{ scale: modalScale.value }],
opacity: modalOpacity.value,
}));
const handleClose = () => {
- modalScale.value = withTiming(0.9, { duration: 150 });
modalOpacity.value = withTiming(0, { duration: 150 });
setTimeout(() => setShowAudioModal(false), 150);
};
@@ -124,8 +96,8 @@ export const AudioTrackModal: React.FC = ({
return (
= ({
padding: 16,
}}
>
- {/* Backdrop */}
= ({
activeOpacity={1}
/>
- {/* Modal Content */}
= ({
modalStyle,
]}
>
- {/* Glassmorphism Background */}
= ({
height: '100%',
}}
>
- {/* Header */}
= ({
width: '100%',
}}
>
-
+
= ({
}}>
Choose from {vlcAudioTracks.length} available track{vlcAudioTracks.length !== 1 ? 's' : ''}
-
+
-
-
-
-
-
+
+
+
- {/* Content */}
= ({
bounces={false}
>
- {vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => (
- 0 ? vlcAudioTracks.map((track) => (
+ = ({
{selectedAudioTrack === track.id && (
- = ({
}}>
ACTIVE
-
+
)}
@@ -367,7 +326,6 @@ export const AudioTrackModal: React.FC = ({
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
icon="language"
- delay={50}
/>
)}
@@ -387,20 +345,17 @@ export const AudioTrackModal: React.FC = ({
? 'rgba(249, 115, 22, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
}}>
- {selectedAudioTrack === track.id ? (
-
-
-
- ) : (
-
- )}
+
-
+
)) : (
- = ({
}}>
No audio tracks are available for this content.{'\n'}Try a different source or check your connection.
-
+
)}
diff --git a/src/components/player/modals/ResumeOverlay.tsx b/src/components/player/modals/ResumeOverlay.tsx
index 0945165..724a57f 100644
--- a/src/components/player/modals/ResumeOverlay.tsx
+++ b/src/components/player/modals/ResumeOverlay.tsx
@@ -13,10 +13,6 @@ interface ResumeOverlayProps {
title: string;
season?: number;
episode?: number;
- rememberChoice: boolean;
- setRememberChoice: (remember: boolean) => void;
- resumePreference: string | null;
- resetResumePreference: () => void;
handleResume: () => void;
handleStartFromBeginning: () => void;
}
@@ -28,10 +24,6 @@ export const ResumeOverlay: React.FC = ({
title,
season,
episode,
- rememberChoice,
- setRememberChoice,
- resumePreference,
- resetResumePreference,
handleResume,
handleStartFromBeginning,
}) => {
@@ -78,29 +70,6 @@ export const ResumeOverlay: React.FC = ({
- {/* Remember choice checkbox */}
- setRememberChoice(!rememberChoice)}
- activeOpacity={0.7}
- >
-
-
- {rememberChoice && }
-
- Remember my choice
-
-
- {resumePreference && (
-
- Reset
-
- )}
-
-
{
}
return (
- {
}}>
{label}
-
+
);
};
@@ -97,17 +83,14 @@ const StreamMetaBadge = ({
text,
color,
bgColor,
- icon,
- delay = 0
+ icon
}: {
text: string;
color: string;
bgColor: string;
icon?: string;
- delay?: number;
}) => (
-
{text}
-
+
);
const SourcesModal: React.FC = ({
@@ -146,32 +129,33 @@ const SourcesModal: React.FC = ({
onSelectStream,
isChangingSource,
}) => {
- const modalScale = useSharedValue(0.9);
const modalOpacity = useSharedValue(0);
React.useEffect(() => {
if (showSourcesModal) {
- modalScale.value = withSpring(1, {
- damping: 20,
- stiffness: 300,
- mass: 0.8,
- });
- modalOpacity.value = withTiming(1, {
- duration: 200,
- easing: Easing.out(Easing.quad),
- });
+ modalOpacity.value = withTiming(1, { duration: 200 });
+ } else {
+ modalOpacity.value = withTiming(0, { duration: 150 });
}
+
+ return () => {
+ modalOpacity.value = 0;
+ };
}, [showSourcesModal]);
const modalStyle = useAnimatedStyle(() => ({
- transform: [{ scale: modalScale.value }],
opacity: modalOpacity.value,
}));
+ const handleClose = () => {
+ modalOpacity.value = withTiming(0, { duration: 150 }, () => {
+ runOnJS(setShowSourcesModal)(false);
+ });
+ };
+
if (!showSourcesModal) return null;
const sortedProviders = Object.entries(availableStreams).sort(([a], [b]) => {
- // Put HDRezka first
if (a === 'hdrezka') return -1;
if (b === 'hdrezka') return 1;
return 0;
@@ -193,16 +177,10 @@ const SourcesModal: React.FC = ({
return stream.url === currentStreamUrl;
};
- const handleClose = () => {
- modalScale.value = withTiming(0.9, { duration: 150 });
- modalOpacity.value = withTiming(0, { duration: 150 });
- setTimeout(() => setShowSourcesModal(false), 150);
- };
-
return (
= ({
padding: 16,
}}
>
- {/* Backdrop */}
= ({
activeOpacity={1}
/>
- {/* Modal Content */}
= ({
modalStyle,
]}
>
- {/* Glassmorphism Background */}
= ({
height: '100%',
}}
>
- {/* Header */}
= ({
width: '100%',
}}
>
-
+
= ({
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
}}>
- Switch Source
+ Video Sources
= ({
fontWeight: '500',
letterSpacing: 0.2,
}}>
- Choose from {Object.values(availableStreams).reduce((acc, curr) => acc + curr.streams.length, 0)} available streams
+ Choose from {Object.values(availableStreams).reduce((acc, curr) => acc + curr.streams.length, 0)} available sources
-
+
-
-
-
-
-
+
+
+
- {/* Content */}
= ({
}}
bounces={false}
>
- {sortedProviders.map(([providerId, { streams, addonName }], providerIndex) => (
- 0 ? 32 : 0,
- width: '100%',
- }}
- >
- {/* Provider Header */}
+ {sortedProviders.map(([providerId, { streams, addonName }]) => (
+
-
-
-
- {addonName}
-
-
- Provider • {streams.length} stream{streams.length !== 1 ? 's' : ''}
-
-
-
+
+ {addonName}
+
{streams.length}
-
- {/* Streams Grid */}
-
- {streams.map((stream, index) => {
- const quality = getQualityFromTitle(stream.title);
- const isSelected = isStreamSelected(stream);
- const isHDR = stream.title?.toLowerCase().includes('hdr');
- const isDolby = stream.title?.toLowerCase().includes('dolby') || stream.title?.includes('DV');
- const size = stream.title?.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
- const isDebrid = stream.behaviorHints?.cached;
- const isHDRezka = providerId === 'hdrezka';
- return (
- {
+ const isSelected = isStreamSelected(stream);
+ const quality = getQualityFromTitle(stream.title);
+
+ return (
+
+ handleStreamSelect(stream)}
+ activeOpacity={0.85}
+ disabled={isChangingSource}
>
- handleStreamSelect(stream)}
- disabled={isChangingSource || isSelected}
- activeOpacity={0.85}
- >
-
- {/* Stream Info */}
-
- {/* Title Row */}
-
+
+
+
-
- {isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')}
-
-
- {isSelected && (
-
-
-
- PLAYING
-
-
- )}
-
- {isChangingSource && isSelected && (
-
-
-
- Switching...
-
-
- )}
-
+ {stream.title || 'Untitled Stream'}
+
- {/* Subtitle */}
- {!isHDRezka && stream.title && stream.title !== stream.name && (
-
- {stream.title}
-
+ {isSelected && (
+
+
+
+ PLAYING
+
+
)}
-
- {/* Enhanced Meta Info */}
-
-
-
- {isDolby && (
-
- )}
-
- {isHDR && (
-
- )}
-
- {size && (
-
- )}
-
- {isDebrid && (
-
- )}
-
- {isHDRezka && (
-
- )}
-
- {/* Enhanced Action Icon */}
- {isSelected ? (
-
-
-
- ) : (
-
- )}
+ {quality && }
+
-
-
- );
- })}
-
-
+
+
+ {isChangingSource ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ );
+ })}
+
))}
diff --git a/src/components/player/modals/SubtitleModals.tsx b/src/components/player/modals/SubtitleModals.tsx
index cf5e247..455905a 100644
--- a/src/components/player/modals/SubtitleModals.tsx
+++ b/src/components/player/modals/SubtitleModals.tsx
@@ -1,26 +1,14 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, Image, Dimensions } from 'react-native';
-import { Ionicons, MaterialIcons } from '@expo/vector-icons';
+import { MaterialIcons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import Animated, {
FadeIn,
- FadeOut,
- SlideInDown,
- SlideOutDown,
- FadeInDown,
- FadeInUp,
- Layout,
- withSpring,
- withTiming,
+ FadeOut,
useAnimatedStyle,
useSharedValue,
- interpolate,
- Easing,
- withDelay,
- withSequence,
+ withTiming,
runOnJS,
- BounceIn,
- ZoomIn
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { styles } from '../utils/playerStyles';
@@ -49,7 +37,6 @@ interface SubtitleModalsProps {
const { width, height } = Dimensions.get('window');
-// Fixed dimensions for the modals
const MODAL_WIDTH = Math.min(width - 32, 520);
const MODAL_MAX_HEIGHT = height * 0.85;
@@ -57,17 +44,14 @@ const SubtitleBadge = ({
text,
color,
bgColor,
- icon,
- delay = 0
+ icon
}: {
text: string;
color: string;
bgColor: string;
icon?: string;
- delay?: number;
}) => (
-
{text}
-
+
);
export const SubtitleModals: React.FC = ({
@@ -117,59 +101,51 @@ export const SubtitleModals: React.FC = ({
increaseSubtitleSize,
decreaseSubtitleSize,
}) => {
- const modalScale = useSharedValue(0.9);
const modalOpacity = useSharedValue(0);
- const languageModalScale = useSharedValue(0.9);
const languageModalOpacity = useSharedValue(0);
React.useEffect(() => {
if (showSubtitleModal) {
- modalScale.value = withSpring(1, {
- damping: 20,
- stiffness: 300,
- mass: 0.8,
- });
- modalOpacity.value = withTiming(1, {
- duration: 200,
- easing: Easing.out(Easing.quad),
- });
+ modalOpacity.value = withTiming(1, { duration: 200 });
+ } else {
+ modalOpacity.value = withTiming(0, { duration: 150 });
}
+
+ return () => {
+ modalOpacity.value = 0;
+ };
}, [showSubtitleModal]);
React.useEffect(() => {
if (showSubtitleLanguageModal) {
- languageModalScale.value = withSpring(1, {
- damping: 20,
- stiffness: 300,
- mass: 0.8,
- });
- languageModalOpacity.value = withTiming(1, {
- duration: 200,
- easing: Easing.out(Easing.quad),
- });
+ languageModalOpacity.value = withTiming(1, { duration: 200 });
+ } else {
+ languageModalOpacity.value = withTiming(0, { duration: 150 });
}
+
+ return () => {
+ languageModalOpacity.value = 0;
+ };
}, [showSubtitleLanguageModal]);
const modalStyle = useAnimatedStyle(() => ({
- transform: [{ scale: modalScale.value }],
opacity: modalOpacity.value,
}));
const languageModalStyle = useAnimatedStyle(() => ({
- transform: [{ scale: languageModalScale.value }],
opacity: languageModalOpacity.value,
}));
const handleClose = () => {
- modalScale.value = withTiming(0.9, { duration: 150 });
- modalOpacity.value = withTiming(0, { duration: 150 });
- setTimeout(() => setShowSubtitleModal(false), 150);
+ modalOpacity.value = withTiming(0, { duration: 150 }, () => {
+ runOnJS(setShowSubtitleModal)(false);
+ });
};
const handleLanguageClose = () => {
- languageModalScale.value = withTiming(0.9, { duration: 150 });
- languageModalOpacity.value = withTiming(0, { duration: 150 });
- setTimeout(() => setShowSubtitleLanguageModal(false), 150);
+ languageModalOpacity.value = withTiming(0, { duration: 150 }, () => {
+ runOnJS(setShowSubtitleLanguageModal)(false);
+ });
};
// Render subtitle settings modal
@@ -178,8 +154,8 @@ export const SubtitleModals: React.FC = ({
return (
= ({
padding: 16,
}}
>
- {/* Backdrop */}
= ({
activeOpacity={1}
/>
- {/* Modal Content */}
= ({
modalStyle,
]}
>
- {/* Glassmorphism Background */}
= ({
height: '100%',
}}
>
- {/* Header */}
= ({
width: '100%',
}}
>
-
+
= ({
fontWeight: '500',
letterSpacing: 0.2,
}}>
- Configure subtitles and language options
+ Customize your subtitle experience
-
+
-
-
-
-
-
+
+
+
- {/* Content */}
= ({
bounces={false}
>
-
- {/* External Subtitles Section */}
-
+
+
+ Size Adjustment
+
+
-
-
+ onPress={decreaseSubtitleSize}
+ >
+
+
+
+
- External Subtitles
+ {subtitleSize}
- High quality with size control
+ Font Size
-
-
- {/* Custom subtitles option */}
- {customSubtitles.length > 0 && (
-
- {
- selectTextTrack(-999);
- setShowSubtitleModal(false);
- }}
- activeOpacity={0.85}
- >
-
-
-
-
- Custom Subtitles
-
-
- {useCustomSubtitles && (
-
-
-
- ACTIVE
-
-
- )}
-
-
-
-
-
-
-
-
-
- {useCustomSubtitles ? (
-
-
-
- ) : (
-
- )}
-
-
-
-
- )}
-
- {/* Search for external subtitles */}
-
+
{
- handleClose();
- fetchAvailableSubtitles();
- }}
- disabled={isLoadingSubtitleList}
- activeOpacity={0.85}
- >
-
- {isLoadingSubtitleList ? (
-
- ) : (
-
- )}
-
- {isLoadingSubtitleList ? 'Searching...' : 'Search Online Subtitles'}
-
-
+ alignItems: 'center',
+ borderWidth: 2,
+ borderColor: 'rgba(255, 255, 255, 0.1)',
+ }}
+ onPress={increaseSubtitleSize}
+ >
+
-
-
+
+
- {/* Subtitle Size Controls */}
- {useCustomSubtitles && (
-
+
+ Subtitle Source
+
+
+ setShowSubtitleLanguageModal(true)}
>
-
- Size Control
+ Change Language
-
- Adjust font size for better readability
-
-
-
-
-
-
-
-
-
-
-
- {subtitleSize}px
-
-
- Font Size
-
-
-
-
-
-
-
-
-
- )}
-
- {/* Available built-in subtitle tracks */}
- {vlcTextTracks.length > 0 ? vlcTextTracks.map((track, index) => (
-
- {
- selectTextTrack(track.id);
- handleClose();
- }}
- activeOpacity={0.85}
- >
-
-
-
-
- {getTrackDisplayName(track)}
-
-
- {(selectedTextTrack === track.id && !useCustomSubtitles) && (
-
-
-
- ACTIVE
-
-
- )}
-
-
-
+
+ {selectedTextTrack !== -1 && (
- t.id === selectedTextTrack)?.language?.toUpperCase() || 'UNKNOWN'}
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
- icon="format-size"
- delay={50}
/>
-
-
-
-
- {(selectedTextTrack === track.id && !useCustomSubtitles) ? (
-
-
-
- ) : (
-
)}
-
-
- )) : (
-
-
-
- No built-in subtitles available
-
-
- Try searching for external subtitles
-
-
- )}
+
+
+
+
@@ -866,11 +432,11 @@ export const SubtitleModals: React.FC = ({
// Render subtitle language selection modal
const renderSubtitleLanguageModal = () => {
if (!showSubtitleLanguageModal) return null;
-
+
return (
= ({
padding: 16,
}}
>
- {/* Backdrop */}
= ({
activeOpacity={1}
/>
- {/* Modal Content */}
= ({
languageModalStyle,
]}
>
- {/* Glassmorphism Background */}
= ({
height: '100%',
}}
>
- {/* Header */}
= ({
width: '100%',
}}
>
-
+
= ({
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
}}>
- Select Language
+ Subtitle Language
= ({
fontWeight: '500',
letterSpacing: 0.2,
}}>
- Choose from {availableSubtitles.length} available languages
+ Choose from {vlcTextTracks.length} available tracks
-
+
-
-
-
-
-
+
+
+
- {/* Content */}
= ({
}}
bounces={false}
>
- {availableSubtitles.length > 0 ? availableSubtitles.map((subtitle, index) => (
-
-
+ {vlcTextTracks.map((track) => (
+ loadWyzieSubtitle(subtitle)}
- disabled={isLoadingSubtitles}
- activeOpacity={0.85}
>
-
+ {
+ selectTextTrack(track.id);
+ handleLanguageClose();
+ }}
+ activeOpacity={0.85}
+ >
-
-
-
+
- {formatLanguage(subtitle.language)}
-
-
+ {getTrackDisplayName(track)}
+
+
+ {selectedTextTrack === track.id && (
+
+
+
+ ACTIVE
+
+
+ )}
+
+
+
- {subtitle.display}
-
+
+ {track.language && (
+
+ )}
+
+
+
+
+
-
-
- {isLoadingSubtitles ? (
-
- ) : (
-
- )}
-
-
-
-
- )) : (
-
-
-
- No subtitles found
-
-
- No subtitles are available for this content.{'\n'}Try searching again or check back later.
-
-
- )}
+
+
+ ))}
+
diff --git a/src/hooks/useLibrary.ts b/src/hooks/useLibrary.ts
index 0f8e656..5164359 100644
--- a/src/hooks/useLibrary.ts
+++ b/src/hooks/useLibrary.ts
@@ -1,6 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { StreamingContent } from '../types/metadata';
+import { catalogService } from '../services/catalogService';
const LIBRARY_STORAGE_KEY = 'stremio-library';
@@ -83,6 +84,17 @@ export const useLibrary = () => {
loadLibraryItems();
}, [loadLibraryItems]);
+ // Subscribe to catalogService library updates
+ useEffect(() => {
+ const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
+ console.log('[useLibrary] Received library update from catalogService:', items.length, 'items');
+ setLibraryItems(items);
+ setLoading(false);
+ });
+
+ return () => unsubscribe();
+ }, []);
+
return {
libraryItems,
loading,
diff --git a/src/hooks/useMetadataAnimations.ts b/src/hooks/useMetadataAnimations.ts
index 89f0d6d..671e400 100644
--- a/src/hooks/useMetadataAnimations.ts
+++ b/src/hooks/useMetadataAnimations.ts
@@ -7,6 +7,7 @@ import {
Easing,
useAnimatedScrollHandler,
runOnUI,
+ cancelAnimation,
} from 'react-native-reanimated';
const { width, height } = Dimensions.get('window');
@@ -57,10 +58,11 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) =
// Ultra-fast entrance sequence - batch animations for better performance
useEffect(() => {
- // Batch all entrance animations to run simultaneously
+ // Batch all entrance animations to run simultaneously with safety
const enterAnimations = () => {
'worklet';
+ try {
// Start with slightly reduced values and animate to full visibility
screenOpacity.value = withTiming(1, {
duration: 250,
@@ -85,32 +87,70 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) =
duration: 350,
easing: easings.fast
});
+ } catch (error) {
+ // Silently handle any animation errors
+ console.warn('Animation error in enterAnimations:', error);
+ }
};
- // Use runOnUI for better performance
+ // Use runOnUI for better performance with error handling
+ try {
runOnUI(enterAnimations)();
+ } catch (error) {
+ console.warn('Failed to run enter animations:', error);
+ }
}, []);
- // Optimized watch progress animation
+ // Optimized watch progress animation with safety
useEffect(() => {
const hasProgress = watchProgress && watchProgress.duration > 0;
const updateProgress = () => {
'worklet';
+
+ try {
progressOpacity.value = withTiming(hasProgress ? 1 : 0, {
duration: hasProgress ? 200 : 150,
easing: easings.fast
});
+ } catch (error) {
+ console.warn('Animation error in updateProgress:', error);
+ }
};
+ try {
runOnUI(updateProgress)();
+ } catch (error) {
+ console.warn('Failed to run progress animation:', error);
+ }
}, [watchProgress]);
- // Ultra-optimized scroll handler with minimal calculations
+ // Cleanup function to cancel animations
+ useEffect(() => {
+ return () => {
+ try {
+ cancelAnimation(screenOpacity);
+ cancelAnimation(contentOpacity);
+ cancelAnimation(heroOpacity);
+ cancelAnimation(heroScale);
+ cancelAnimation(uiElementsOpacity);
+ cancelAnimation(uiElementsTranslateY);
+ cancelAnimation(progressOpacity);
+ cancelAnimation(scrollY);
+ cancelAnimation(headerProgress);
+ cancelAnimation(staticHeaderElementsY);
+ } catch (error) {
+ console.warn('Error canceling animations:', error);
+ }
+ };
+ }, []);
+
+ // Ultra-optimized scroll handler with minimal calculations and safety
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
'worklet';
+ try {
const rawScrollY = event.contentOffset.y;
scrollY.value = rawScrollY;
@@ -124,6 +164,9 @@ export const useMetadataAnimations = (safeAreaTop: number, watchProgress: any) =
duration: progress ? 200 : 150,
easing: easings.ultraFast
});
+ }
+ } catch (error) {
+ console.warn('Animation error in scroll handler:', error);
}
},
});
diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts
index ec3b664..5f47328 100644
--- a/src/hooks/useSettings.ts
+++ b/src/hooks/useSettings.ts
@@ -78,10 +78,14 @@ export const useSettings = () => {
try {
const storedSettings = await AsyncStorage.getItem(SETTINGS_STORAGE_KEY);
if (storedSettings) {
- setSettings(JSON.parse(storedSettings));
+ const parsedSettings = JSON.parse(storedSettings);
+ // Merge with defaults to ensure all properties exist
+ setSettings({ ...DEFAULT_SETTINGS, ...parsedSettings });
}
} catch (error) {
console.error('Failed to load settings:', error);
+ // Fallback to default settings on error
+ setSettings(DEFAULT_SETTINGS);
}
};
diff --git a/src/hooks/useTraktAutosync.ts b/src/hooks/useTraktAutosync.ts
index 299efd4..bdbdafc 100644
--- a/src/hooks/useTraktAutosync.ts
+++ b/src/hooks/useTraktAutosync.ts
@@ -186,7 +186,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
options.type,
true,
progressPercent,
- options.episodeId
+ options.episodeId,
+ currentTime
);
logger.log(`[TraktAutosync] Synced progress ${progressPercent.toFixed(1)}%: ${contentData.title}`);
@@ -215,6 +216,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
// ENHANCED DEDUPLICATION: Check if we've already stopped this session
// However, allow updates if the new progress is significantly higher (>5% improvement)
+ let isSignificantUpdate = false;
if (hasStopped.current) {
const currentProgressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
const progressImprovement = currentProgressPercent - lastSyncProgress.current;
@@ -223,6 +225,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
logger.log(`[TraktAutosync] Session already stopped, but progress improved significantly by ${progressImprovement.toFixed(1)}% (${lastSyncProgress.current.toFixed(1)}% → ${currentProgressPercent.toFixed(1)}%), allowing update`);
// Reset stopped flag to allow this significant update
hasStopped.current = false;
+ isSignificantUpdate = true;
} else {
logger.log(`[TraktAutosync] Already stopped this session, skipping duplicate call (reason: ${reason})`);
return;
@@ -230,7 +233,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
}
// ENHANCED DEDUPLICATION: Prevent rapid successive calls (within 5 seconds)
- if (now - lastStopCall.current < 5000) {
+ // Bypass for significant updates
+ if (!isSignificantUpdate && now - lastStopCall.current < 5000) {
logger.log(`[TraktAutosync] Ignoring rapid successive stop call within 5 seconds (reason: ${reason})`);
return;
}
@@ -315,7 +319,8 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
options.type,
true,
progressPercent,
- options.episodeId
+ options.episodeId,
+ currentTime
);
// Mark session as complete if high progress (scrobbled)
diff --git a/src/hooks/useTraktIntegration.ts b/src/hooks/useTraktIntegration.ts
index 3b61d44..1e818a0 100644
--- a/src/hooks/useTraktIntegration.ts
+++ b/src/hooks/useTraktIntegration.ts
@@ -324,22 +324,21 @@ export function useTraktIntegration() {
// Fetch and merge Trakt progress with local progress
const fetchAndMergeTraktProgress = useCallback(async (): Promise => {
- logger.log(`[useTraktIntegration] fetchAndMergeTraktProgress called - isAuthenticated: ${isAuthenticated}`);
-
if (!isAuthenticated) {
- logger.log('[useTraktIntegration] Not authenticated, skipping Trakt progress fetch');
return false;
}
try {
// Fetch both playback progress and recently watched movies
- logger.log('[useTraktIntegration] Fetching Trakt playback progress and watched movies...');
const [traktProgress, watchedMovies] = await Promise.all([
getTraktPlaybackProgress(),
traktService.getWatchedMovies()
]);
- logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} Trakt progress items, ${watchedMovies.length} watched movies`);
+ logger.log(`[useTraktIntegration] Retrieved ${traktProgress.length} progress items, ${watchedMovies.length} watched movies`);
+
+ // Batch process all updates to reduce storage notifications
+ const updatePromises: Promise[] = [];
// Process playback progress (in-progress items)
for (const item of traktProgress) {
@@ -351,27 +350,35 @@ export function useTraktIntegration() {
if (item.type === 'movie' && item.movie) {
id = item.movie.ids.imdb;
type = 'movie';
- logger.log(`[useTraktIntegration] Processing Trakt movie progress: ${item.movie.title} (${id}) - ${item.progress}%`);
} else if (item.type === 'episode' && item.show && item.episode) {
id = item.show.ids.imdb;
type = 'series';
episodeId = `${id}:${item.episode.season}:${item.episode.number}`;
- logger.log(`[useTraktIntegration] Processing Trakt episode progress: ${item.show.title} S${item.episode.season}E${item.episode.number} (${id}) - ${item.progress}%`);
} else {
- logger.warn(`[useTraktIntegration] Skipping invalid Trakt progress item:`, item);
continue;
}
- logger.log(`[useTraktIntegration] Merging progress for ${type} ${id}: ${item.progress}% from ${item.paused_at}`);
- await storageService.mergeWithTraktProgress(
+ // Try to calculate exact time if we have stored duration
+ const exactTime = await (async () => {
+ const storedDuration = await storageService.getContentDuration(id, type, episodeId);
+ if (storedDuration && storedDuration > 0) {
+ return (item.progress / 100) * storedDuration;
+ }
+ return undefined;
+ })();
+
+ updatePromises.push(
+ storageService.mergeWithTraktProgress(
id,
type,
item.progress,
item.paused_at,
- episodeId
+ episodeId,
+ exactTime
+ )
);
} catch (error) {
- logger.error('[useTraktIntegration] Error merging individual Trakt progress:', error);
+ logger.error('[useTraktIntegration] Error preparing Trakt progress update:', error);
}
}
@@ -381,21 +388,25 @@ export function useTraktIntegration() {
if (movie.movie?.ids?.imdb) {
const id = movie.movie.ids.imdb;
const watchedAt = movie.last_watched_at;
- logger.log(`[useTraktIntegration] Processing watched movie: ${movie.movie.title} (${id}) - 100% watched on ${watchedAt}`);
- await storageService.mergeWithTraktProgress(
+ updatePromises.push(
+ storageService.mergeWithTraktProgress(
id,
'movie',
100, // 100% progress for watched items
watchedAt
+ )
);
}
} catch (error) {
- logger.error('[useTraktIntegration] Error merging watched movie:', error);
+ logger.error('[useTraktIntegration] Error preparing watched movie update:', error);
}
}
- logger.log(`[useTraktIntegration] Successfully merged ${traktProgress.length} progress items + ${watchedMovies.length} watched movies`);
+ // Execute all updates in parallel
+ await Promise.all(updatePromises);
+
+ logger.log(`[useTraktIntegration] Successfully merged ${updatePromises.length} items from Trakt`);
return true;
} catch (error) {
logger.error('[useTraktIntegration] Error fetching and merging Trakt progress:', error);
@@ -419,17 +430,10 @@ export function useTraktIntegration() {
useEffect(() => {
if (isAuthenticated) {
// Fetch Trakt progress and merge with local
- logger.log('[useTraktIntegration] User authenticated, fetching Trakt progress to replace local data');
fetchAndMergeTraktProgress().then((success) => {
if (success) {
- logger.log('[useTraktIntegration] Trakt progress merged successfully - local data replaced with Trakt data');
- } else {
- logger.warn('[useTraktIntegration] Failed to merge Trakt progress');
+ logger.log('[useTraktIntegration] Trakt progress merged successfully');
}
- // Small delay to ensure storage subscribers are notified
- setTimeout(() => {
- logger.log('[useTraktIntegration] Trakt progress merge completed, UI should refresh');
- }, 100);
});
}
}, [isAuthenticated, fetchAndMergeTraktProgress]);
@@ -440,12 +444,7 @@ export function useTraktIntegration() {
const handleAppStateChange = (nextAppState: AppStateStatus) => {
if (nextAppState === 'active') {
- logger.log('[useTraktIntegration] App became active, syncing Trakt data');
- fetchAndMergeTraktProgress().then((success) => {
- if (success) {
- logger.log('[useTraktIntegration] App focus sync completed successfully');
- }
- }).catch(error => {
+ fetchAndMergeTraktProgress().catch(error => {
logger.error('[useTraktIntegration] App focus sync failed:', error);
});
}
@@ -461,12 +460,7 @@ export function useTraktIntegration() {
// Trigger sync when auth status is manually refreshed (for login scenarios)
useEffect(() => {
if (isAuthenticated) {
- logger.log('[useTraktIntegration] Auth status refresh detected, triggering Trakt progress merge');
- fetchAndMergeTraktProgress().then((success) => {
- if (success) {
- logger.log('[useTraktIntegration] Trakt progress merged after manual auth refresh');
- }
- });
+ fetchAndMergeTraktProgress();
}
}, [lastAuthCheck, isAuthenticated, fetchAndMergeTraktProgress]);
diff --git a/src/hooks/useWatchProgress.ts b/src/hooks/useWatchProgress.ts
index a4ec9d5..63427bc 100644
--- a/src/hooks/useWatchProgress.ts
+++ b/src/hooks/useWatchProgress.ts
@@ -170,7 +170,6 @@ export const useWatchProgress = (
// Subscribe to storage changes for real-time updates
useEffect(() => {
const unsubscribe = storageService.subscribeToWatchProgressUpdates(() => {
- logger.log('[useWatchProgress] Storage updated, reloading progress');
loadWatchProgress();
});
diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx
index dd7b0c2..cf61468 100644
--- a/src/screens/CalendarScreen.tsx
+++ b/src/screens/CalendarScreen.tsx
@@ -10,7 +10,8 @@ import {
SafeAreaView,
StatusBar,
Dimensions,
- SectionList
+ SectionList,
+ Platform
} from 'react-native';
import { useNavigation } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
@@ -28,6 +29,7 @@ import { tmdbService } from '../services/tmdbService';
import { logger } from '../utils/logger';
const { width } = Dimensions.get('window');
+const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface CalendarEpisode {
id: string;
@@ -663,6 +665,8 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
padding: 12,
+ paddingTop: Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT + 12 : 12,
+ borderBottomWidth: 1,
},
backButton: {
padding: 8,
diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx
index e622643..d853ff1 100644
--- a/src/screens/HomeScreen.tsx
+++ b/src/screens/HomeScreen.tsx
@@ -62,6 +62,10 @@ import homeStyles, { sharedStyles } from '../styles/homeStyles';
import { useTheme } from '../contexts/ThemeContext';
import type { Theme } from '../contexts/ThemeContext';
import * as ScreenOrientation from 'expo-screen-orientation';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+
+// Constants
+const CATALOG_SETTINGS_KEY = 'catalog_settings';
// Define interfaces for our data
interface Category {
@@ -69,299 +73,16 @@ interface Category {
name: string;
}
-interface ContentItemProps {
- item: StreamingContent;
- onPress: (id: string, type: string) => void;
-}
-
-interface DropUpMenuProps {
- visible: boolean;
- onClose: () => void;
- item: StreamingContent;
- onOptionSelect: (option: string) => void;
-}
-
interface ContinueWatchingRef {
refresh: () => Promise;
}
-const DropUpMenu = React.memo(({ visible, onClose, item, onOptionSelect }: DropUpMenuProps) => {
- const translateY = useSharedValue(300);
- const opacity = useSharedValue(0);
- const isDarkMode = useColorScheme() === 'dark';
- const { currentTheme } = useTheme();
- const SNAP_THRESHOLD = 100;
-
- useEffect(() => {
- if (visible) {
- opacity.value = withTiming(1, { duration: 200 });
- translateY.value = withTiming(0, { duration: 300 });
- } else {
- opacity.value = withTiming(0, { duration: 200 });
- translateY.value = withTiming(300, { duration: 300 });
- }
-
- // Cleanup animations when component unmounts
- return () => {
- opacity.value = 0;
- translateY.value = 300;
- };
- }, [visible]);
-
- const gesture = useMemo(() => Gesture.Pan()
- .onStart(() => {
- // Store initial position if needed
- })
- .onUpdate((event) => {
- if (event.translationY > 0) { // Only allow dragging downwards
- translateY.value = event.translationY;
- opacity.value = interpolate(
- event.translationY,
- [0, 300],
- [1, 0],
- Extrapolate.CLAMP
- );
- }
- })
- .onEnd((event) => {
- if (event.translationY > SNAP_THRESHOLD || event.velocityY > 500) {
- translateY.value = withTiming(300, { duration: 300 });
- opacity.value = withTiming(0, { duration: 200 });
- runOnJS(onClose)();
- } else {
- translateY.value = withTiming(0, { duration: 300 });
- opacity.value = withTiming(1, { duration: 200 });
- }
- }), [onClose]);
-
- const overlayStyle = useAnimatedStyle(() => ({
- opacity: opacity.value,
- backgroundColor: currentTheme.colors.transparentDark,
- }));
-
- const menuStyle = useAnimatedStyle(() => ({
- transform: [{ translateY: translateY.value }],
- borderTopLeftRadius: 24,
- borderTopRightRadius: 24,
- backgroundColor: isDarkMode ? currentTheme.colors.elevation2 : currentTheme.colors.white,
- }));
-
- const menuOptions = useMemo(() => [
- {
- icon: item.inLibrary ? 'bookmark' : 'bookmark-border',
- label: item.inLibrary ? 'Remove from Library' : 'Add to Library',
- action: 'library'
- },
- {
- icon: 'check-circle',
- label: 'Mark as Watched',
- action: 'watched'
- },
- {
- icon: 'playlist-add',
- label: 'Add to Playlist',
- action: 'playlist'
- },
- {
- icon: 'share',
- label: 'Share',
- action: 'share'
- }
- ], [item.inLibrary]);
-
- const handleOptionSelect = useCallback((action: string) => {
- onOptionSelect(action);
- onClose();
- }, [onOptionSelect, onClose]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- {item.name}
-
- {item.year && (
-
- {item.year}
-
- )}
-
-
-
- {menuOptions.map((option, index) => (
- handleOptionSelect(option.action)}
- >
-
-
- {option.label}
-
-
- ))}
-
-
-
-
-
-
- );
-});
-
-const ContentItem = React.memo(({ item: initialItem, onPress }: ContentItemProps) => {
- const [menuVisible, setMenuVisible] = useState(false);
- const [localItem, setLocalItem] = useState(initialItem);
- const [isWatched, setIsWatched] = useState(false);
- const [imageLoaded, setImageLoaded] = useState(false);
- const [imageError, setImageError] = useState(false);
- const { currentTheme } = useTheme();
-
- const handleLongPress = useCallback(() => {
- setMenuVisible(true);
- }, []);
-
- const handlePress = useCallback(() => {
- onPress(localItem.id, localItem.type);
- }, [localItem.id, localItem.type, onPress]);
-
- const handleOptionSelect = useCallback((option: string) => {
- switch (option) {
- case 'library':
- if (localItem.inLibrary) {
- catalogService.removeFromLibrary(localItem.type, localItem.id);
- } else {
- catalogService.addToLibrary(localItem);
- }
- break;
- case 'watched':
- setIsWatched(prev => !prev);
- break;
- case 'playlist':
- case 'share':
- // These options don't have implementations yet
- break;
- }
- }, [localItem]);
-
- const handleMenuClose = useCallback(() => {
- setMenuVisible(false);
- }, []);
-
- // Only update localItem when initialItem changes
- useEffect(() => {
- setLocalItem(initialItem);
- }, [initialItem]);
-
- // Subscribe to library updates
- useEffect(() => {
- const unsubscribe = catalogService.subscribeToLibraryUpdates((libraryItems) => {
- const isInLibrary = libraryItems.some(
- libraryItem => libraryItem.id === localItem.id && libraryItem.type === localItem.type
- );
- if (isInLibrary !== localItem.inLibrary) {
- setLocalItem(prev => ({ ...prev, inLibrary: isInLibrary }));
- }
- });
-
- return () => unsubscribe();
- }, [localItem.id, localItem.type]);
-
- return (
- <>
-
-
- {
- setImageLoaded(false);
- setImageError(false);
- }}
- onLoadEnd={() => setImageLoaded(true)}
- onError={() => {
- setImageError(true);
- setImageLoaded(true);
- }}
- />
- {(!imageLoaded || imageError) && (
-
- {!imageError ? (
-
- ) : (
-
- )}
-
- )}
- {isWatched && (
-
-
-
- )}
- {localItem.inLibrary && (
-
-
-
- )}
-
-
-
- {menuVisible && (
-
- )}
- >
- );
-}, (prevProps, nextProps) => {
- // Custom comparison function to prevent unnecessary re-renders
- return (
- prevProps.item.id === nextProps.item.id &&
- prevProps.item.inLibrary === nextProps.item.inLibrary &&
- prevProps.onPress === nextProps.onPress
- );
-});
+type HomeScreenListItem =
+ | { type: 'featured'; key: string }
+ | { type: 'thisWeek'; key: string }
+ | { type: 'continueWatching'; key: string }
+ | { type: 'catalog'; catalog: CatalogContent; key: string }
+ | { type: 'placeholder'; key: string };
// Sample categories (real app would get these from API)
const SAMPLE_CATEGORIES: Category[] = [
@@ -393,7 +114,7 @@ const HomeScreen = () => {
const refreshTimeoutRef = useRef(null);
const [hasContinueWatching, setHasContinueWatching] = useState(false);
- const [catalogs, setCatalogs] = useState([]);
+ const [catalogs, setCatalogs] = useState<(CatalogContent | null)[]>([]);
const [catalogsLoading, setCatalogsLoading] = useState(true);
const [loadedCatalogCount, setLoadedCatalogCount] = useState(0);
const totalCatalogsRef = useRef(0);
@@ -415,6 +136,13 @@ const HomeScreen = () => {
try {
const addons = await catalogService.getAllAddons();
+ // Load catalog settings to check which catalogs are enabled
+ const catalogSettingsJson = await AsyncStorage.getItem(CATALOG_SETTINGS_KEY);
+ const catalogSettings = catalogSettingsJson ? JSON.parse(catalogSettingsJson) : {};
+
+ // Hoist addon manifest loading out of the loop
+ const addonManifests = await stremioService.getInstalledAddonsAsync();
+
// Create placeholder array with proper order and track indices
const catalogPlaceholders: (CatalogContent | null)[] = [];
const catalogPromises: Promise[] = [];
@@ -423,76 +151,79 @@ const HomeScreen = () => {
for (const addon of addons) {
if (addon.catalogs) {
for (const catalog of addon.catalogs) {
- const currentIndex = catalogIndex;
- catalogPlaceholders.push(null); // Reserve position
+ // Check if this catalog is enabled (default to true if no setting exists)
+ const settingKey = `${addon.id}:${catalog.type}:${catalog.id}`;
+ const isEnabled = catalogSettings[settingKey] ?? true;
- const catalogPromise = (async () => {
- try {
- const addonManifest = await stremioService.getInstalledAddonsAsync();
- const manifest = addonManifest.find((a: any) => a.id === addon.id);
- if (!manifest) return;
+ // Only load enabled catalogs
+ if (isEnabled) {
+ const currentIndex = catalogIndex;
+ catalogPlaceholders.push(null); // Reserve position
+
+ const catalogPromise = (async () => {
+ try {
+ const manifest = addonManifests.find((a: any) => a.id === addon.id);
+ if (!manifest) return;
- const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
- if (metas && metas.length > 0) {
- const items = metas.map((meta: any) => ({
- id: meta.id,
- type: meta.type,
- name: meta.name,
- poster: meta.poster,
- posterShape: meta.posterShape,
- banner: meta.background,
- logo: meta.logo,
- imdbRating: meta.imdbRating,
- year: meta.year,
- genres: meta.genres,
- description: meta.description,
- runtime: meta.runtime,
- released: meta.released,
- trailerStreams: meta.trailerStreams,
- videos: meta.videos,
- directors: meta.director,
- creators: meta.creator,
- certification: meta.certification
- }));
-
- let displayName = catalog.name;
- const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
- if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
- displayName = `${displayName} ${contentType}`;
+ const metas = await stremioService.getCatalog(manifest, catalog.type, catalog.id, 1);
+ if (metas && metas.length > 0) {
+ const items = metas.map((meta: any) => ({
+ id: meta.id,
+ type: meta.type,
+ name: meta.name,
+ poster: meta.poster,
+ posterShape: meta.posterShape,
+ banner: meta.background,
+ logo: meta.logo,
+ imdbRating: meta.imdbRating,
+ year: meta.year,
+ genres: meta.genres,
+ description: meta.description,
+ runtime: meta.runtime,
+ released: meta.released,
+ trailerStreams: meta.trailerStreams,
+ videos: meta.videos,
+ directors: meta.director,
+ creators: meta.creator,
+ certification: meta.certification
+ }));
+
+ let displayName = catalog.name;
+ const contentType = catalog.type === 'movie' ? 'Movies' : 'TV Shows';
+ if (!displayName.toLowerCase().includes(contentType.toLowerCase())) {
+ displayName = `${displayName} ${contentType}`;
+ }
+
+ const catalogContent = {
+ addon: addon.id,
+ type: catalog.type,
+ id: catalog.id,
+ name: displayName,
+ items
+ };
+
+ // Update the catalog at its specific position
+ setCatalogs(prevCatalogs => {
+ const newCatalogs = [...prevCatalogs];
+ newCatalogs[currentIndex] = catalogContent;
+ return newCatalogs;
+ });
}
-
- const catalogContent = {
- addon: addon.id,
- type: catalog.type,
- id: catalog.id,
- name: displayName,
- items
- };
-
- console.log(`[HomeScreen] Loaded catalog: ${displayName} at position ${currentIndex} (${items.length} items)`);
-
- // Update the catalog at its specific position
- setCatalogs(prevCatalogs => {
- const newCatalogs = [...prevCatalogs];
- newCatalogs[currentIndex] = catalogContent;
- return newCatalogs;
- });
+ } catch (error) {
+ console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error);
+ } finally {
+ setLoadedCatalogCount(prev => prev + 1);
}
- } catch (error) {
- console.error(`[HomeScreen] Failed to load ${catalog.name} from ${addon.name}:`, error);
- } finally {
- setLoadedCatalogCount(prev => prev + 1);
- }
- })();
-
- catalogPromises.push(catalogPromise);
- catalogIndex++;
+ })();
+
+ catalogPromises.push(catalogPromise);
+ catalogIndex++;
+ }
}
}
}
totalCatalogsRef.current = catalogIndex;
- console.log(`[HomeScreen] Starting to load ${catalogIndex} catalogs progressively...`);
// Initialize catalogs array with proper length
setCatalogs(new Array(catalogIndex).fill(null));
@@ -500,10 +231,8 @@ const HomeScreen = () => {
// Start all catalog loading promises but don't wait for them
// They will update the state progressively as they complete
Promise.allSettled(catalogPromises).then(() => {
- console.log('[HomeScreen] All catalogs processed');
-
// Final cleanup: Filter out null values to get only successfully loaded catalogs
- setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null));
+ setCatalogs(prevCatalogs => prevCatalogs.filter(catalog => catalog !== null));
});
} catch (error) {
@@ -596,21 +325,40 @@ const HomeScreen = () => {
if (!content.length) return;
try {
- const imagePromises = content.map(item => {
- const imagesToLoad = [
- item.poster,
- item.banner,
- item.logo
- ].filter(Boolean) as string[];
+ // Limit concurrent prefetching to prevent memory pressure
+ const MAX_CONCURRENT_PREFETCH = 5;
+ const BATCH_SIZE = 3;
+
+ const allImages = content.slice(0, 10) // Limit total images to prefetch
+ .map(item => [item.poster, item.banner, item.logo])
+ .flat()
+ .filter(Boolean) as string[];
- return Promise.all(
- imagesToLoad.map(imageUrl =>
- ExpoImage.prefetch(imageUrl)
- )
- );
- });
-
- await Promise.all(imagePromises);
+ // Process in small batches to prevent memory pressure
+ for (let i = 0; i < allImages.length; i += BATCH_SIZE) {
+ const batch = allImages.slice(i, i + BATCH_SIZE);
+
+ try {
+ 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));
+ } catch (error) {
+ // Silently handle individual prefetch errors
+ }
+ })
+ );
+
+ // Delay between batches to allow GC
+ if (i + BATCH_SIZE < allImages.length) {
+ await new Promise(resolve => setTimeout(resolve, 50));
+ }
+ } catch (error) {
+ // Continue with next batch if current batch fails
+ }
+ }
} catch (error) {
// Silently handle preload errors
}
@@ -624,11 +372,27 @@ const HomeScreen = () => {
if (!featuredContent) return;
try {
+ // Clear image cache to reduce memory pressure before orientation change
+ if (typeof (global as any)?.ExpoImage?.clearMemoryCache === 'function') {
+ try {
+ (global as any).ExpoImage.clearMemoryCache();
+ } catch (e) {
+ // Ignore cache clear errors
+ }
+ }
+
// Lock orientation to landscape before navigation to prevent glitches
+ try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.LANDSCAPE);
- // Small delay to ensure orientation is set before navigation
+ // Longer delay to ensure orientation is fully set before navigation
+ await new Promise(resolve => setTimeout(resolve, 200));
+ } catch (orientationError) {
+ // If orientation lock fails, continue anyway but log it
+ logger.warn('[HomeScreen] Orientation lock failed:', orientationError);
+ // Still add a small delay
await new Promise(resolve => setTimeout(resolve, 100));
+ }
navigation.navigate('Player', {
uri: stream.url,
@@ -640,6 +404,8 @@ const HomeScreen = () => {
type: featuredContent.type
});
} catch (error) {
+ logger.error('[HomeScreen] Error in handlePlayStream:', error);
+
// Fallback: navigate anyway
navigation.navigate('Player', {
uri: stream.url,
@@ -654,37 +420,15 @@ const HomeScreen = () => {
}, [featuredContent, navigation]);
const refreshContinueWatching = useCallback(async () => {
- console.log('[HomeScreen] Refreshing continue watching...');
if (continueWatchingRef.current) {
try {
const hasContent = await continueWatchingRef.current.refresh();
- console.log(`[HomeScreen] Continue watching has content: ${hasContent}`);
setHasContinueWatching(hasContent);
- // Debug: Let's check what's in storage
- const allProgress = await storageService.getAllWatchProgress();
- console.log('[HomeScreen] All watch progress in storage:', Object.keys(allProgress).length, 'items');
- console.log('[HomeScreen] Watch progress items:', allProgress);
-
- // Check if any items are being filtered out due to >85% progress
- let filteredCount = 0;
- for (const [key, progress] of Object.entries(allProgress)) {
- const progressPercent = (progress.currentTime / progress.duration) * 100;
- if (progressPercent >= 85) {
- filteredCount++;
- console.log(`[HomeScreen] Filtered out ${key}: ${progressPercent.toFixed(1)}% complete`);
- } else {
- console.log(`[HomeScreen] Valid progress ${key}: ${progressPercent.toFixed(1)}% complete`);
- }
- }
- console.log(`[HomeScreen] Filtered out ${filteredCount} completed items`);
-
} catch (error) {
console.error('[HomeScreen] Error refreshing continue watching:', error);
setHasContinueWatching(false);
}
- } else {
- console.log('[HomeScreen] Continue watching ref is null');
}
}, []);
@@ -722,6 +466,106 @@ const HomeScreen = () => {
return null;
}, [isLoading, currentTheme.colors]);
+ const listData: HomeScreenListItem[] = useMemo(() => {
+ const data: HomeScreenListItem[] = [];
+
+ if (showHeroSection) {
+ data.push({ type: 'featured', key: 'featured' });
+ }
+
+ data.push({ type: 'thisWeek', key: 'thisWeek' });
+ data.push({ type: 'continueWatching', key: 'continueWatching' });
+
+ catalogs.forEach((catalog, index) => {
+ if (catalog) {
+ data.push({ type: 'catalog', catalog, key: `${catalog.addon}-${catalog.id}-${index}` });
+ } else {
+ // Add a key for placeholders
+ data.push({ type: 'placeholder', key: `placeholder-${index}` });
+ }
+ });
+
+ return data;
+ }, [showHeroSection, catalogs]);
+
+ const renderListItem = useCallback(({ item }: { item: HomeScreenListItem }) => {
+ switch (item.type) {
+ case 'featured':
+ return (
+
+ );
+ case 'thisWeek':
+ return ;
+ case 'continueWatching':
+ return ;
+ case 'catalog':
+ return (
+
+
+
+ );
+ case 'placeholder':
+ return (
+
+
+
+
+
+
+ {[...Array(4)].map((_, posterIndex) => (
+
+ ))}
+
+
+ );
+ default:
+ return null;
+ }
+ }, [
+ showHeroSection,
+ featuredContentSource,
+ featuredContent,
+ isSaved,
+ handleSaveToLibrary,
+ currentTheme.colors
+ ]);
+
+ const ListFooterComponent = useMemo(() => (
+ <>
+ {catalogsLoading && catalogs.length < totalCatalogsRef.current && (
+
+
+
+ Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
+
+
+ )}
+ {!catalogsLoading && catalogs.filter(c => c).length === 0 && (
+
+
+
+ No content available
+
+ navigation.navigate('Settings')}
+ >
+
+ Add Catalogs
+
+
+ )}
+ >
+ ), [catalogsLoading, catalogs, loadedCatalogCount, totalCatalogsRef.current, navigation, currentTheme.colors]);
+
// Memoize the main content section
const renderMainContent = useMemo(() => {
if (isLoading) return null;
@@ -733,102 +577,28 @@ const HomeScreen = () => {
backgroundColor="transparent"
translucent
/>
- item.key}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
]}
showsVerticalScrollIndicator={false}
- removeClippedSubviews={true}
- >
- {showHeroSection && (
-
- )}
-
-
-
-
-
-
-
- {/* Show catalogs as they load */}
- {catalogs.map((catalog, index) => {
- if (!catalog) {
- // Show placeholder for loading catalog
- return (
-
-
-
-
-
-
- {[...Array(4)].map((_, posterIndex) => (
-
- ))}
-
-
- );
- }
-
- return (
-
-
-
- );
- })}
-
- {/* Show loading indicator for remaining catalogs */}
- {catalogsLoading && catalogs.length < totalCatalogsRef.current && (
-
-
-
- Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
-
-
- )}
-
- {/* Show empty state only if all catalogs are loaded and none are available */}
- {!catalogsLoading && catalogs.length === 0 && (
-
-
-
- No content available
-
- navigation.navigate('Settings')}
- >
-
- Add Catalogs
-
-
- )}
-
+ ListFooterComponent={ListFooterComponent}
+ initialNumToRender={5}
+ maxToRenderPerBatch={5}
+ windowSize={10}
+ />
);
}, [
- isLoading,
- currentTheme.colors,
- showHeroSection,
- featuredContent,
- isSaved,
- handleSaveToLibrary,
- hasContinueWatching,
- catalogs,
- catalogsLoading,
- navigation,
- featuredContentSource
+ isLoading,
+ currentTheme.colors,
+ listData,
+ renderListItem,
+ ListFooterComponent
]);
return isLoading ? renderLoadingScreen : renderMainContent;
@@ -857,11 +627,8 @@ const calculatePosterLayout = (screenWidth: number) => {
const usableWidth = availableWidth - 8;
const posterWidth = (usableWidth - (n - 1) * SPACING) / (n + 0.25);
- console.log(`[HomeScreen] Testing ${n} posters: width=${posterWidth.toFixed(1)}px, screen=${screenWidth}px`);
-
if (posterWidth >= MIN_POSTER_WIDTH && posterWidth <= MAX_POSTER_WIDTH) {
bestLayout = { numFullPosters: n, posterWidth };
- console.log(`[HomeScreen] Selected layout: ${n} full posters at ${posterWidth.toFixed(1)}px each`);
}
}
diff --git a/src/screens/LibraryScreen.tsx b/src/screens/LibraryScreen.tsx
index 22f61a2..ef80caf 100644
--- a/src/screens/LibraryScreen.tsx
+++ b/src/screens/LibraryScreen.tsx
@@ -28,12 +28,16 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../contexts/ThemeContext';
import { useTraktContext } from '../contexts/TraktContext';
import TraktIcon from '../../assets/rating-icons/trakt.svg';
-import { traktService, TraktService } from '../services/traktService';
+import { traktService, TraktService, TraktImages } from '../services/traktService';
// Define interfaces for proper typing
interface LibraryItem extends StreamingContent {
progress?: number;
lastWatched?: string;
+ gradient: [string, string];
+ imdbId?: string;
+ traktId: number;
+ images?: TraktImages;
}
interface TraktDisplayItem {
@@ -47,6 +51,7 @@ interface TraktDisplayItem {
rating?: number;
imdbId?: string;
traktId: number;
+ images?: TraktImages;
}
interface TraktFolder {
@@ -60,6 +65,82 @@ interface TraktFolder {
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
+const TraktItem = React.memo(({ item, width, navigation, currentTheme }: { item: TraktDisplayItem; width: number; navigation: any; currentTheme: any }) => {
+ const [posterUrl, setPosterUrl] = useState(null);
+
+ useEffect(() => {
+ let isMounted = true;
+ const fetchPoster = async () => {
+ if (item.images) {
+ const url = await TraktService.getTraktPosterUrlCached(item.images);
+ if (isMounted && url) {
+ setPosterUrl(url);
+ }
+ }
+ };
+ fetchPoster();
+ return () => { isMounted = false; };
+ }, [item.images]);
+
+ const handlePress = useCallback(() => {
+ if (item.imdbId) {
+ navigation.navigate('Metadata', { id: item.imdbId, type: item.type });
+ }
+ }, [navigation, item.imdbId, item.type]);
+
+ return (
+
+
+ {posterUrl ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ {item.name}
+
+ {item.lastWatched && (
+
+ Last watched: {item.lastWatched}
+
+ )}
+ {item.plays && item.plays > 1 && (
+
+ {item.plays} plays
+
+ )}
+
+
+
+
+
+ {item.type === 'movie' ? 'Movie' : 'Series'}
+
+
+
+
+ );
+});
+
const SkeletonLoader = () => {
const pulseAnim = React.useRef(new RNAnimated.Value(0)).current;
const { width } = useWindowDimensions();
@@ -168,7 +249,7 @@ const LibraryScreen = () => {
setLoading(true);
try {
const items = await catalogService.getLibraryItems();
- setLibraryItems(items);
+ setLibraryItems(items as LibraryItem[]);
} catch (error) {
logger.error('Failed to load library:', error);
} finally {
@@ -180,7 +261,7 @@ const LibraryScreen = () => {
// Subscribe to library updates
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
- setLibraryItems(items);
+ setLibraryItems(items as LibraryItem[]);
});
return () => {
@@ -246,136 +327,6 @@ const LibraryScreen = () => {
return folders.filter(folder => folder.itemCount > 0);
}, [traktAuthenticated, watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
- // State for poster URLs (since they're now async)
- const [traktPostersMap, setTraktPostersMap] = useState>(new Map());
-
- // Prepare Trakt items with placeholders, then load posters async
- const traktItems = useMemo(() => {
- if (!traktAuthenticated || (!watchedMovies?.length && !watchedShows?.length)) {
- return [];
- }
-
- const items: TraktDisplayItem[] = [];
-
- // Process watched movies
- if (watchedMovies) {
- for (const watchedMovie of watchedMovies) {
- const movie = watchedMovie.movie;
- if (movie) {
- const itemId = String(movie.ids.trakt);
- const cachedPoster = traktPostersMap.get(itemId);
-
- items.push({
- id: itemId,
- name: movie.title,
- type: 'movie',
- poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
- year: movie.year,
- lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(),
- plays: watchedMovie.plays,
- imdbId: movie.ids.imdb,
- traktId: movie.ids.trakt,
- });
- }
- }
- }
-
- // Process watched shows
- if (watchedShows) {
- for (const watchedShow of watchedShows) {
- const show = watchedShow.show;
- if (show) {
- const itemId = String(show.ids.trakt);
- const cachedPoster = traktPostersMap.get(itemId);
-
- items.push({
- id: itemId,
- name: show.title,
- type: 'series',
- poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
- year: show.year,
- lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(),
- plays: watchedShow.plays,
- imdbId: show.ids.imdb,
- traktId: show.ids.trakt,
- });
- }
- }
- }
-
- // Sort by last watched date (most recent first)
- return items.sort((a, b) => {
- const dateA = a.lastWatched ? new Date(a.lastWatched).getTime() : 0;
- const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0;
- return dateB - dateA;
- });
- }, [traktAuthenticated, watchedMovies, watchedShows, traktPostersMap]);
-
- // Effect to load cached poster URLs
- useEffect(() => {
- const loadCachedPosters = async () => {
- if (!traktAuthenticated) return;
-
- const postersToLoad = new Map();
-
- // Collect movies that need posters
- if (watchedMovies) {
- for (const watchedMovie of watchedMovies) {
- const movie = watchedMovie.movie;
- if (movie) {
- const itemId = String(movie.ids.trakt);
- if (!traktPostersMap.has(itemId)) {
- postersToLoad.set(itemId, movie.images);
- }
- }
- }
- }
-
- // Collect shows that need posters
- if (watchedShows) {
- for (const watchedShow of watchedShows) {
- const show = watchedShow.show;
- if (show) {
- const itemId = String(show.ids.trakt);
- if (!traktPostersMap.has(itemId)) {
- postersToLoad.set(itemId, show.images);
- }
- }
- }
- }
-
- // Load posters in parallel
- const posterPromises = Array.from(postersToLoad.entries()).map(async ([itemId, images]) => {
- try {
- const posterUrl = await TraktService.getTraktPosterUrl(images);
- return {
- itemId,
- posterUrl: posterUrl || 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'
- };
- } catch (error) {
- logger.error(`Failed to get cached poster for ${itemId}:`, error);
- return {
- itemId,
- posterUrl: 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster'
- };
- }
- });
-
- const results = await Promise.all(posterPromises);
-
- // Update state with new posters
- setTraktPostersMap(prevMap => {
- const newMap = new Map(prevMap);
- results.forEach(({ itemId, posterUrl }) => {
- newMap.set(itemId, posterUrl);
- });
- return newMap;
- });
- };
-
- loadCachedPosters();
- }, [traktAuthenticated, watchedMovies, watchedShows]);
-
const itemWidth = (width - 48) / 2; // 2 items per row with padding
const renderItem = ({ item }: { item: LibraryItem }) => (
@@ -491,9 +442,9 @@ const LibraryScreen = () => {
Trakt Collection
- {traktAuthenticated && traktItems.length > 0 && (
+ {traktAuthenticated && traktFolders.length > 0 && (
- {traktItems.length} items
+ {traktFolders.length} items
)}
{!traktAuthenticated && (
@@ -514,59 +465,9 @@ const LibraryScreen = () => {
);
- const renderTraktItem = ({ item, customWidth }: { item: TraktDisplayItem; customWidth?: number }) => {
- const posterUrl = item.poster || 'https://via.placeholder.com/300x450/ff0000/ffffff?text=No+Poster';
- const width = customWidth || itemWidth;
-
- return (
- {
- // Navigate using IMDB ID for Trakt items
- if (item.imdbId) {
- navigation.navigate('Metadata', { id: item.imdbId, type: item.type });
- }
- }}
- activeOpacity={0.7}
- >
-
-
-
-
- {item.name}
-
-
- Last watched: {item.lastWatched}
-
- {item.plays && item.plays > 1 && (
-
- {item.plays} plays
-
- )}
-
-
- {/* Trakt badge */}
-
-
-
- {item.type === 'movie' ? 'Movie' : 'Series'}
-
-
-
-
- );
- };
+ const renderTraktItem = useCallback(({ item }: { item: TraktDisplayItem }) => {
+ return ;
+ }, [itemWidth, navigation, currentTheme]);
// Get items for a specific Trakt folder
const getTraktFolderItems = useCallback((folderId: string): TraktDisplayItem[] => {
@@ -579,19 +480,17 @@ const LibraryScreen = () => {
for (const watchedMovie of watchedMovies) {
const movie = watchedMovie.movie;
if (movie) {
- const itemId = String(movie.ids.trakt);
- const cachedPoster = traktPostersMap.get(itemId);
-
items.push({
- id: itemId,
+ id: String(movie.ids.trakt),
name: movie.title,
type: 'movie',
- poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
+ poster: 'placeholder',
year: movie.year,
lastWatched: new Date(watchedMovie.last_watched_at).toLocaleDateString(),
plays: watchedMovie.plays,
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
+ images: movie.images,
});
}
}
@@ -601,19 +500,17 @@ const LibraryScreen = () => {
for (const watchedShow of watchedShows) {
const show = watchedShow.show;
if (show) {
- const itemId = String(show.ids.trakt);
- const cachedPoster = traktPostersMap.get(itemId);
-
items.push({
- id: itemId,
+ id: String(show.ids.trakt),
name: show.title,
type: 'series',
- poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
+ poster: 'placeholder',
year: show.year,
lastWatched: new Date(watchedShow.last_watched_at).toLocaleDateString(),
plays: watchedShow.plays,
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
+ images: show.images,
});
}
}
@@ -625,32 +522,28 @@ const LibraryScreen = () => {
if (continueWatching) {
for (const item of continueWatching) {
if (item.type === 'movie' && item.movie) {
- const itemId = String(item.movie.ids.trakt);
- const cachedPoster = traktPostersMap.get(itemId);
-
items.push({
- id: itemId,
+ id: String(item.movie.ids.trakt),
name: item.movie.title,
type: 'movie',
- poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
+ poster: 'placeholder',
year: item.movie.year,
lastWatched: new Date(item.paused_at).toLocaleDateString(),
imdbId: item.movie.ids.imdb,
traktId: item.movie.ids.trakt,
+ images: item.movie.images,
});
} else if (item.type === 'episode' && item.show && item.episode) {
- const itemId = String(item.show.ids.trakt);
- const cachedPoster = traktPostersMap.get(itemId);
-
items.push({
id: `${item.show.ids.trakt}:${item.episode.season}:${item.episode.number}`,
name: `${item.show.title} S${item.episode.season}E${item.episode.number}`,
type: 'series',
- poster: cachedPoster || 'https://via.placeholder.com/300x450/cccccc/666666?text=Loading...',
+ poster: 'placeholder',
year: item.show.year,
lastWatched: new Date(item.paused_at).toLocaleDateString(),
imdbId: item.show.ids.imdb,
traktId: item.show.ids.trakt,
+ images: item.show.images,
});
}
}
@@ -663,19 +556,16 @@ const LibraryScreen = () => {
for (const watchlistMovie of watchlistMovies) {
const movie = watchlistMovie.movie;
if (movie) {
- const itemId = String(movie.ids.trakt);
- const posterUrl = TraktService.getTraktPosterUrl(movie.images) ||
- 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster';
-
items.push({
- id: itemId,
+ id: String(movie.ids.trakt),
name: movie.title,
type: 'movie',
- poster: posterUrl,
+ poster: 'placeholder',
year: movie.year,
lastWatched: new Date(watchlistMovie.listed_at).toLocaleDateString(),
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
+ images: movie.images,
});
}
}
@@ -685,19 +575,16 @@ const LibraryScreen = () => {
for (const watchlistShow of watchlistShows) {
const show = watchlistShow.show;
if (show) {
- const itemId = String(show.ids.trakt);
- const posterUrl = TraktService.getTraktPosterUrl(show.images) ||
- 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster';
-
items.push({
- id: itemId,
+ id: String(show.ids.trakt),
name: show.title,
type: 'series',
- poster: posterUrl,
+ poster: 'placeholder',
year: show.year,
lastWatched: new Date(watchlistShow.listed_at).toLocaleDateString(),
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
+ images: show.images,
});
}
}
@@ -710,19 +597,16 @@ const LibraryScreen = () => {
for (const collectionMovie of collectionMovies) {
const movie = collectionMovie.movie;
if (movie) {
- const itemId = String(movie.ids.trakt);
- const posterUrl = TraktService.getTraktPosterUrl(movie.images) ||
- 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster';
-
items.push({
- id: itemId,
+ id: String(movie.ids.trakt),
name: movie.title,
type: 'movie',
- poster: posterUrl,
+ poster: 'placeholder',
year: movie.year,
lastWatched: new Date(collectionMovie.collected_at).toLocaleDateString(),
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
+ images: movie.images,
});
}
}
@@ -732,19 +616,16 @@ const LibraryScreen = () => {
for (const collectionShow of collectionShows) {
const show = collectionShow.show;
if (show) {
- const itemId = String(show.ids.trakt);
- const posterUrl = TraktService.getTraktPosterUrl(show.images) ||
- 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster';
-
items.push({
- id: itemId,
+ id: String(show.ids.trakt),
name: show.title,
type: 'series',
- poster: posterUrl,
+ poster: 'placeholder',
year: show.year,
lastWatched: new Date(collectionShow.collected_at).toLocaleDateString(),
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
+ images: show.images,
});
}
}
@@ -757,37 +638,31 @@ const LibraryScreen = () => {
for (const ratedItem of ratedContent) {
if (ratedItem.movie) {
const movie = ratedItem.movie;
- const itemId = String(movie.ids.trakt);
- const posterUrl = TraktService.getTraktPosterUrl(movie.images) ||
- 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster';
-
items.push({
- id: itemId,
+ id: String(movie.ids.trakt),
name: movie.title,
type: 'movie',
- poster: posterUrl,
+ poster: 'placeholder',
year: movie.year,
lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(),
rating: ratedItem.rating,
imdbId: movie.ids.imdb,
traktId: movie.ids.trakt,
+ images: movie.images,
});
} else if (ratedItem.show) {
const show = ratedItem.show;
- const itemId = String(show.ids.trakt);
- const posterUrl = TraktService.getTraktPosterUrl(show.images) ||
- 'https://via.placeholder.com/300x450/cccccc/666666?text=No+Poster';
-
items.push({
- id: itemId,
+ id: String(show.ids.trakt),
name: show.title,
type: 'series',
- poster: posterUrl,
+ poster: 'placeholder',
year: show.year,
lastWatched: new Date(ratedItem.rated_at).toLocaleDateString(),
rating: ratedItem.rating,
imdbId: show.ids.imdb,
traktId: show.ids.trakt,
+ images: show.images,
});
}
}
@@ -801,7 +676,7 @@ const LibraryScreen = () => {
const dateB = b.lastWatched ? new Date(b.lastWatched).getTime() : 0;
return dateB - dateA;
});
- }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent, traktPostersMap]);
+ }, [watchedMovies, watchedShows, watchlistMovies, watchlistShows, collectionMovies, collectionShows, continueWatching, ratedContent]);
const renderTraktContent = () => {
if (traktLoading) {
@@ -880,70 +755,21 @@ const LibraryScreen = () => {
);
}
- // Separate movies and shows for the selected folder
- const movies = folderItems.filter(item => item.type === 'movie');
- const shows = folderItems.filter(item => item.type === 'series');
-
return (
- renderTraktItem({ item })}
+ keyExtractor={(item) => `${item.type}-${item.id}`}
+ numColumns={2}
+ columnWrapperStyle={styles.row}
+ style={styles.traktContainer}
+ contentContainerStyle={{ paddingBottom: insets.bottom + 80 }}
showsVerticalScrollIndicator={false}
- contentContainerStyle={styles.sectionsContent}
- >
- {movies.length > 0 && (
-
-
-
-
- Movies ({movies.length})
-
-
-
- {movies.map((item) => (
-
- {renderTraktItem({ item, customWidth: itemWidth * 0.8 })}
-
- ))}
-
-
- )}
-
- {shows.length > 0 && (
-
-
-
-
- TV Shows ({shows.length})
-
-
-
- {shows.map((item) => (
-
- {renderTraktItem({ item, customWidth: itemWidth * 0.8 })}
-
- ))}
-
-
- )}
-
+ initialNumToRender={10}
+ maxToRenderPerBatch={10}
+ windowSize={21}
+ removeClippedSubviews={Platform.OS === 'android'}
+ />
);
};
@@ -1387,6 +1213,17 @@ const styles = StyleSheet.create({
headerSpacer: {
width: 44, // Match the back button width
},
+ traktContainer: {
+ flex: 1,
+ },
+ emptyListText: {
+ fontSize: 16,
+ fontWeight: '500',
+ },
+ row: {
+ justifyContent: 'space-between',
+ paddingHorizontal: 16,
+ },
});
export default LibraryScreen;
\ No newline at end of file
diff --git a/src/screens/MetadataScreen.tsx b/src/screens/MetadataScreen.tsx
index d55d06b..e553d24 100644
--- a/src/screens/MetadataScreen.tsx
+++ b/src/screens/MetadataScreen.tsx
@@ -1,4 +1,4 @@
-import React, { useCallback, useState, useEffect, useMemo } from 'react';
+import React, { useCallback, useState, useEffect, useMemo, useRef } from 'react';
import {
View,
Text,
@@ -56,9 +56,7 @@ const MetadataScreen: React.FC = () => {
// Optimized state management - reduced state variables
const [isContentReady, setIsContentReady] = useState(false);
- const [showSkeleton, setShowSkeleton] = useState(true);
- const transitionOpacity = useSharedValue(0);
- const skeletonOpacity = useSharedValue(1);
+ const transitionOpacity = useSharedValue(1);
const {
metadata,
@@ -187,26 +185,14 @@ const MetadataScreen: React.FC = () => {
// Memoized derived values for performance
const isReady = useMemo(() => !loading && metadata && !metadataError, [loading, metadata, metadataError]);
- // Smooth skeleton to content transition
+ // Simple content ready state management
useEffect(() => {
- if (isReady && !isContentReady) {
- // Small delay to ensure skeleton is rendered before starting transition
- setTimeout(() => {
- // Start fade out skeleton and fade in content simultaneously
- skeletonOpacity.value = withTiming(0, { duration: 300 });
- transitionOpacity.value = withTiming(1, { duration: 400 });
-
- // Hide skeleton after fade out completes
- setTimeout(() => {
- setShowSkeleton(false);
- setIsContentReady(true);
- }, 300);
- }, 100);
+ if (isReady) {
+ setIsContentReady(true);
+ transitionOpacity.value = withTiming(1, { duration: 50 });
} else if (!isReady && isContentReady) {
setIsContentReady(false);
- setShowSkeleton(true);
transitionOpacity.value = 0;
- skeletonOpacity.value = 1;
}
}, [isReady, isContentReady]);
@@ -257,10 +243,6 @@ const MetadataScreen: React.FC = () => {
opacity: transitionOpacity.value,
}), []);
- const skeletonStyle = useAnimatedStyle(() => ({
- opacity: skeletonOpacity.value,
- }), []);
-
// Memoized error component for performance
const ErrorComponent = useMemo(() => {
if (!metadataError) return null;
@@ -300,106 +282,126 @@ const MetadataScreen: React.FC = () => {
}
return (
-
- {/* Skeleton Loading Screen - with fade out transition */}
- {showSkeleton && (
-
-
-
- )}
-
- {/* Main Content - with fade in transition */}
+
+
+
{metadata && (
-
-
+ {/* Floating Header - Optimized */}
+
+
+
-
-
- {/* Floating Header - Optimized */}
-
-
- {/* Hero Section - Optimized */}
-
+ imdbId ? (
+
+ ) : null}
/>
- {/* Main Content - Optimized */}
-
- imdbId ? (
-
- ) : null}
- />
-
+ {/* Cast Section with skeleton when loading */}
+ {loadingCast ? (
+
+
+
+ {[...Array(4)].map((_, index) => (
+
+ ))}
+
+
+ ) : (
+ )}
- {type === 'movie' && (
+ {/* Recommendations Section with skeleton when loading */}
+ {type === 'movie' && (
+ loadingRecommendations ? (
+
+
+
+ {[...Array(3)].map((_, index) => (
+
+ ))}
+
+
+ ) : (
- )}
+ )
+ )}
- {type === 'series' ? (
+ {/* Series/Movie Content with episode skeleton when loading */}
+ {type === 'series' ? (
+ (loadingSeasons || Object.keys(groupedEpisodes).length === 0) ? (
+
+
+
+ {[...Array(6)].map((_, index) => (
+
+ ))}
+
+
+ ) : (
{
groupedEpisodes={groupedEpisodes}
metadata={metadata || undefined}
/>
- ) : (
- metadata &&
- )}
-
-
-
-
+ )
+ ) : (
+ metadata &&
+ )}
+
+
+ >
)}
-
+
);
};
@@ -466,6 +468,44 @@ const styles = StyleSheet.create({
fontSize: 16,
fontWeight: '600',
},
+ // Skeleton loading styles
+ skeletonSection: {
+ padding: 16,
+ marginBottom: 24,
+ },
+ skeletonTitle: {
+ width: 150,
+ height: 20,
+ borderRadius: 4,
+ marginBottom: 16,
+ },
+ skeletonCastRow: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ skeletonCastItem: {
+ width: 80,
+ height: 120,
+ borderRadius: 8,
+ },
+ skeletonRecommendationsRow: {
+ flexDirection: 'row',
+ gap: 12,
+ },
+ skeletonRecommendationItem: {
+ width: 120,
+ height: 180,
+ borderRadius: 8,
+ },
+ skeletonEpisodesContainer: {
+ gap: 12,
+ },
+ skeletonEpisodeItem: {
+ width: '100%',
+ height: 80,
+ borderRadius: 8,
+ marginBottom: 8,
+ },
});
export default MetadataScreen;
\ No newline at end of file
diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx
index 7ba205b..df2027d 100644
--- a/src/screens/SearchScreen.tsx
+++ b/src/screens/SearchScreen.tsx
@@ -35,7 +35,6 @@ import Animated, {
interpolate,
withSpring,
withDelay,
- ZoomIn
} from 'react-native-reanimated';
import { RootStackParamList } from '../navigation/AppNavigator';
import { logger } from '../utils/logger';
@@ -445,7 +444,7 @@ const SearchScreen = () => {
onPress={() => {
navigation.navigate('Metadata', { id: item.id, type: item.type });
}}
- entering={FadeIn.duration(500).delay(index * 100)}
+ entering={FadeIn.duration(300).delay(index * 50)}
activeOpacity={0.7}
>
{
{seriesResults.length > 0 && (
TV Shows ({seriesResults.length})
diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx
index 7f44226..5ef3d62 100644
--- a/src/screens/SettingsScreen.tsx
+++ b/src/screens/SettingsScreen.tsx
@@ -11,7 +11,8 @@ import {
Alert,
Platform,
Dimensions,
- Image
+ Image,
+ Button
} from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useNavigation } from '@react-navigation/native';
@@ -26,6 +27,7 @@ import { useTraktContext } from '../contexts/TraktContext';
import { useTheme } from '../contexts/ThemeContext';
import { catalogService, DataSource } from '../services/catalogService';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import * as Sentry from '@sentry/react-native';
const { width } = Dimensions.get('window');
@@ -95,22 +97,22 @@ const SettingItem: React.FC = ({
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
-
+
-
+
{title}
{description && (
-
+
{description}
)}
{badge && (
- {badge}
+ {String(badge)}
)}
@@ -226,6 +228,29 @@ const SettingsScreen: React.FC = () => {
);
}, [updateSetting]);
+ const handleClearMDBListCache = () => {
+ Alert.alert(
+ "Clear MDBList Cache",
+ "Are you sure you want to clear all cached MDBList data? This cannot be undone.",
+ [
+ { text: "Cancel", style: "cancel" },
+ {
+ text: "Clear",
+ style: "destructive",
+ onPress: async () => {
+ try {
+ await AsyncStorage.removeItem('mdblist_cache');
+ Alert.alert("Success", "MDBList cache has been cleared.");
+ } catch (error) {
+ Alert.alert("Error", "Could not clear MDBList cache.");
+ console.error('Error clearing MDBList cache:', error);
+ }
+ }
+ }
+ ]
+ );
+ };
+
const CustomSwitch = ({ value, onValueChange }: { value: boolean, onValueChange: (value: boolean) => void }) => (
{
/>
(
{
{
{
{
{
/>
+
+
+ Sentry.showFeedbackWidget()}
+ />
+
+
+
+ {/* MDBList Cache Management */}
+ {mdblistKeySet && (
+
+
+
+
+
+ )}
+
Version 1.0.0
@@ -572,7 +619,7 @@ const styles = StyleSheet.create({
flex: 1,
},
header: {
- paddingHorizontal: 20,
+ paddingHorizontal: Math.max(16, width * 0.05),
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
@@ -581,7 +628,7 @@ const styles = StyleSheet.create({
zIndex: 2,
},
headerTitle: {
- fontSize: 32,
+ fontSize: Math.min(32, width * 0.08),
fontWeight: '800',
letterSpacing: 0.3,
},
@@ -607,11 +654,11 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.8,
- marginLeft: 16,
+ marginLeft: Math.max(12, width * 0.04),
marginBottom: 8,
},
card: {
- marginHorizontal: 16,
+ marginHorizontal: Math.max(12, width * 0.04),
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
@@ -625,9 +672,9 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
- paddingHorizontal: 16,
+ paddingHorizontal: Math.max(12, width * 0.04),
borderBottomWidth: 0.5,
- minHeight: 58,
+ minHeight: Math.max(54, width * 0.14),
width: '100%',
},
settingItemBorder: {
@@ -650,12 +697,12 @@ const styles = StyleSheet.create({
flex: 1,
},
settingTitle: {
- fontSize: 16,
+ fontSize: Math.min(16, width * 0.042),
fontWeight: '500',
marginBottom: 3,
},
settingDescription: {
- fontSize: 14,
+ fontSize: Math.min(14, width * 0.037),
opacity: 0.8,
},
settingControl: {
@@ -697,8 +744,10 @@ const styles = StyleSheet.create({
borderRadius: 8,
overflow: 'hidden',
height: 36,
- width: 180,
+ minWidth: 140,
+ maxWidth: 180,
marginRight: 8,
+ alignSelf: 'flex-end',
},
selectorButton: {
flex: 1,
@@ -708,7 +757,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.08)',
},
selectorText: {
- fontSize: 13,
+ fontSize: Math.min(13, width * 0.034),
fontWeight: '500',
textAlign: 'center',
},
diff --git a/src/screens/ShowRatingsScreen.tsx b/src/screens/ShowRatingsScreen.tsx
index 390ed0f..462751d 100644
--- a/src/screens/ShowRatingsScreen.tsx
+++ b/src/screens/ShowRatingsScreen.tsx
@@ -481,7 +481,7 @@ const ShowRatingsScreen = ({ route }: Props) => {
S{season.season_number}
@@ -507,7 +507,7 @@ const ShowRatingsScreen = ({ route }: Props) => {
{season.episodes[episodeIndex] &&
void;
index: number;
isLoading?: boolean;
statusMessage?: string;
theme: any;
- isExiting?: boolean;
}) => {
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
@@ -80,92 +79,13 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, i
const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream');
const displayAddonName = isHDRezka ? '' : (stream.title || '');
- // Animation delay based on index - stagger effect (only if not exiting)
- const enterDelay = isExiting ? 0 : 100 + (index * 30);
-
- // Use simple View when exiting to prevent animation conflicts
- if (isExiting) {
- return (
-
-
-
-
-
-
- {displayTitle}
-
- {displayAddonName && displayAddonName !== displayTitle && (
-
- {displayAddonName}
-
- )}
-
-
- {/* Show loading indicator if stream is loading */}
- {isLoading && (
-
-
-
- {statusMessage || "Loading..."}
-
-
- )}
-
-
-
- {quality && quality >= "720" && (
-
- )}
-
- {isDolby && (
-
- )}
-
- {size && (
-
- {size}
-
- )}
-
- {isDebrid && (
-
- DEBRID
-
- )}
-
- {/* Special badge for HDRezka streams */}
- {isHDRezka && (
-
- HDREZKA
-
- )}
-
-
-
-
-
-
-
-
- );
- }
+ // Animation delay based on index - stagger effect
+ const enterDelay = 100 + (index * 30);
return (
(
{
const loadStartTimeRef = useRef(0);
const hasDoneInitialLoadRef = useRef(false);
- // Add state for handling orientation transition
- const [isTransitioning, setIsTransitioning] = useState(false);
- // Add state to prevent animation conflicts during exit
- const [isExiting, setIsExiting] = useState(false);
// Add timing logs
const [loadStartTime, setLoadStartTime] = useState(0);
@@ -506,9 +422,6 @@ export const StreamsScreen = () => {
// Memoize handlers
const handleBack = useCallback(() => {
- // Set exit state to prevent animation conflicts and hide content immediately
- setIsExiting(true);
-
const cleanup = () => {
headerOpacity.value = withTiming(0, { duration: 100 });
heroScale.value = withTiming(0.95, { duration: 100 });
@@ -1065,10 +978,9 @@ export const StreamsScreen = () => {
isLoading={isLoading}
statusMessage={undefined}
theme={currentTheme}
- isExiting={isExiting}
/>
);
- }, [handleStreamPress, currentTheme, isExiting]);
+ }, [handleStreamPress, currentTheme]);
const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => {
const isProviderLoading = loadingProviders[section.addonId];
@@ -1076,7 +988,7 @@ export const StreamsScreen = () => {
return (
@@ -1101,43 +1013,7 @@ export const StreamsScreen = () => {
};
}, []);
- // Add orientation handling when screen comes into focus
- useFocusEffect(
- useCallback(() => {
- // Set transitioning state to mask any visual glitches
- setIsTransitioning(true);
-
- // Immediately lock to portrait when returning to this screen
- const lockToPortrait = async () => {
- try {
- await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
- // Small delay then unlock to allow natural portrait orientation
- setTimeout(async () => {
- try {
- await ScreenOrientation.unlockAsync();
- // Clear transition state after orientation is handled
- setTimeout(() => {
- setIsTransitioning(false);
- }, 100);
- } catch (error) {
- logger.error('[StreamsScreen] Error unlocking orientation:', error);
- setIsTransitioning(false);
- }
- }, 200);
- } catch (error) {
- logger.error('[StreamsScreen] Error locking to portrait:', error);
- setIsTransitioning(false);
- }
- };
- lockToPortrait();
-
- return () => {
- // Cleanup when screen loses focus
- setIsTransitioning(false);
- };
- }, [])
- );
return (
@@ -1147,17 +1023,6 @@ export const StreamsScreen = () => {
barStyle="light-content"
/>
- {/* Instant overlay when exiting to prevent glitches */}
- {isExiting && (
-
- )}
-
- {/* Transition overlay to mask orientation changes */}
- {isTransitioning && (
-
-
-
- )}
{
{type === 'series' && currentEpisode && (
void)[] = [];
+ private notificationDebounceTimer: NodeJS.Timeout | null = null;
+ private lastNotificationTime: number = 0;
+ private readonly NOTIFICATION_DEBOUNCE_MS = 1000; // 1 second debounce
+ private readonly MIN_NOTIFICATION_INTERVAL = 500; // Minimum 500ms between notifications
private constructor() {}
@@ -25,7 +30,65 @@ class StorageService {
}
private getWatchProgressKey(id: string, type: string, episodeId?: string): string {
- return this.WATCH_PROGRESS_KEY + `${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
+ return `${this.WATCH_PROGRESS_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
+ }
+
+ private getContentDurationKey(id: string, type: string, episodeId?: string): string {
+ return `${this.CONTENT_DURATION_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
+ }
+
+ public async setContentDuration(
+ id: string,
+ type: string,
+ duration: number,
+ episodeId?: string
+ ): Promise {
+ try {
+ const key = this.getContentDurationKey(id, type, episodeId);
+ await AsyncStorage.setItem(key, duration.toString());
+ } catch (error) {
+ logger.error('Error setting content duration:', error);
+ }
+ }
+
+ public async getContentDuration(
+ id: string,
+ type: string,
+ episodeId?: string
+ ): Promise {
+ try {
+ const key = this.getContentDurationKey(id, type, episodeId);
+ const data = await AsyncStorage.getItem(key);
+ return data ? parseFloat(data) : null;
+ } catch (error) {
+ logger.error('Error getting content duration:', error);
+ return null;
+ }
+ }
+
+ public async updateProgressDuration(
+ id: string,
+ type: string,
+ newDuration: number,
+ episodeId?: string
+ ): Promise {
+ try {
+ const existingProgress = await this.getWatchProgress(id, type, episodeId);
+ if (existingProgress && Math.abs(existingProgress.duration - newDuration) > 60) {
+ // Calculate the new current time to maintain the same percentage
+ const progressPercent = (existingProgress.currentTime / existingProgress.duration) * 100;
+ const updatedProgress: WatchProgress = {
+ ...existingProgress,
+ currentTime: (progressPercent / 100) * newDuration,
+ duration: newDuration,
+ lastUpdated: Date.now()
+ };
+ await this.setWatchProgress(id, type, updatedProgress, episodeId);
+ logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration/60).toFixed(0)}min to ${(newDuration/60).toFixed(0)}min`);
+ }
+ } catch (error) {
+ logger.error('Error updating progress duration:', error);
+ }
}
public async setWatchProgress(
@@ -36,16 +99,56 @@ class StorageService {
): Promise {
try {
const key = this.getWatchProgressKey(id, type, episodeId);
+
+ // Check if progress has actually changed significantly
+ const existingProgress = await this.getWatchProgress(id, type, episodeId);
+ if (existingProgress) {
+ const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime);
+ const durationDiff = Math.abs(progress.duration - existingProgress.duration);
+
+ // Only update if there's a significant change (>5 seconds or duration change)
+ if (timeDiff < 5 && durationDiff < 1) {
+ return; // Skip update for minor changes
+ }
+ }
+
await AsyncStorage.setItem(key, JSON.stringify(progress));
- // Notify subscribers
- this.notifyWatchProgressSubscribers();
+
+ // Use debounced notification to reduce spam
+ this.debouncedNotifySubscribers();
} catch (error) {
- logger.error('Error saving watch progress:', error);
+ logger.error('Error setting watch progress:', error);
+ }
+ }
+
+ private debouncedNotifySubscribers(): void {
+ const now = Date.now();
+
+ // Clear existing timer
+ if (this.notificationDebounceTimer) {
+ clearTimeout(this.notificationDebounceTimer);
+ }
+
+ // If we notified recently, debounce longer
+ const timeSinceLastNotification = now - this.lastNotificationTime;
+ if (timeSinceLastNotification < this.MIN_NOTIFICATION_INTERVAL) {
+ this.notificationDebounceTimer = setTimeout(() => {
+ this.notifyWatchProgressSubscribers();
+ }, this.NOTIFICATION_DEBOUNCE_MS);
+ } else {
+ // Notify immediately if enough time has passed
+ this.notifyWatchProgressSubscribers();
}
}
private notifyWatchProgressSubscribers(): void {
+ this.lastNotificationTime = Date.now();
+ this.notificationDebounceTimer = null;
+
+ // Only notify if we have subscribers
+ if (this.watchProgressSubscribers.length > 0) {
this.watchProgressSubscribers.forEach(callback => callback());
+ }
}
public subscribeToWatchProgressUpdates(callback: () => void): () => void {
@@ -115,7 +218,8 @@ class StorageService {
type: string,
traktSynced: boolean,
traktProgress?: number,
- episodeId?: string
+ episodeId?: string,
+ exactTime?: number
): Promise {
try {
const existingProgress = await this.getWatchProgress(id, type, episodeId);
@@ -124,7 +228,9 @@ class StorageService {
...existingProgress,
traktSynced,
traktLastSynced: traktSynced ? Date.now() : existingProgress.traktLastSynced,
- traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress
+ traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress,
+ // Update current time with exact time if provided
+ ...(exactTime && exactTime > 0 && { currentTime: exactTime })
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
}
@@ -182,60 +288,127 @@ class StorageService {
}
/**
- * Merge Trakt progress with local progress
+ * Merge Trakt progress with local progress using exact time when available
*/
public async mergeWithTraktProgress(
id: string,
type: string,
traktProgress: number,
traktPausedAt: string,
- episodeId?: string
+ episodeId?: string,
+ exactTime?: number // Optional exact time in seconds from Trakt scrobble data
): Promise {
try {
const localProgress = await this.getWatchProgress(id, type, episodeId);
const traktTimestamp = new Date(traktPausedAt).getTime();
if (!localProgress) {
- // No local progress, use Trakt data (estimate duration)
- const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; // Default 1 hour
+ // No local progress - use stored duration or estimate
+ let duration = await this.getContentDuration(id, type, episodeId);
+ let currentTime: number;
+
+ if (exactTime && exactTime > 0) {
+ // Use exact time from Trakt if available
+ currentTime = exactTime;
+ if (!duration) {
+ // Calculate duration from exact time and percentage
+ duration = (exactTime / traktProgress) * 100;
+ }
+ } else {
+ // Fallback to percentage-based calculation
+ if (!duration) {
+ // Use reasonable duration estimates as fallback
+ if (type === 'movie') {
+ duration = 6600; // 110 minutes for movies
+ } else if (episodeId) {
+ duration = 2700; // 45 minutes for TV episodes
+ } else {
+ duration = 3600; // 60 minutes default
+ }
+ }
+ currentTime = (traktProgress / 100) * duration;
+ }
+
const newProgress: WatchProgress = {
- currentTime: (traktProgress / 100) * estimatedDuration,
- duration: estimatedDuration,
+ currentTime,
+ duration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, newProgress, episodeId);
+
+ const timeSource = exactTime ? 'exact' : 'calculated';
+ const durationSource = await this.getContentDuration(id, type, episodeId) ? 'stored' : 'estimated';
+ logger.log(`[StorageService] Created progress from Trakt: ${(currentTime/60).toFixed(1)}min (${timeSource}) of ${(duration/60).toFixed(0)}min (${durationSource})`);
} else {
- // Always prioritize Trakt progress when merging
+ // Local progress exists - merge intelligently
const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100;
- if (localProgress.duration > 0) {
- // Use Trakt progress, keeping the existing duration
- const updatedProgress: WatchProgress = {
- ...localProgress,
- currentTime: (traktProgress / 100) * localProgress.duration,
- lastUpdated: traktTimestamp,
- traktSynced: true,
- traktLastSynced: Date.now(),
- traktProgress
- };
- await this.setWatchProgress(id, type, updatedProgress, episodeId);
- logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%)`);
+ // Only proceed if there's a significant difference (>5% or different completion status)
+ const progressDiff = Math.abs(traktProgress - localProgressPercent);
+ if (progressDiff < 5 && traktProgress < 100 && localProgressPercent < 100) {
+ return; // Skip minor updates
+ }
+
+ let currentTime: number;
+ let duration = localProgress.duration;
+
+ if (exactTime && exactTime > 0 && localProgress.duration > 0) {
+ // Use exact time from Trakt, keep local duration
+ currentTime = exactTime;
+
+ // If exact time doesn't match the duration well, recalculate duration
+ const calculatedDuration = (exactTime / traktProgress) * 100;
+ const durationDiff = Math.abs(calculatedDuration - localProgress.duration);
+ if (durationDiff > 300) { // More than 5 minutes difference
+ duration = calculatedDuration;
+ logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration/60).toFixed(0)}min → ${(duration/60).toFixed(0)}min`);
+ }
+ } else if (localProgress.duration > 0) {
+ // Use percentage calculation with local duration
+ currentTime = (traktProgress / 100) * localProgress.duration;
} else {
- // If no duration, estimate it from Trakt progress
- const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600;
+ // No local duration, check stored duration
+ const storedDuration = await this.getContentDuration(id, type, episodeId);
+ duration = storedDuration || 0;
+
+ if (!duration || duration <= 0) {
+ if (exactTime && exactTime > 0) {
+ duration = (exactTime / traktProgress) * 100;
+ currentTime = exactTime;
+ } else {
+ // Final fallback to estimates
+ if (type === 'movie') {
+ duration = 6600; // 110 minutes for movies
+ } else if (episodeId) {
+ duration = 2700; // 45 minutes for TV episodes
+ } else {
+ duration = 3600; // 60 minutes default
+ }
+ currentTime = (traktProgress / 100) * duration;
+ }
+ } else {
+ currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration;
+ }
+ }
+
const updatedProgress: WatchProgress = {
- currentTime: (traktProgress / 100) * estimatedDuration,
- duration: estimatedDuration,
+ ...localProgress,
+ currentTime,
+ duration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
- logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%) - estimated duration`);
+
+ // Only log significant changes
+ if (progressDiff > 10 || traktProgress === 100) {
+ const timeSource = exactTime ? 'exact' : 'calculated';
+ logger.log(`[StorageService] Updated progress: ${(currentTime/60).toFixed(1)}min (${timeSource}) = ${traktProgress}%`);
}
}
} catch (error) {
diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts
index 6415c14..bec8078 100644
--- a/src/services/stremioService.ts
+++ b/src/services/stremioService.ts
@@ -535,13 +535,12 @@ class StremioService {
if (hasMetaSupport) {
try {
- logger.log(`HTTP GET: ${wouldBeUrl} (preferred addon: ${preferredAddon.name})`);
+
const response = await this.retryRequest(async () => {
return await axios.get(wouldBeUrl, { timeout: 10000 });
});
if (response.data && response.data.meta) {
- logger.log(`✅ Metadata fetched successfully from preferred addon: ${wouldBeUrl}`);
return response.data.meta;
}
} catch (error) {
@@ -564,13 +563,12 @@ class StremioService {
for (const baseUrl of cinemetaUrls) {
try {
const url = `${baseUrl}/meta/${type}/${id}.json`;
- logger.log(`HTTP GET: ${url}`);
+
const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 });
});
if (response.data && response.data.meta) {
- logger.log(`✅ Metadata fetched successfully from: ${url}`);
return response.data.meta;
}
} catch (error) {
@@ -619,7 +617,6 @@ class StremioService {
});
if (response.data && response.data.meta) {
- logger.log(`✅ Metadata fetched successfully from: ${url}`);
return response.data.meta;
}
} catch (error) {