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 Logo

-# 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 | -| :----------------------------------------- | :----------------------------------------- | :--------------------------------------- | -| ![Home](src/assets/home.jpg) | ![Discover](src/assets/discover.jpg) | ![Search](src/assets/search.jpg) | -| **Metadata** | **Seasons & Episodes** | **Rating** | -| ![Metadata](src/assets/metadascreen.jpg) | ![Seasons](src/assets/seasonandepisode.jpg)| ![Rating](src/assets/ratingscreen.jpg) | +| Home & Continue Watching | Discover & Browse | Search & Details | +|:-----------------------:|:-----------------:|:----------------:| +| ![Home](src/assets/home.jpg) | ![Discover](src/assets/discover.jpg) | ![Search](src/assets/search.jpg) | +| **Content Details** | **Episodes & Seasons** | **Ratings & Info** | +| ![Metadata](src/assets/metadascreen.jpg) | ![Seasons](src/assets/seasonandepisode.jpg) | ![Rating](src/assets/ratingscreen.jpg) | -## 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 }) => ( { /> ( { { { { { /> + + +