Ios #35

Merged
tapframe merged 30 commits from ios into main 2025-07-28 13:17:13 +00:00
63 changed files with 2590 additions and 2961 deletions

37
.github/workflows/release.yml vendored Normal file
View file

@ -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

5
.gitignore vendored
View file

@ -37,4 +37,7 @@ yarn-error.*
# typescript
*.tsbuildinfo
plan.md
release_announcement.md
release_announcement.md
ALPHA_BUILD_2_ANNOUNCEMENT.md
CHANGELOG.md
.env.local

19
App.tsx
View file

@ -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);

146
README.md
View file

@ -1,52 +1,134 @@
# Nuvio Streaming App
<p align="center">
<img src="assets/titlelogo.png" alt="Nuvio Logo" width="300"/>
</p>
# Nuvio
<p align="center">
A modern streaming app built with React Native and Expo, featuring Stremio addon integration, Trakt synchronization, and a beautiful user interface.
</p>
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!*
<p align="center">
<em>Happy Streaming! 🎬</em>
</p>

View file

@ -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"
}
]
]
}
}

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 720 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1 MiB

View file

@ -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<VideoPlayerProps> = ({
currentTime,
selectedAudioTrack,
selectedTextTrack,
resizeMode = 'contain' as ResizeMode,
onProgress,
onLoad,
onError,
@ -93,7 +95,7 @@ export const AndroidVideoPlayer: React.FC<VideoPlayerProps> = ({
onBuffer={handleBuffer}
onError={handleError}
onEnd={handleEnd}
resizeMode="contain"
resizeMode={resizeMode}
controls={false}
playInBackground={false}
playWhenInactive={false}

View file

@ -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;
module.exports = config;

366
package-lock.json generated
View file

@ -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",

View file

@ -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": {

View file

@ -64,11 +64,11 @@ const CatalogSection = ({ catalog, selectedCategory }: CatalogSectionProps) => {
</View>
<TouchableOpacity
onPress={handleSeeMorePress}
style={styles.seeAllButton}
style={[styles.seeAllButton, { backgroundColor: 'rgba(255,255,255,0.1)' }]}
activeOpacity={0.6}
>
<Text style={[styles.seeAllText, { color: currentTheme.colors.primary }]}>See All</Text>
<MaterialIcons name="arrow-forward-ios" color={currentTheme.colors.primary} size={14} />
<Text style={[styles.seeAllText, { color: currentTheme.colors.textMuted }]}>View All</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} />
</TouchableOpacity>
</View>
@ -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,
},
});

View file

@ -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}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}

View file

@ -36,11 +36,8 @@ const calculatePosterLayout = (screenWidth: number) => {
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 (
<Animated.View
style={styles.catalogContainer}
entering={FadeIn.duration(400).delay(50)}
entering={FadeIn.duration(300).delay(50)}
>
<View style={styles.catalogHeader}>
<View style={styles.titleContainer}>
<Text style={[styles.catalogTitle, { color: currentTheme.colors.highEmphasis }]}>{catalog.name}</Text>
<LinearGradient
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.titleUnderline}
/>
<Text style={[styles.catalogTitle, { color: currentTheme.colors.text }]}>{catalog.name}</Text>
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
</View>
<TouchableOpacity
onPress={() =>
@ -99,10 +91,10 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
addonId: catalog.addon
})
}
style={styles.seeAllButton}
style={styles.viewAllButton}
>
<Text style={[styles.seeAllText, { color: currentTheme.colors.primary }]}>See More</Text>
<MaterialIcons name="arrow-forward" color={currentTheme.colors.primary} size={16} />
<Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} />
</TouchableOpacity>
</View>
@ -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,

View file

@ -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 (
<>
<TouchableOpacity
@ -118,12 +99,14 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: localItem.poster }}
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.poster}
contentFit="cover"
transition={300}
cachePolicy="memory-disk"
recyclingKey={`poster-${localItem.id}`}
cachePolicy="memory"
transition={200}
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
onLoadStart={() => {
setImageLoaded(false);
setImageError(false);
@ -148,7 +131,7 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
</View>
)}
{localItem.inLibrary && (
{item.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
@ -159,12 +142,12 @@ const ContentItem = ({ item: initialItem, onPress }: ContentItemProps) => {
<DropUpMenu
visible={menuVisible}
onClose={handleMenuClose}
item={localItem}
item={item}
onOptionSelect={handleOptionSelect}
/>
</>
);
};
});
const styles = StyleSheet.create({
contentItem: {

View file

@ -77,10 +77,20 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const appState = useRef(AppState.currentState);
const refreshTimerRef = useRef<NodeJS.Timeout | null>(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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((props, re
}
return (
<Animated.View entering={FadeIn.duration(400).delay(250)} style={styles.container}>
<Animated.View entering={FadeIn.duration(300).delay(150)} style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.highEmphasis }]}>Continue Watching</Text>
<LinearGradient
colors={[currentTheme.colors.primary, currentTheme.colors.secondary]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.titleUnderline}
/>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>Continue Watching</Text>
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
</View>
</View>
@ -302,11 +306,14 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
{/* Poster Image */}
<View style={styles.posterContainer}>
<ExpoImage
source={{ uri: item.poster }}
style={styles.widePoster}
source={{ uri: item.poster || 'https://via.placeholder.com/300x450' }}
style={styles.continueWatchingPoster}
contentFit="cover"
cachePolicy="memory"
transition={200}
cachePolicy="memory-disk"
placeholder={{ uri: 'https://via.placeholder.com/300x450' }}
placeholderContentFit="cover"
recyclingKey={item.id}
/>
</View>
@ -386,7 +393,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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,

View file

@ -99,16 +99,35 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
// Preload the image
const preloadImage = async (url: string): Promise<boolean> => {
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 <SkeletonFeatured />;
}
return (
<Animated.View
entering={FadeIn.duration(800).easing(Easing.out(Easing.cubic))}
entering={FadeIn.duration(400).easing(Easing.out(Easing.cubic))}
>
<TouchableOpacity
activeOpacity={0.95}
@ -410,12 +454,10 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
source={{ uri: logoUrl }}
style={styles.featuredLogo as ImageStyle}
contentFit="contain"
cachePolicy="memory-disk"
transition={400}
onError={() => {
console.warn(`[FeaturedContent] Logo failed to load: ${logoUrl}`);
setLogoLoadError(true);
}}
cachePolicy="memory"
transition={300}
recyclingKey={`logo-${featuredContent.id}`}
onError={onLogoLoadError}
/>
</Animated.View>
) : (
@ -473,14 +515,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary }: Feat
<TouchableOpacity
style={styles.infoButton as ViewStyle}
onPress={() => {
if (featuredContent) {
navigation.navigate('Metadata', {
id: featuredContent.id,
type: featuredContent.type
});
}
}}
onPress={handleInfoPress}
activeOpacity={0.7}
>
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />

View file

@ -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 (
<View style={styles.loadingContainer}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
<Text style={[styles.loadingText, { color: currentTheme.colors.textMuted }]}>
Loading this week's episodes...
</Text>
</View>
);
}
@ -196,72 +189,72 @@ export const ThisWeekSection = () => {
return (
<Animated.View
entering={FadeInRight.delay(index * 100).duration(400)}
entering={FadeInRight.delay(index * 50).duration(300)}
style={styles.episodeItemContainer}
>
<TouchableOpacity
style={styles.episodeItem}
style={[
styles.episodeItem,
{
shadowColor: currentTheme.colors.black,
backgroundColor: currentTheme.colors.background,
}
]}
onPress={() => handleEpisodePress(item)}
activeOpacity={0.7}
activeOpacity={0.8}
>
<View style={styles.imageContainer}>
<Image
source={{ uri: imageUrl }}
style={styles.poster}
contentFit="cover"
transition={300}
transition={400}
/>
{/* Enhanced gradient overlay */}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.8)', 'rgba(0,0,0,0.9)']}
colors={[
'transparent',
'transparent',
'rgba(0,0,0,0.4)',
'rgba(0,0,0,0.8)',
'rgba(0,0,0,0.95)'
]}
style={styles.gradient}
>
<View style={styles.badgeContainer}>
<View style={[
styles.badge,
isReleased ? styles.releasedBadge : styles.upcomingBadge,
{ backgroundColor: isReleased ? currentTheme.colors.success + 'CC' : currentTheme.colors.primary + 'CC' }
]}>
<MaterialIcons
name={isReleased ? "check-circle" : "event"}
size={12}
color={currentTheme.colors.white}
/>
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
{isReleased ? 'Released' : 'Coming Soon'}
locations={[0, 0.4, 0.6, 0.8, 1]}
>
{/* Content area */}
<View style={styles.contentArea}>
<Text style={[styles.seriesName, { color: currentTheme.colors.white }]} numberOfLines={1}>
{item.seriesName}
</Text>
<Text style={[styles.episodeTitle, { color: 'rgba(255,255,255,0.9)' }]} numberOfLines={2}>
{item.title}
</Text>
</View>
{item.vote_average > 0 && (
<View style={[styles.ratingBadge, { backgroundColor: 'rgba(0,0,0,0.8)' }]}>
{item.overview && (
<Text style={[styles.overview, { color: 'rgba(255,255,255,0.8)' }]} numberOfLines={2}>
{item.overview}
</Text>
)}
<View style={styles.dateContainer}>
<Text style={[styles.episodeInfo, { color: 'rgba(255,255,255,0.7)' }]}>
S{item.season}:E{item.episode}
</Text>
<MaterialIcons
name="star"
size={12}
name="event"
size={14}
color={currentTheme.colors.primary}
/>
<Text style={[styles.ratingText, { color: currentTheme.colors.primary }]}>
{item.vote_average.toFixed(1)}
<Text style={[styles.releaseDate, { color: currentTheme.colors.primary }]}>
{formattedDate}
</Text>
</View>
)}
</View>
<View style={styles.content}>
<Text style={[styles.seriesName, { color: currentTheme.colors.text }]} numberOfLines={1}>
{item.seriesName}
</Text>
<Text style={[styles.episodeTitle, { color: currentTheme.colors.lightGray }]} numberOfLines={2}>
S{item.season}:E{item.episode} - {item.title}
</Text>
{item.overview ? (
<Text style={[styles.overview, { color: currentTheme.colors.lightGray, opacity: 0.8 }]} numberOfLines={2}>
{item.overview}
</Text>
) : null}
<Text style={[styles.releaseDate, { color: currentTheme.colors.primary }]}>
{formattedDate}
</Text>
</View>
</LinearGradient>
</View>
</TouchableOpacity>
</Animated.View>
);
@ -270,10 +263,13 @@ export const ThisWeekSection = () => {
return (
<Animated.View entering={FadeIn.duration(300)} style={styles.container}>
<View style={styles.header}>
<View style={styles.titleContainer}>
<Text style={[styles.title, { color: currentTheme.colors.text }]}>This Week</Text>
<View style={[styles.titleUnderline, { backgroundColor: currentTheme.colors.primary }]} />
</View>
<TouchableOpacity onPress={handleViewAll} style={styles.viewAllButton}>
<Text style={[styles.viewAllText, { color: currentTheme.colors.lightGray }]}>View All</Text>
<MaterialIcons name="chevron-right" size={18} color={currentTheme.colors.lightGray} />
<Text style={[styles.viewAllText, { color: currentTheme.colors.textMuted }]}>View All</Text>
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.textMuted} />
</TouchableOpacity>
</View>
@ -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={() => <View style={{ width: 16 }} />}
/>
</Animated.View>
);
@ -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,
},
});

View file

@ -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<CastSectionProps> = ({
return (
<Animated.View
style={styles.castSection}
entering={FadeIn.duration(500).delay(300)}
layout={Layout}
entering={FadeIn.duration(300).delay(150)}
>
<View style={styles.sectionHeader}>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>Cast</Text>
@ -56,8 +54,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
keyExtractor={(item) => item.id.toString()}
renderItem={({ item, index }) => (
<Animated.View
entering={FadeIn.duration(500).delay(100 + index * 50)}
layout={Layout}
entering={FadeIn.duration(300).delay(50 + index * 30)}
>
<TouchableOpacity
style={styles.castCard}
@ -75,7 +72,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
transition={200}
/>
) : (
<View style={[styles.castImagePlaceholder, { backgroundColor: currentTheme.colors.cardBackground }]}>
<View style={[styles.castImagePlaceholder, { backgroundColor: currentTheme.colors.darkBackground }]}>
<Text style={[styles.placeholderText, { color: currentTheme.colors.textMuted }]}>
{item.name.split(' ').reduce((prev: string, current: string) => prev + current[0], '').substring(0, 2)}
</Text>

View file

@ -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<HeroSectionProps> = ({
}]
}), []);
// 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[]) => (
<React.Fragment key={`${genreName}-${index}`}>
<Animated.View
key={`${genreName}-${index}`}
entering={FadeIn.duration(400).delay(200 + index * 100)}
style={{ flexDirection: 'row', alignItems: 'center' }}
>
<Text style={[styles.genreText, { color: currentTheme.colors.text }]}>
{genreName}
</Text>
{index < array.length - 1 && (
<Text style={[styles.genreDot, { color: currentTheme.colors.text }]}></Text>
)}
</React.Fragment>
</Animated.View>
));
}, [metadata.genres, currentTheme.colors.text]);

View file

@ -60,7 +60,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
{/* Creator/Director Info */}
<Animated.View
entering={FadeIn.duration(500).delay(200)}
entering={FadeIn.duration(300).delay(100)}
style={styles.creatorContainer}
>
{metadata.directors && metadata.directors.length > 0 && (
@ -81,7 +81,7 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
{metadata.description && (
<Animated.View
style={styles.descriptionContainer}
layout={Layout.duration(300).easing(Easing.inOut(Easing.ease))}
entering={FadeIn.duration(300)}
>
<TouchableOpacity
onPress={() => setIsFullDescriptionOpen(!isFullDescriptionOpen)}

View file

@ -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<SeriesContentProps> = ({
// Add refs for the scroll views
const seasonScrollViewRef = useRef<ScrollView | null>(null);
const episodeScrollViewRef = useRef<ScrollView | null>(null);
const loadEpisodesProgress = async () => {
if (!metadata?.id) return;
@ -69,18 +71,12 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
// 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<SeriesContentProps> = ({
}
});
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<SeriesContentProps> = ({
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<SeriesContentProps> = ({
// 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<SeriesContentProps> = ({
}
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 (
<View style={styles.seasonSelectorWrapper}>
<Text style={[styles.seasonSelectorTitle, { color: currentTheme.colors.highEmphasis }]}>Seasons</Text>
<ScrollView
ref={seasonScrollViewRef}
horizontal
<FlatList
ref={seasonScrollViewRef as React.RefObject<FlatList<any>>}
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<SeriesContentProps> = ({
{selectedSeason === season && (
<View style={[styles.selectedSeasonIndicator, { backgroundColor: currentTheme.colors.primary }]} />
)}
{/* Show episode count badge, including when there are no episodes */}
<View style={[styles.episodeCountBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
<Text style={[styles.episodeCountText, { color: currentTheme.colors.textMuted }]}>
{seasonEpisodes.length} ep{seasonEpisodes.length !== 1 ? 's' : ''}
</Text>
</View>
</View>
<Text
style={[
@ -241,8 +239,9 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
}}
keyExtractor={season => season.toString()}
/>
</View>
);
};
@ -535,74 +534,92 @@ export const SeriesContent: React.FC<SeriesContentProps> = ({
return (
<View style={styles.container}>
<Animated.View
entering={FadeIn.duration(500).delay(100)}
entering={FadeIn.duration(300).delay(50)}
>
{renderSeasonSelector()}
</Animated.View>
<Animated.View
entering={FadeIn.duration(500).delay(200)}
entering={FadeIn.duration(300).delay(100)}
>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis }]}>
{episodes.length} {episodes.length === 1 ? 'Episode' : 'Episodes'}
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
</Text>
{settings.episodeLayoutStyle === 'horizontal' ? (
// Horizontal Layout (Netflix-style)
<ScrollView
ref={episodeScrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
style={styles.episodeList}
contentContainerStyle={styles.episodeListContentHorizontal}
decelerationRate="fast"
snapToInterval={isTablet ? width * 0.4 + 16 : width * 0.85 + 16}
snapToAlignment="start"
>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
style={[
styles.episodeCardWrapperHorizontal,
isTablet && styles.episodeCardWrapperHorizontalTablet
]}
>
{renderHorizontalEpisodeCard(episode)}
</Animated.View>
))}
</ScrollView>
) : (
// Vertical Layout (Traditional)
<ScrollView
style={styles.episodeList}
contentContainerStyle={[
styles.episodeListContentVertical,
isTablet && styles.episodeListContentVerticalTablet
]}
>
{isTablet ? (
<View style={styles.episodeGridVertical}>
{currentSeasonEpisodes.map((episode, index) => (
{/* Show message when no episodes are available for selected season */}
{currentSeasonEpisodes.length === 0 && (
<View style={styles.centeredContainer}>
<MaterialIcons name="schedule" size={48} color={currentTheme.colors.textMuted} />
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>
No episodes available for Season {selectedSeason}
</Text>
<Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}>
Episodes may not be released yet
</Text>
</View>
)}
{/* Only render episode list if there are episodes */}
{currentSeasonEpisodes.length > 0 && (
(settings?.episodeLayoutStyle === 'horizontal') ? (
// Horizontal Layout (Netflix-style)
<FlatList
ref={episodeScrollViewRef as React.RefObject<FlatList<any>>}
data={currentSeasonEpisodes}
renderItem={({ item: episode, index }) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(300).delay(100 + index * 30)}
style={[
styles.episodeCardWrapperHorizontal,
isTablet && styles.episodeCardWrapperHorizontalTablet
]}
>
{renderHorizontalEpisodeCard(episode)}
</Animated.View>
)}
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)
<View
style={[
styles.episodeList,
isTablet ? styles.episodeListContentVerticalTablet : styles.episodeListContentVertical
]}
>
{isTablet ? (
<View style={styles.episodeGridVertical}>
{currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(300).delay(100 + index * 30)}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
entering={FadeIn.duration(300).delay(100 + index * 30)}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))}
</View>
) : (
currentSeasonEpisodes.map((episode, index) => (
<Animated.View
key={episode.id}
entering={FadeIn.duration(400).delay(300 + index * 50)}
>
{renderVerticalEpisodeCard(episode)}
</Animated.View>
))
)}
</ScrollView>
))
)}
</View>
)
)}
</Animated.View>
</View>
@ -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',
},
});

View file

@ -95,7 +95,7 @@ const AndroidVideoPlayer: React.FC = () => {
const [selectedAudioTrack, setSelectedAudioTrack] = useState<number | null>(null);
const [textTracks, setTextTracks] = useState<TextTrack[]>([]);
const [selectedTextTrack, setSelectedTextTrack] = useState<number>(-1);
const [resizeMode, setResizeMode] = useState<ResizeModeType>('stretch');
const [resizeMode, setResizeMode] = useState<ResizeModeType>('contain');
const [buffered, setBuffered] = useState(0);
const [seekTime, setSeekTime] = useState<number | null>(null);
const videoRef = useRef<VideoRef>(null);
@ -106,8 +106,7 @@ const AndroidVideoPlayer: React.FC = () => {
const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [rememberChoice, setRememberChoice] = useState(false);
const [resumePreference, setResumePreference] = useState<string | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(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<string | undefined>(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName);
const isMounted = useRef(true);
const controlsTimeout = useRef<NodeJS.Timeout | null>(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 = () => {
<ResumeOverlay
showResumeOverlay={showResumeOverlay}
resumePosition={resumePosition}
duration={duration}
title={title}
duration={savedDuration || duration}
title={episodeTitle || title}
season={season}
episode={episode}
rememberChoice={rememberChoice}
setRememberChoice={setRememberChoice}
resumePreference={resumePreference}
resetResumePreference={resetResumePreference}
handleResume={handleResume}
handleStartFromBeginning={handleStartFromBeginning}
/>

View file

@ -101,8 +101,7 @@ const VideoPlayer: React.FC = () => {
const [isInitialSeekComplete, setIsInitialSeekComplete] = useState(false);
const [showResumeOverlay, setShowResumeOverlay] = useState(false);
const [resumePosition, setResumePosition] = useState<number | null>(null);
const [rememberChoice, setRememberChoice] = useState(false);
const [resumePreference, setResumePreference] = useState<string | null>(null);
const [savedDuration, setSavedDuration] = useState<number | null>(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<string | undefined>(streamProvider);
const [currentStreamName, setCurrentStreamName] = useState<string | undefined>(streamName);
const isMounted = useRef(true);
const controlsTimeout = useRef<NodeJS.Timeout | null>(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 = () => {
<ResumeOverlay
showResumeOverlay={showResumeOverlay}
resumePosition={resumePosition}
duration={duration}
title={title}
duration={savedDuration || duration}
title={episodeTitle || title}
season={season}
episode={episode}
rememberChoice={rememberChoice}
setRememberChoice={setRememberChoice}
resumePreference={resumePreference}
resetResumePreference={resetResumePreference}
handleResume={handleResume}
handleStartFromBeginning={handleStartFromBeginning}
/>

View file

@ -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<PlayerControlsProps> = ({
currentTime,
duration,
zoomScale,
currentResizeMode,
vlcAudioTracks,
selectedAudioTrack,
availableStreams,
@ -178,7 +180,11 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
<TouchableOpacity style={styles.bottomButton} onPress={cycleAspectRatio}>
<Ionicons name="resize" size={20} color="white" />
<Text style={[styles.bottomButtonText, { fontSize: 14, textAlign: 'center' }]}>
{zoomScale === 1.1 ? 'Fill' : 'Cover'}
{currentResizeMode ?
(currentResizeMode === 'none' ? 'Original' :
currentResizeMode.charAt(0).toUpperCase() + currentResizeMode.slice(1)) :
(zoomScale === 1.1 ? 'Fill' : 'Cover')
}
</Text>
</TouchableOpacity>

View file

@ -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;
}) => (
<Animated.View
entering={FadeInUp.duration(200).delay(delay)}
<View
style={{
backgroundColor: bgColor,
borderColor: `${color}40`,
@ -82,7 +65,7 @@ const AudioBadge = ({
}}>
{text}
</Text>
</Animated.View>
</View>
);
export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
@ -92,30 +75,19 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
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<AudioTrackModalProps> = ({
return (
<Animated.View
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(200)}
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{
position: 'absolute',
top: 0,
@ -139,7 +111,6 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
padding: 16,
}}
>
{/* Backdrop */}
<TouchableOpacity
style={{
position: 'absolute',
@ -152,7 +123,6 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
activeOpacity={1}
/>
{/* Modal Content */}
<Animated.View
style={[
{
@ -170,7 +140,6 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
modalStyle,
]}
>
{/* Glassmorphism Background */}
<BlurView
intensity={100}
tint="dark"
@ -182,7 +151,6 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
height: '100%',
}}
>
{/* Header */}
<LinearGradient
colors={[
'rgba(249, 115, 22, 0.95)',
@ -201,10 +169,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
width: '100%',
}}
>
<Animated.View
entering={FadeInDown.duration(300).delay(100)}
style={{ flex: 1 }}
>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
fontSize: 24,
@ -225,33 +190,30 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
}}>
Choose from {vlcAudioTracks.length} available track{vlcAudioTracks.length !== 1 ? 's' : ''}
</Text>
</Animated.View>
</View>
<Animated.View entering={BounceIn.duration(400).delay(200)}>
<TouchableOpacity
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#fff" />
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#fff" />
</TouchableOpacity>
</LinearGradient>
{/* Content */}
<ScrollView
style={{
maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height
maxHeight: MODAL_MAX_HEIGHT - 100,
backgroundColor: 'transparent',
width: '100%',
}}
@ -264,11 +226,9 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
bounces={false}
>
<View style={styles.modernTrackListContainer}>
{vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track, index) => (
<Animated.View
{vlcAudioTracks.length > 0 ? vlcAudioTracks.map((track) => (
<View
key={track.id}
entering={FadeInDown.duration(300).delay(150 + (index * 50))}
layout={Layout.springify()}
style={{
marginBottom: 16,
width: '100%',
@ -322,8 +282,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
</Text>
{selectedAudioTrack === track.id && (
<Animated.View
entering={BounceIn.duration(300)}
<View
style={{
flexDirection: 'row',
alignItems: 'center',
@ -345,7 +304,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
}}>
ACTIVE
</Text>
</Animated.View>
</View>
)}
</View>
@ -367,7 +326,6 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
icon="language"
delay={50}
/>
)}
</View>
@ -387,20 +345,17 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
? 'rgba(249, 115, 22, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
}}>
{selectedAudioTrack === track.id ? (
<Animated.View entering={ZoomIn.duration(200)}>
<MaterialIcons name="check-circle" size={24} color="#F97316" />
</Animated.View>
) : (
<MaterialIcons name="volume-up" size={24} color="rgba(255,255,255,0.6)" />
)}
<MaterialIcons
name={selectedAudioTrack === track.id ? "check-circle" : "volume-up"}
size={24}
color={selectedAudioTrack === track.id ? "#F97316" : "rgba(255,255,255,0.6)"}
/>
</View>
</View>
</TouchableOpacity>
</Animated.View>
</View>
)) : (
<Animated.View
entering={FadeInDown.duration(300).delay(150)}
<View
style={{
backgroundColor: 'rgba(255, 255, 255, 0.02)',
borderRadius: 20,
@ -431,7 +386,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
}}>
No audio tracks are available for this content.{'\n'}Try a different source or check your connection.
</Text>
</Animated.View>
</View>
)}
</View>
</ScrollView>

View file

@ -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<ResumeOverlayProps> = ({
title,
season,
episode,
rememberChoice,
setRememberChoice,
resumePreference,
resetResumePreference,
handleResume,
handleStartFromBeginning,
}) => {
@ -78,29 +70,6 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
</View>
</View>
{/* Remember choice checkbox */}
<TouchableOpacity
style={styles.rememberChoiceContainer}
onPress={() => setRememberChoice(!rememberChoice)}
activeOpacity={0.7}
>
<View style={styles.checkboxContainer}>
<View style={[styles.checkbox, rememberChoice && styles.checkboxChecked]}>
{rememberChoice && <Ionicons name="checkmark" size={12} color="white" />}
</View>
<Text style={styles.rememberChoiceText}>Remember my choice</Text>
</View>
{resumePreference && (
<TouchableOpacity
onPress={resetResumePreference}
style={styles.resetPreferenceButton}
>
<Text style={styles.resetPreferenceText}>Reset</Text>
</TouchableOpacity>
)}
</TouchableOpacity>
<View style={styles.resumeButtons}>
<TouchableOpacity
style={styles.resumeButton}

View file

@ -1,26 +1,14 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView, ActivityIndicator, 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';
@ -38,7 +26,6 @@ interface SourcesModalProps {
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;
@ -61,8 +48,7 @@ const QualityIndicator = ({ quality }: { quality: string | null }) => {
}
return (
<Animated.View
entering={ZoomIn.duration(200).delay(100)}
<View
style={{
backgroundColor: `${color}20`,
borderColor: `${color}60`,
@ -89,7 +75,7 @@ const QualityIndicator = ({ quality }: { quality: string | null }) => {
}}>
{label}
</Text>
</Animated.View>
</View>
);
};
@ -97,17 +83,14 @@ const StreamMetaBadge = ({
text,
color,
bgColor,
icon,
delay = 0
icon
}: {
text: string;
color: string;
bgColor: string;
icon?: string;
delay?: number;
}) => (
<Animated.View
entering={FadeInUp.duration(200).delay(delay)}
<View
style={{
backgroundColor: bgColor,
borderColor: `${color}40`,
@ -135,7 +118,7 @@ const StreamMetaBadge = ({
}}>
{text}
</Text>
</Animated.View>
</View>
);
const SourcesModal: React.FC<SourcesModalProps> = ({
@ -146,32 +129,33 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
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<SourcesModalProps> = ({
return stream.url === currentStreamUrl;
};
const handleClose = () => {
modalScale.value = withTiming(0.9, { duration: 150 });
modalOpacity.value = withTiming(0, { duration: 150 });
setTimeout(() => setShowSourcesModal(false), 150);
};
return (
<Animated.View
entering={FadeIn.duration(250)}
exiting={FadeOut.duration(200)}
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={{
position: 'absolute',
top: 0,
@ -216,7 +194,6 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
padding: 16,
}}
>
{/* Backdrop */}
<TouchableOpacity
style={{
position: 'absolute',
@ -229,7 +206,6 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
activeOpacity={1}
/>
{/* Modal Content */}
<Animated.View
style={[
{
@ -247,7 +223,6 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
modalStyle,
]}
>
{/* Glassmorphism Background */}
<BlurView
intensity={100}
tint="dark"
@ -259,12 +234,11 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
height: '100%',
}}
>
{/* Header */}
<LinearGradient
colors={[
'rgba(229, 9, 20, 0.95)',
'rgba(176, 6, 16, 0.95)',
'rgba(139, 5, 12, 0.9)'
'rgba(249, 115, 22, 0.95)',
'rgba(234, 88, 12, 0.95)',
'rgba(194, 65, 12, 0.9)'
]}
locations={[0, 0.6, 1]}
style={{
@ -278,10 +252,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
width: '100%',
}}
>
<Animated.View
entering={FadeInDown.duration(300).delay(100)}
style={{ flex: 1 }}
>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
fontSize: 24,
@ -291,7 +262,7 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
}}>
Switch Source
Video Sources
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.85)',
@ -300,35 +271,32 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
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
</Text>
</Animated.View>
</View>
<Animated.View entering={BounceIn.duration(400).delay(200)}>
<TouchableOpacity
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#fff" />
</TouchableOpacity>
</Animated.View>
<TouchableOpacity
style={{
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.15)',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
}}
onPress={handleClose}
activeOpacity={0.7}
>
<MaterialIcons name="close" size={20} color="#fff" />
</TouchableOpacity>
</LinearGradient>
{/* Content */}
<ScrollView
style={{
maxHeight: MODAL_MAX_HEIGHT - 100, // Account for header height
maxHeight: MODAL_MAX_HEIGHT - 100,
backgroundColor: 'transparent',
width: '100%',
}}
@ -340,312 +308,169 @@ const SourcesModal: React.FC<SourcesModalProps> = ({
}}
bounces={false}
>
{sortedProviders.map(([providerId, { streams, addonName }], providerIndex) => (
<Animated.View
key={providerId}
entering={FadeInDown.duration(400).delay(150 + (providerIndex * 80))}
layout={Layout.springify()}
style={{
marginBottom: streams.length > 0 ? 32 : 0,
width: '100%',
}}
>
{/* Provider Header */}
{sortedProviders.map(([providerId, { streams, addonName }]) => (
<View key={providerId} style={{ marginBottom: 24 }}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 20,
paddingBottom: 12,
borderBottomWidth: 1,
borderBottomColor: 'rgba(255, 255, 255, 0.08)',
width: '100%',
marginBottom: 12,
}}>
<LinearGradient
colors={providerId === 'hdrezka' ? ['#00d4aa', '#00a085'] : ['#E50914', '#B00610']}
style={{
width: 12,
height: 12,
borderRadius: 6,
marginRight: 16,
elevation: 3,
shadowColor: providerId === 'hdrezka' ? '#00d4aa' : '#E50914',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 4,
}}
/>
<View style={{ flex: 1 }}>
<Text style={{
color: '#fff',
fontSize: 18,
fontWeight: '700',
letterSpacing: -0.3,
}}>
{addonName}
</Text>
<Text style={{
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 12,
marginTop: 1,
fontWeight: '500',
}}>
Provider {streams.length} stream{streams.length !== 1 ? 's' : ''}
</Text>
</View>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 14,
fontWeight: '600',
letterSpacing: 0.3,
textTransform: 'uppercase',
}}>
{addonName}
</Text>
<View style={{
backgroundColor: 'rgba(255, 255, 255, 0.08)',
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 12,
marginLeft: 8,
}}>
<Text style={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: 11,
fontWeight: '700',
letterSpacing: 0.5,
color: 'rgba(255, 255, 255, 0.5)',
fontSize: 12,
fontWeight: '600',
}}>
{streams.length}
</Text>
</View>
</View>
{/* Streams Grid */}
<View style={{ gap: 16, width: '100%' }}>
{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 (
<Animated.View
key={`${stream.url}-${index}`}
entering={FadeInDown.duration(300).delay((providerIndex * 80) + (index * 40))}
layout={Layout.springify()}
style={{ width: '100%' }}
{streams.map((stream, index) => {
const isSelected = isStreamSelected(stream);
const quality = getQualityFromTitle(stream.title);
return (
<View
key={`${stream.url}-${index}`}
style={{
marginBottom: 12,
width: '100%',
}}
>
<TouchableOpacity
style={{
backgroundColor: isSelected
? 'rgba(249, 115, 22, 0.08)'
: 'rgba(255, 255, 255, 0.03)',
borderRadius: 20,
padding: 20,
borderWidth: 2,
borderColor: isSelected
? 'rgba(249, 115, 22, 0.4)'
: 'rgba(255, 255, 255, 0.08)',
elevation: isSelected ? 8 : 3,
shadowColor: isSelected ? '#F97316' : '#000',
shadowOffset: { width: 0, height: isSelected ? 4 : 2 },
shadowOpacity: isSelected ? 0.3 : 0.1,
shadowRadius: isSelected ? 12 : 6,
width: '100%',
}}
onPress={() => handleStreamSelect(stream)}
activeOpacity={0.85}
disabled={isChangingSource}
>
<TouchableOpacity
style={{
backgroundColor: isSelected
? 'rgba(229, 9, 20, 0.08)'
: 'rgba(255, 255, 255, 0.03)',
borderRadius: 20,
padding: 20,
borderWidth: 2,
borderColor: isSelected
? 'rgba(229, 9, 20, 0.4)'
: 'rgba(255, 255, 255, 0.08)',
elevation: isSelected ? 8 : 3,
shadowColor: isSelected ? '#E50914' : '#000',
shadowOffset: { width: 0, height: isSelected ? 4 : 2 },
shadowOpacity: isSelected ? 0.3 : 0.1,
shadowRadius: isSelected ? 12 : 6,
transform: [{ scale: isSelected ? 1.02 : 1 }],
width: '100%',
}}
onPress={() => handleStreamSelect(stream)}
disabled={isChangingSource || isSelected}
activeOpacity={0.85}
>
<View style={{
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'space-between',
width: '100%',
}}>
{/* Stream Info */}
<View style={{ flex: 1, marginRight: 16 }}>
{/* Title Row */}
<View style={{
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 12,
flexWrap: 'wrap',
gap: 8,
<View style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
}}>
<View style={{ flex: 1, marginRight: 16 }}>
<View style={{
flexDirection: 'row',
alignItems: 'center',
marginBottom: 8,
gap: 12,
}}>
<Text style={{
color: isSelected ? '#fff' : 'rgba(255, 255, 255, 0.95)',
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
flex: 1,
}}>
<Text style={{
color: isSelected ? '#fff' : 'rgba(255, 255, 255, 0.95)',
fontSize: 16,
fontWeight: '700',
letterSpacing: -0.2,
flex: 1,
lineHeight: 22,
}}>
{isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream')}
</Text>
{isSelected && (
<Animated.View
entering={BounceIn.duration(300)}
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(229, 9, 20, 0.25)',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(229, 9, 20, 0.5)',
elevation: 4,
shadowColor: '#E50914',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.3,
shadowRadius: 4,
}}
>
<MaterialIcons name="play-circle-filled" size={12} color="#E50914" />
<Text style={{
color: '#E50914',
fontSize: 10,
fontWeight: '800',
marginLeft: 3,
letterSpacing: 0.3,
}}>
PLAYING
</Text>
</Animated.View>
)}
{isChangingSource && isSelected && (
<Animated.View
entering={FadeIn.duration(200)}
style={{
backgroundColor: 'rgba(229, 9, 20, 0.2)',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 12,
flexDirection: 'row',
alignItems: 'center',
}}
>
<ActivityIndicator size="small" color="#E50914" />
<Text style={{
color: '#E50914',
fontSize: 10,
fontWeight: '600',
marginLeft: 4,
}}>
Switching...
</Text>
</Animated.View>
)}
</View>
{stream.title || 'Untitled Stream'}
</Text>
{/* Subtitle */}
{!isHDRezka && stream.title && stream.title !== stream.name && (
<Text style={{
color: 'rgba(255, 255, 255, 0.65)',
fontSize: 13,
marginBottom: 12,
lineHeight: 18,
fontWeight: '400',
}}>
{stream.title}
</Text>
{isSelected && (
<View
style={{
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(249, 115, 22, 0.25)',
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 14,
borderWidth: 1,
borderColor: 'rgba(249, 115, 22, 0.5)',
}}
>
<MaterialIcons name="play-arrow" size={12} color="#F97316" />
<Text style={{
color: '#F97316',
fontSize: 10,
fontWeight: '800',
marginLeft: 3,
letterSpacing: 0.3,
}}>
PLAYING
</Text>
</View>
)}
{/* Enhanced Meta Info */}
<View style={{
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
alignItems: 'center',
}}>
<QualityIndicator quality={quality} />
{isDolby && (
<StreamMetaBadge
text="DOLBY"
color="#8B5CF6"
bgColor="rgba(139, 92, 246, 0.15)"
icon="hd"
delay={100}
/>
)}
{isHDR && (
<StreamMetaBadge
text="HDR"
color="#F59E0B"
bgColor="rgba(245, 158, 11, 0.15)"
icon="brightness-high"
delay={120}
/>
)}
{size && (
<StreamMetaBadge
text={size}
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
icon="storage"
delay={140}
/>
)}
{isDebrid && (
<StreamMetaBadge
text="DEBRID"
color="#00d4aa"
bgColor="rgba(0, 212, 170, 0.15)"
icon="flash-on"
delay={160}
/>
)}
{isHDRezka && (
<StreamMetaBadge
text="HDREZKA"
color="#00d4aa"
bgColor="rgba(0, 212, 170, 0.15)"
icon="verified"
delay={180}
/>
)}
</View>
</View>
{/* Enhanced Action Icon */}
<View style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: isSelected
? 'rgba(229, 9, 20, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
justifyContent: 'center',
flexDirection: 'row',
flexWrap: 'wrap',
gap: 6,
alignItems: 'center',
borderWidth: 2,
borderColor: isSelected
? 'rgba(229, 9, 20, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
elevation: 4,
shadowColor: isSelected ? '#E50914' : '#fff',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: isSelected ? 0.2 : 0.05,
shadowRadius: 4,
}}>
{isSelected ? (
<Animated.View entering={ZoomIn.duration(200)}>
<MaterialIcons name="check-circle" size={24} color="#E50914" />
</Animated.View>
) : (
<MaterialIcons name="play-arrow" size={24} color="rgba(255,255,255,0.6)" />
)}
{quality && <QualityIndicator quality={quality} />}
<StreamMetaBadge
text={providerId.toUpperCase()}
color="#6B7280"
bgColor="rgba(107, 114, 128, 0.15)"
icon="source"
/>
</View>
</View>
</TouchableOpacity>
</Animated.View>
);
})}
</View>
</Animated.View>
<View style={{
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: isSelected
? 'rgba(249, 115, 22, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: isSelected
? 'rgba(249, 115, 22, 0.3)'
: 'rgba(255, 255, 255, 0.1)',
}}>
{isChangingSource ? (
<ActivityIndicator size="small" color="#F97316" />
) : (
<MaterialIcons
name={isSelected ? "check-circle" : "play-circle-outline"}
size={24}
color={isSelected ? "#F97316" : "rgba(255,255,255,0.6)"}
/>
)}
</View>
</View>
</TouchableOpacity>
</View>
);
})}
</View>
))}
</ScrollView>
</BlurView>

File diff suppressed because it is too large Load diff

View file

@ -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,

View file

@ -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);
}
},
});

View file

@ -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);
}
};

View file

@ -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)

View file

@ -324,22 +324,21 @@ export function useTraktIntegration() {
// Fetch and merge Trakt progress with local progress
const fetchAndMergeTraktProgress = useCallback(async (): Promise<boolean> => {
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<void>[] = [];
// 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]);

View file

@ -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();
});

View file

@ -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,

View file

@ -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<boolean>;
}
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 (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
>
<GestureHandlerRootView style={{ flex: 1 }}>
<Animated.View style={[styles.modalOverlay, overlayStyle]}>
<Pressable style={styles.modalOverlayPressable} onPress={onClose} />
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.menuContainer, menuStyle]}>
<View style={[styles.dragHandle, { backgroundColor: currentTheme.colors.transparentLight }]} />
<View style={[styles.menuHeader, { borderBottomColor: currentTheme.colors.border }]}>
<ExpoImage
source={{ uri: item.poster }}
style={styles.menuPoster}
contentFit="cover"
/>
<View style={styles.menuTitleContainer}>
<Text style={[styles.menuTitle, { color: isDarkMode ? currentTheme.colors.white : currentTheme.colors.black }]}>
{item.name}
</Text>
{item.year && (
<Text style={[styles.menuYear, { color: isDarkMode ? currentTheme.colors.mediumEmphasis : currentTheme.colors.textMutedDark }]}>
{item.year}
</Text>
)}
</View>
</View>
<View style={styles.menuOptions}>
{menuOptions.map((option, index) => (
<TouchableOpacity
key={option.action}
style={[
styles.menuOption,
{ borderBottomColor: isDarkMode ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)' },
index === menuOptions.length - 1 && styles.lastMenuOption
]}
onPress={() => handleOptionSelect(option.action)}
>
<MaterialIcons
name={option.icon as "bookmark" | "check-circle" | "playlist-add" | "share" | "bookmark-border"}
size={24}
color={currentTheme.colors.primary}
/>
<Text style={[
styles.menuOptionText,
{ color: isDarkMode ? currentTheme.colors.white : currentTheme.colors.black }
]}>
{option.label}
</Text>
</TouchableOpacity>
))}
</View>
</Animated.View>
</GestureDetector>
</Animated.View>
</GestureHandlerRootView>
</Modal>
);
});
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 (
<>
<TouchableOpacity
style={styles.contentItem}
activeOpacity={0.7}
onPress={handlePress}
onLongPress={handleLongPress}
delayLongPress={300}
>
<View style={styles.contentItemContainer}>
<ExpoImage
source={{ uri: localItem.poster }}
style={styles.poster}
contentFit="cover"
transition={300}
cachePolicy="memory-disk"
recyclingKey={`poster-${localItem.id}`}
onLoadStart={() => {
setImageLoaded(false);
setImageError(false);
}}
onLoadEnd={() => setImageLoaded(true)}
onError={() => {
setImageError(true);
setImageLoaded(true);
}}
/>
{(!imageLoaded || imageError) && (
<View style={[styles.loadingOverlay, { backgroundColor: currentTheme.colors.elevation2 }]}>
{!imageError ? (
<ActivityIndicator color={currentTheme.colors.primary} size="small" />
) : (
<MaterialIcons name="broken-image" size={24} color={currentTheme.colors.lightGray} />
)}
</View>
)}
{isWatched && (
<View style={styles.watchedIndicator}>
<MaterialIcons name="check-circle" size={22} color={currentTheme.colors.success} />
</View>
)}
{localItem.inLibrary && (
<View style={styles.libraryBadge}>
<MaterialIcons name="bookmark" size={16} color={currentTheme.colors.white} />
</View>
)}
</View>
</TouchableOpacity>
{menuVisible && (
<DropUpMenu
visible={menuVisible}
onClose={handleMenuClose}
item={localItem}
onOptionSelect={handleOptionSelect}
/>
)}
</>
);
}, (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<NodeJS.Timeout | null>(null);
const [hasContinueWatching, setHasContinueWatching] = useState(false);
const [catalogs, setCatalogs] = useState<CatalogContent[]>([]);
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<void>[] = [];
@ -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 (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
);
case 'thisWeek':
return <Animated.View entering={FadeIn.duration(300).delay(100)}><ThisWeekSection /></Animated.View>;
case 'continueWatching':
return <ContinueWatchingSection ref={continueWatchingRef} />;
case 'catalog':
return (
<Animated.View entering={FadeIn.duration(300)}>
<CatalogSection catalog={item.catalog} />
</Animated.View>
);
case 'placeholder':
return (
<View style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
<View style={styles.placeholderPosters}>
{[...Array(4)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</View>
</View>
);
default:
return null;
}
}, [
showHeroSection,
featuredContentSource,
featuredContent,
isSaved,
handleSaveToLibrary,
currentTheme.colors
]);
const ListFooterComponent = useMemo(() => (
<>
{catalogsLoading && catalogs.length < totalCatalogsRef.current && (
<View style={styles.loadingMoreCatalogs}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}>
Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
</Text>
</View>
)}
{!catalogsLoading && catalogs.filter(c => c).length === 0 && (
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)}
</>
), [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
/>
<ScrollView
<FlatList
data={listData}
renderItem={renderListItem}
keyExtractor={item => item.key}
contentContainerStyle={[
styles.scrollContent,
{ paddingTop: Platform.OS === 'ios' ? 100 : 90 }
]}
showsVerticalScrollIndicator={false}
removeClippedSubviews={true}
>
{showHeroSection && (
<FeaturedContent
key={`featured-${showHeroSection}-${featuredContentSource}`}
featuredContent={featuredContent}
isSaved={isSaved}
handleSaveToLibrary={handleSaveToLibrary}
/>
)}
<Animated.View entering={FadeIn.duration(400).delay(150)}>
<ThisWeekSection />
</Animated.View>
<ContinueWatchingSection ref={continueWatchingRef} />
{/* Show catalogs as they load */}
{catalogs.map((catalog, index) => {
if (!catalog) {
// Show placeholder for loading catalog
return (
<View key={`placeholder-${index}`} style={styles.catalogPlaceholder}>
<View style={styles.placeholderHeader}>
<View style={[styles.placeholderTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
</View>
<View style={styles.placeholderPosters}>
{[...Array(4)].map((_, posterIndex) => (
<View
key={posterIndex}
style={[styles.placeholderPoster, { backgroundColor: currentTheme.colors.elevation1 }]}
/>
))}
</View>
</View>
);
}
return (
<Animated.View
key={`${catalog.addon}-${catalog.id}-${index}`}
entering={FadeIn.duration(300)}
>
<CatalogSection catalog={catalog} />
</Animated.View>
);
})}
{/* Show loading indicator for remaining catalogs */}
{catalogsLoading && catalogs.length < totalCatalogsRef.current && (
<View style={styles.loadingMoreCatalogs}>
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
<Text style={[styles.loadingMoreText, { color: currentTheme.colors.textMuted }]}>
Loading more content... ({loadedCatalogCount}/{totalCatalogsRef.current})
</Text>
</View>
)}
{/* Show empty state only if all catalogs are loaded and none are available */}
{!catalogsLoading && catalogs.length === 0 && (
<View style={[styles.emptyCatalog, { backgroundColor: currentTheme.colors.elevation1 }]}>
<MaterialIcons name="movie-filter" size={40} color={currentTheme.colors.textDark} />
<Text style={{ color: currentTheme.colors.textDark, marginTop: 8, fontSize: 16, textAlign: 'center' }}>
No content available
</Text>
<TouchableOpacity
style={[styles.addCatalogButton, { backgroundColor: currentTheme.colors.primary }]}
onPress={() => navigation.navigate('Settings')}
>
<MaterialIcons name="add-circle" size={20} color={currentTheme.colors.white} />
<Text style={[styles.addCatalogButtonText, { color: currentTheme.colors.white }]}>Add Catalogs</Text>
</TouchableOpacity>
</View>
)}
</ScrollView>
ListFooterComponent={ListFooterComponent}
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={10}
/>
</View>
);
}, [
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`);
}
}

View file

@ -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<string | null>(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 (
<TouchableOpacity
style={[styles.itemContainer, { width }]}
onPress={handlePress}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
{posterUrl ? (
<Image
source={{ uri: posterUrl }}
style={styles.poster}
contentFit="cover"
transition={300}
recyclingKey={`trakt-item-${item.id}`}
/>
) : (
<View style={[styles.poster, { backgroundColor: currentTheme.colors.elevation1, justifyContent: 'center', alignItems: 'center' }]}>
<ActivityIndicator color={currentTheme.colors.primary} />
</View>
)}
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.posterGradient}
>
<Text
style={[styles.itemTitle, { color: currentTheme.colors.white }]}
numberOfLines={2}
>
{item.name}
</Text>
{item.lastWatched && (
<Text style={styles.lastWatched}>
Last watched: {item.lastWatched}
</Text>
)}
{item.plays && item.plays > 1 && (
<Text style={styles.playsCount}>
{item.plays} plays
</Text>
)}
</LinearGradient>
<View style={[styles.badgeContainer, { backgroundColor: 'rgba(232,37,75,0.9)' }]}>
<TraktIcon width={12} height={12} style={{ marginRight: 4 }} />
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
{item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
</View>
</View>
</TouchableOpacity>
);
});
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<Map<string, string>>(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<string, any>();
// 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 = () => {
<Text style={[styles.folderTitle, { color: currentTheme.colors.white }]}>
Trakt Collection
</Text>
{traktAuthenticated && traktItems.length > 0 && (
{traktAuthenticated && traktFolders.length > 0 && (
<Text style={styles.folderCount}>
{traktItems.length} items
{traktFolders.length} items
</Text>
)}
{!traktAuthenticated && (
@ -514,59 +465,9 @@ const LibraryScreen = () => {
</TouchableOpacity>
);
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 (
<TouchableOpacity
style={[styles.itemContainer, { width }]}
onPress={() => {
// Navigate using IMDB ID for Trakt items
if (item.imdbId) {
navigation.navigate('Metadata', { id: item.imdbId, type: item.type });
}
}}
activeOpacity={0.7}
>
<View style={[styles.posterContainer, { shadowColor: currentTheme.colors.black }]}>
<Image
source={{ uri: posterUrl }}
style={styles.poster}
contentFit="cover"
transition={300}
/>
<LinearGradient
colors={['transparent', 'rgba(0,0,0,0.85)']}
style={styles.posterGradient}
>
<Text
style={[styles.itemTitle, { color: currentTheme.colors.white }]}
numberOfLines={2}
>
{item.name}
</Text>
<Text style={styles.lastWatched}>
Last watched: {item.lastWatched}
</Text>
{item.plays && item.plays > 1 && (
<Text style={styles.playsCount}>
{item.plays} plays
</Text>
)}
</LinearGradient>
{/* Trakt badge */}
<View style={[styles.badgeContainer, { backgroundColor: 'rgba(232,37,75,0.9)' }]}>
<TraktIcon width={12} height={12} style={{ marginRight: 4 }} />
<Text style={[styles.badgeText, { color: currentTheme.colors.white }]}>
{item.type === 'movie' ? 'Movie' : 'Series'}
</Text>
</View>
</View>
</TouchableOpacity>
);
};
const renderTraktItem = useCallback(({ item }: { item: TraktDisplayItem }) => {
return <TraktItem item={item} width={itemWidth} navigation={navigation} currentTheme={currentTheme} />;
}, [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 (
<ScrollView
style={styles.sectionsContainer}
<FlatList
data={folderItems}
renderItem={({ item }) => 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 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialIcons
name="movie"
size={24}
color={currentTheme.colors.white}
style={styles.sectionIcon}
/>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.white }]}>
Movies ({movies.length})
</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalScrollContent}
>
{movies.map((item) => (
<View key={item.id} style={{ width: itemWidth * 0.8, marginRight: 12 }}>
{renderTraktItem({ item, customWidth: itemWidth * 0.8 })}
</View>
))}
</ScrollView>
</View>
)}
{shows.length > 0 && (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<MaterialIcons
name="live-tv"
size={24}
color={currentTheme.colors.white}
style={styles.sectionIcon}
/>
<Text style={[styles.sectionTitle, { color: currentTheme.colors.white }]}>
TV Shows ({shows.length})
</Text>
</View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.horizontalScrollContent}
>
{shows.map((item) => (
<View key={item.id} style={{ width: itemWidth * 0.8, marginRight: 12 }}>
{renderTraktItem({ item, customWidth: itemWidth * 0.8 })}
</View>
))}
</ScrollView>
</View>
)}
</ScrollView>
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;

View file

@ -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 (
<View style={StyleSheet.absoluteFill}>
{/* Skeleton Loading Screen - with fade out transition */}
{showSkeleton && (
<Animated.View
style={[StyleSheet.absoluteFill, skeletonStyle]}
pointerEvents={metadata ? 'none' : 'auto'}
>
<MetadataLoadingScreen type={metadata?.type === 'movie' ? 'movie' : 'series'} />
</Animated.View>
)}
{/* Main Content - with fade in transition */}
<SafeAreaView
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
edges={['bottom']}
>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
{metadata && (
<Animated.View
style={[StyleSheet.absoluteFill, transitionStyle]}
pointerEvents={metadata ? 'auto' : 'none'}
>
<SafeAreaView
style={[containerStyle, styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}
edges={['bottom']}
<>
{/* Floating Header - Optimized */}
<FloatingHeader
metadata={metadata}
logoLoadError={assetData.logoLoadError}
handleBack={handleBack}
handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
setLogoLoadError={assetData.setLogoLoadError}
/>
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
bounces={false}
overScrollMode="never"
contentContainerStyle={styles.scrollContent}
>
<StatusBar translucent backgroundColor="transparent" barStyle="light-content" animated />
{/* Floating Header - Optimized */}
<FloatingHeader
{/* Hero Section - Optimized */}
<HeroSection
metadata={metadata}
bannerImage={assetData.bannerImage}
loadingBanner={assetData.loadingBanner}
logoLoadError={assetData.logoLoadError}
handleBack={handleBack}
scrollY={animations.scrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
logoOpacity={animations.logoOpacity}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgressData.watchProgress}
type={type as 'movie' | 'series'}
getEpisodeDetails={watchProgressData.getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary}
headerElementsY={animations.headerElementsY}
inLibrary={inLibrary}
headerOpacity={animations.headerOpacity}
headerElementsOpacity={animations.headerElementsOpacity}
safeAreaTop={safeAreaTop}
id={id}
navigation={navigation}
getPlayButtonText={watchProgressData.getPlayButtonText}
setBannerImage={assetData.setBannerImage}
setLogoLoadError={assetData.setLogoLoadError}
/>
<Animated.ScrollView
style={styles.scrollView}
showsVerticalScrollIndicator={false}
onScroll={animations.scrollHandler}
scrollEventThrottle={16}
bounces={false}
overScrollMode="never"
contentContainerStyle={styles.scrollContent}
>
{/* Hero Section - Optimized */}
<HeroSection
{/* Main Content - Optimized */}
<Animated.View style={contentStyle}>
<MetadataDetails
metadata={metadata}
bannerImage={assetData.bannerImage}
loadingBanner={assetData.loadingBanner}
logoLoadError={assetData.logoLoadError}
scrollY={animations.scrollY}
heroHeight={animations.heroHeight}
heroOpacity={animations.heroOpacity}
logoOpacity={animations.logoOpacity}
buttonsOpacity={animations.buttonsOpacity}
buttonsTranslateY={animations.buttonsTranslateY}
watchProgressOpacity={animations.watchProgressOpacity}
watchProgressWidth={animations.watchProgressWidth}
watchProgress={watchProgressData.watchProgress}
imdbId={imdbId}
type={type as 'movie' | 'series'}
getEpisodeDetails={watchProgressData.getEpisodeDetails}
handleShowStreams={handleShowStreams}
handleToggleLibrary={handleToggleLibrary}
inLibrary={inLibrary}
id={id}
navigation={navigation}
getPlayButtonText={watchProgressData.getPlayButtonText}
setBannerImage={assetData.setBannerImage}
setLogoLoadError={assetData.setLogoLoadError}
renderRatings={() => imdbId ? (
<RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} />
) : null}
/>
{/* Main Content - Optimized */}
<Animated.View style={contentStyle}>
<MetadataDetails
metadata={metadata}
imdbId={imdbId}
type={type as 'movie' | 'series'}
renderRatings={() => imdbId ? (
<RatingsSection imdbId={imdbId} type={type === 'series' ? 'show' : 'movie'} />
) : null}
/>
{/* Cast Section with skeleton when loading */}
{loadingCast ? (
<View style={styles.skeletonSection}>
<View style={[styles.skeletonTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<View style={styles.skeletonCastRow}>
{[...Array(4)].map((_, index) => (
<View key={index} style={[styles.skeletonCastItem, { backgroundColor: currentTheme.colors.elevation1 }]} />
))}
</View>
</View>
) : (
<CastSection
cast={cast}
loadingCast={loadingCast}
onSelectCastMember={handleSelectCastMember}
/>
)}
{type === 'movie' && (
{/* Recommendations Section with skeleton when loading */}
{type === 'movie' && (
loadingRecommendations ? (
<View style={styles.skeletonSection}>
<View style={[styles.skeletonTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<View style={styles.skeletonRecommendationsRow}>
{[...Array(3)].map((_, index) => (
<View key={index} style={[styles.skeletonRecommendationItem, { backgroundColor: currentTheme.colors.elevation1 }]} />
))}
</View>
</View>
) : (
<MoreLikeThisSection
recommendations={recommendations}
loadingRecommendations={loadingRecommendations}
/>
)}
)
)}
{type === 'series' ? (
{/* Series/Movie Content with episode skeleton when loading */}
{type === 'series' ? (
(loadingSeasons || Object.keys(groupedEpisodes).length === 0) ? (
<View style={styles.skeletonSection}>
<View style={[styles.skeletonTitle, { backgroundColor: currentTheme.colors.elevation1 }]} />
<View style={styles.skeletonEpisodesContainer}>
{[...Array(6)].map((_, index) => (
<View key={index} style={[styles.skeletonEpisodeItem, { backgroundColor: currentTheme.colors.elevation1 }]} />
))}
</View>
</View>
) : (
<SeriesContent
episodes={episodes}
episodes={Object.values(groupedEpisodes).flat()}
selectedSeason={selectedSeason}
loadingSeasons={loadingSeasons}
onSeasonChange={handleSeasonChangeWithHaptics}
@ -407,15 +409,15 @@ const MetadataScreen: React.FC = () => {
groupedEpisodes={groupedEpisodes}
metadata={metadata || undefined}
/>
) : (
metadata && <MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</SafeAreaView>
</Animated.View>
)
) : (
metadata && <MovieContent metadata={metadata} />
)}
</Animated.View>
</Animated.ScrollView>
</>
)}
</View>
</SafeAreaView>
);
};
@ -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;

View file

@ -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}
>
<View style={[styles.horizontalItemPosterContainer, {
@ -650,7 +649,7 @@ const SearchScreen = () => {
{seriesResults.length > 0 && (
<Animated.View
style={styles.carouselContainer}
entering={FadeIn.duration(300).delay(100)}
entering={FadeIn.duration(300).delay(50)}
>
<Text style={[styles.carouselTitle, { color: currentTheme.colors.white }]}>
TV Shows ({seriesResults.length})

View file

@ -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<SettingItemProps> = ({
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons name={icon} size={20} color={currentTheme.colors.primary} />
<MaterialIcons name={icon} size={Math.min(20, width * 0.05)} color={currentTheme.colors.primary} />
</View>
<View style={styles.settingContent}>
<View style={styles.settingTextContainer}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]}>
<Text style={[styles.settingTitle, { color: currentTheme.colors.highEmphasis }]} numberOfLines={1} adjustsFontSizeToFit>
{title}
</Text>
{description && (
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]}>
<Text style={[styles.settingDescription, { color: currentTheme.colors.mediumEmphasis }]} numberOfLines={2} adjustsFontSizeToFit>
{description}
</Text>
)}
</View>
{badge && (
<View style={[styles.badge, { backgroundColor: currentTheme.colors.primary }]}>
<Text style={styles.badgeText}>{badge}</Text>
<Text style={styles.badgeText} numberOfLines={1} adjustsFontSizeToFit>{String(badge)}</Text>
</View>
)}
</View>
@ -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 }) => (
<Switch
value={value}
@ -370,14 +395,14 @@ const SettingsScreen: React.FC = () => {
/>
<SettingItem
title="Episode Layout"
description={settings.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
description={settings?.episodeLayoutStyle === 'horizontal' ? 'Horizontal Cards' : 'Vertical List'}
icon="view-module"
renderControl={() => (
<View style={styles.selectorContainer}>
<TouchableOpacity
style={[
styles.selectorButton,
settings.episodeLayoutStyle === 'vertical' && {
settings?.episodeLayoutStyle === 'vertical' && {
backgroundColor: currentTheme.colors.primary
}
]}
@ -386,7 +411,7 @@ const SettingsScreen: React.FC = () => {
<Text style={[
styles.selectorText,
{ color: currentTheme.colors.mediumEmphasis },
settings.episodeLayoutStyle === 'vertical' && {
settings?.episodeLayoutStyle === 'vertical' && {
color: currentTheme.colors.white,
fontWeight: '600'
}
@ -395,7 +420,7 @@ const SettingsScreen: React.FC = () => {
<TouchableOpacity
style={[
styles.selectorButton,
settings.episodeLayoutStyle === 'horizontal' && {
settings?.episodeLayoutStyle === 'horizontal' && {
backgroundColor: currentTheme.colors.primary
}
]}
@ -404,7 +429,7 @@ const SettingsScreen: React.FC = () => {
<Text style={[
styles.selectorText,
{ color: currentTheme.colors.mediumEmphasis },
settings.episodeLayoutStyle === 'horizontal' && {
settings?.episodeLayoutStyle === 'horizontal' && {
color: currentTheme.colors.white,
fontWeight: '600'
}
@ -493,12 +518,12 @@ const SettingsScreen: React.FC = () => {
<SettingItem
title="Video Player"
description={Platform.OS === 'ios'
? (settings.preferredPlayer === 'internal'
? (settings?.preferredPlayer === 'internal'
? 'Built-in Player'
: settings.preferredPlayer
: settings?.preferredPlayer
? settings.preferredPlayer.toUpperCase()
: 'Built-in Player')
: (settings.useExternalPlayer ? 'External Player' : 'Built-in Player')
: (settings?.useExternalPlayer ? 'External Player' : 'Built-in Player')
}
icon="play-arrow"
renderControl={ChevronRight}
@ -555,6 +580,28 @@ const SettingsScreen: React.FC = () => {
/>
</SettingsCard>
<SettingsCard title="Debugging">
<View style={{padding: 10}}>
<Button
title="Report a Bug or Suggestion"
onPress={() => Sentry.showFeedbackWidget()}
/>
</View>
</SettingsCard>
{/* MDBList Cache Management */}
{mdblistKeySet && (
<SettingsCard title="MDBList Cache">
<View style={{padding: 10}}>
<Button
title="Clear MDBList Cache"
onPress={handleClearMDBListCache}
color={currentTheme.colors.error}
/>
</View>
</SettingsCard>
)}
<View style={styles.versionContainer}>
<Text style={[styles.versionText, {color: currentTheme.colors.mediumEmphasis}]}>
Version 1.0.0
@ -572,7 +619,7 @@ const styles = StyleSheet.create({
flex: 1,
},
header: {
paddingHorizontal: 20,
paddingHorizontal: Math.max(16, width * 0.05),
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
@ -581,7 +628,7 @@ const styles = StyleSheet.create({
zIndex: 2,
},
headerTitle: {
fontSize: 32,
fontSize: Math.min(32, width * 0.08),
fontWeight: '800',
letterSpacing: 0.3,
},
@ -607,11 +654,11 @@ const styles = StyleSheet.create({
fontSize: 13,
fontWeight: '600',
letterSpacing: 0.8,
marginLeft: 16,
marginLeft: Math.max(12, width * 0.04),
marginBottom: 8,
},
card: {
marginHorizontal: 16,
marginHorizontal: Math.max(12, width * 0.04),
borderRadius: 16,
overflow: 'hidden',
shadowColor: '#000',
@ -625,9 +672,9 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 12,
paddingHorizontal: 16,
paddingHorizontal: Math.max(12, width * 0.04),
borderBottomWidth: 0.5,
minHeight: 58,
minHeight: Math.max(54, width * 0.14),
width: '100%',
},
settingItemBorder: {
@ -650,12 +697,12 @@ const styles = StyleSheet.create({
flex: 1,
},
settingTitle: {
fontSize: 16,
fontSize: Math.min(16, width * 0.042),
fontWeight: '500',
marginBottom: 3,
},
settingDescription: {
fontSize: 14,
fontSize: Math.min(14, width * 0.037),
opacity: 0.8,
},
settingControl: {
@ -697,8 +744,10 @@ const styles = StyleSheet.create({
borderRadius: 8,
overflow: 'hidden',
height: 36,
width: 180,
minWidth: 140,
maxWidth: 180,
marginRight: 8,
alignSelf: 'flex-end',
},
selectorButton: {
flex: 1,
@ -708,7 +757,7 @@ const styles = StyleSheet.create({
backgroundColor: 'rgba(255,255,255,0.08)',
},
selectorText: {
fontSize: 13,
fontSize: Math.min(13, width * 0.034),
fontWeight: '500',
textAlign: 'center',
},

View file

@ -481,7 +481,7 @@ const ShowRatingsScreen = ({ route }: Props) => {
<Animated.View
key={`s${season.season_number}`}
style={styles.ratingColumn}
entering={SlideInRight.delay(season.season_number * 50).duration(200)}
entering={FadeIn.delay(season.season_number * 20).duration(200)}
>
<Text style={[styles.headerText, { color: colors.white }]}>S{season.season_number}</Text>
</Animated.View>
@ -507,7 +507,7 @@ const ShowRatingsScreen = ({ route }: Props) => {
<Animated.View
key={`s${season.season_number}e${episodeIndex + 1}`}
style={styles.ratingColumn}
entering={SlideInRight.delay((season.season_number + episodeIndex) * 10).duration(200)}
entering={FadeIn.delay((season.season_number + episodeIndex) * 5).duration(200)}
>
{season.episodes[episodeIndex] &&
<RatingCell

View file

@ -16,7 +16,7 @@ import {
Linking,
} from 'react-native';
import * as ScreenOrientation from 'expo-screen-orientation';
import { useRoute, useNavigation, useFocusEffect } from '@react-navigation/native';
import { useRoute, useNavigation } from '@react-navigation/native';
import { RouteProp } from '@react-navigation/native';
import { NavigationProp } from '@react-navigation/native';
import { MaterialIcons } from '@expo/vector-icons';
@ -56,14 +56,13 @@ const DOLBY_ICON = 'https://upload.wikimedia.org/wikipedia/en/thumb/3/3f/Dolby_V
const { width, height } = Dimensions.get('window');
// Extracted Components
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, isExiting }: {
const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme }: {
stream: Stream;
onPress: () => void;
index: number;
isLoading?: boolean;
statusMessage?: string;
theme: any;
isExiting?: boolean;
}) => {
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
@ -80,92 +79,13 @@ const StreamCard = ({ stream, onPress, index, isLoading, statusMessage, theme, i
const displayTitle = isHDRezka ? `HDRezka ${stream.title}` : (stream.name || stream.title || 'Unnamed Stream');
const displayAddonName = isHDRezka ? '' : (stream.title || '');
// Animation delay based on index - stagger effect (only if not exiting)
const enterDelay = isExiting ? 0 : 100 + (index * 30);
// Use simple View when exiting to prevent animation conflicts
if (isExiting) {
return (
<View>
<TouchableOpacity
style={[
styles.streamCard,
isLoading && styles.streamCardLoading
]}
onPress={onPress}
disabled={isLoading}
activeOpacity={0.7}
>
<View style={styles.streamDetails}>
<View style={styles.streamNameRow}>
<View style={styles.streamTitleContainer}>
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
{displayTitle}
</Text>
{displayAddonName && displayAddonName !== displayTitle && (
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
{displayAddonName}
</Text>
)}
</View>
{/* Show loading indicator if stream is loading */}
{isLoading && (
<View style={styles.loadingIndicator}>
<ActivityIndicator size="small" color={theme.colors.primary} />
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
{statusMessage || "Loading..."}
</Text>
</View>
)}
</View>
<View style={styles.streamMetaRow}>
{quality && quality >= "720" && (
<QualityBadge type="HD" />
)}
{isDolby && (
<QualityBadge type="VISION" />
)}
{size && (
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>{size}</Text>
</View>
)}
{isDebrid && (
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
</View>
)}
{/* Special badge for HDRezka streams */}
{isHDRezka && (
<View style={[styles.chip, { backgroundColor: theme.colors.accent }]}>
<Text style={[styles.chipText, { color: theme.colors.white }]}>HDREZKA</Text>
</View>
)}
</View>
</View>
<View style={styles.streamAction}>
<MaterialIcons
name="play-arrow"
size={24}
color={theme.colors.primary}
/>
</View>
</TouchableOpacity>
</View>
);
}
// Animation delay based on index - stagger effect
const enterDelay = 100 + (index * 30);
return (
<Animated.View
entering={FadeInDown.duration(200).delay(enterDelay)}
layout={Layout.duration(200)}
exiting={FadeOut.duration(150)}
>
<TouchableOpacity
style={[
@ -268,7 +188,7 @@ const ProviderFilter = memo(({
const renderItem = useCallback(({ item, index }: { item: { id: string; name: string }; index: number }) => (
<Animated.View
entering={FadeIn.duration(300).delay(100 + index * 40)}
layout={Layout.springify()}
exiting={FadeOut.duration(150)}
>
<TouchableOpacity
key={item.id}
@ -328,11 +248,7 @@ export const StreamsScreen = () => {
const loadStartTimeRef = useRef(0);
const hasDoneInitialLoadRef = useRef(false);
// Add state for handling orientation transition
const [isTransitioning, setIsTransitioning] = useState(false);
// Add state to prevent animation conflicts during exit
const [isExiting, setIsExiting] = useState(false);
// Add timing logs
const [loadStartTime, setLoadStartTime] = useState(0);
@ -506,9 +422,6 @@ export const StreamsScreen = () => {
// Memoize handlers
const handleBack = useCallback(() => {
// Set exit state to prevent animation conflicts and hide content immediately
setIsExiting(true);
const cleanup = () => {
headerOpacity.value = withTiming(0, { duration: 100 });
heroScale.value = withTiming(0.95, { duration: 100 });
@ -1065,10 +978,9 @@ export const StreamsScreen = () => {
isLoading={isLoading}
statusMessage={undefined}
theme={currentTheme}
isExiting={isExiting}
/>
);
}, [handleStreamPress, currentTheme, isExiting]);
}, [handleStreamPress, currentTheme]);
const renderSectionHeader = useCallback(({ section }: { section: { title: string; addonId: string } }) => {
const isProviderLoading = loadingProviders[section.addonId];
@ -1076,7 +988,7 @@ export const StreamsScreen = () => {
return (
<Animated.View
entering={FadeIn.duration(400)}
layout={Layout.springify()}
exiting={FadeOut.duration(150)}
style={styles.sectionHeaderContainer}
>
<View style={styles.sectionHeaderContent}>
@ -1101,43 +1013,7 @@ export const StreamsScreen = () => {
};
}, []);
// Add orientation handling when screen comes into focus
useFocusEffect(
useCallback(() => {
// Set transitioning state to mask any visual glitches
setIsTransitioning(true);
// Immediately lock to portrait when returning to this screen
const lockToPortrait = async () => {
try {
await ScreenOrientation.lockAsync(ScreenOrientation.OrientationLock.PORTRAIT_UP);
// Small delay then unlock to allow natural portrait orientation
setTimeout(async () => {
try {
await ScreenOrientation.unlockAsync();
// Clear transition state after orientation is handled
setTimeout(() => {
setIsTransitioning(false);
}, 100);
} catch (error) {
logger.error('[StreamsScreen] Error unlocking orientation:', error);
setIsTransitioning(false);
}
}, 200);
} catch (error) {
logger.error('[StreamsScreen] Error locking to portrait:', error);
setIsTransitioning(false);
}
};
lockToPortrait();
return () => {
// Cleanup when screen loses focus
setIsTransitioning(false);
};
}, [])
);
return (
<View style={styles.container}>
@ -1147,17 +1023,6 @@ export const StreamsScreen = () => {
barStyle="light-content"
/>
{/* Instant overlay when exiting to prevent glitches */}
{isExiting && (
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.darkBackground, zIndex: 100 }]} />
)}
{/* Transition overlay to mask orientation changes */}
{isTransitioning && (
<View style={styles.transitionOverlay}>
<ActivityIndicator size="small" color={colors.primary} />
</View>
)}
<Animated.View
entering={FadeIn.duration(300)}
@ -1196,11 +1061,11 @@ export const StreamsScreen = () => {
{type === 'series' && currentEpisode && (
<Animated.View style={[styles.streamsHeroContainer, heroStyle]}>
<Animated.View
entering={FadeIn.duration(600).springify()}
entering={FadeIn.duration(300)}
style={StyleSheet.absoluteFill}
>
<Animated.View
entering={FadeIn.duration(800).delay(100).springify().withInitialValues({
entering={FadeIn.duration(400).delay(100).withInitialValues({
transform: [{ scale: 1.05 }]
})}
style={StyleSheet.absoluteFill}

View file

@ -13,7 +13,12 @@ interface WatchProgress {
class StorageService {
private static instance: StorageService;
private readonly WATCH_PROGRESS_KEY = '@watch_progress:';
private readonly CONTENT_DURATION_KEY = '@content_duration:';
private watchProgressSubscribers: (() => void)[] = [];
private notificationDebounceTimer: NodeJS.Timeout | null = null;
private lastNotificationTime: number = 0;
private readonly NOTIFICATION_DEBOUNCE_MS = 1000; // 1 second debounce
private readonly MIN_NOTIFICATION_INTERVAL = 500; // Minimum 500ms between notifications
private constructor() {}
@ -25,7 +30,65 @@ class StorageService {
}
private getWatchProgressKey(id: string, type: string, episodeId?: string): string {
return this.WATCH_PROGRESS_KEY + `${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
return `${this.WATCH_PROGRESS_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
}
private getContentDurationKey(id: string, type: string, episodeId?: string): string {
return `${this.CONTENT_DURATION_KEY}${type}:${id}${episodeId ? `:${episodeId}` : ''}`;
}
public async setContentDuration(
id: string,
type: string,
duration: number,
episodeId?: string
): Promise<void> {
try {
const key = this.getContentDurationKey(id, type, episodeId);
await AsyncStorage.setItem(key, duration.toString());
} catch (error) {
logger.error('Error setting content duration:', error);
}
}
public async getContentDuration(
id: string,
type: string,
episodeId?: string
): Promise<number | null> {
try {
const key = this.getContentDurationKey(id, type, episodeId);
const data = await AsyncStorage.getItem(key);
return data ? parseFloat(data) : null;
} catch (error) {
logger.error('Error getting content duration:', error);
return null;
}
}
public async updateProgressDuration(
id: string,
type: string,
newDuration: number,
episodeId?: string
): Promise<void> {
try {
const existingProgress = await this.getWatchProgress(id, type, episodeId);
if (existingProgress && Math.abs(existingProgress.duration - newDuration) > 60) {
// Calculate the new current time to maintain the same percentage
const progressPercent = (existingProgress.currentTime / existingProgress.duration) * 100;
const updatedProgress: WatchProgress = {
...existingProgress,
currentTime: (progressPercent / 100) * newDuration,
duration: newDuration,
lastUpdated: Date.now()
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
logger.log(`[StorageService] Updated progress duration from ${(existingProgress.duration/60).toFixed(0)}min to ${(newDuration/60).toFixed(0)}min`);
}
} catch (error) {
logger.error('Error updating progress duration:', error);
}
}
public async setWatchProgress(
@ -36,16 +99,56 @@ class StorageService {
): Promise<void> {
try {
const key = this.getWatchProgressKey(id, type, episodeId);
// Check if progress has actually changed significantly
const existingProgress = await this.getWatchProgress(id, type, episodeId);
if (existingProgress) {
const timeDiff = Math.abs(progress.currentTime - existingProgress.currentTime);
const durationDiff = Math.abs(progress.duration - existingProgress.duration);
// Only update if there's a significant change (>5 seconds or duration change)
if (timeDiff < 5 && durationDiff < 1) {
return; // Skip update for minor changes
}
}
await AsyncStorage.setItem(key, JSON.stringify(progress));
// Notify subscribers
this.notifyWatchProgressSubscribers();
// Use debounced notification to reduce spam
this.debouncedNotifySubscribers();
} catch (error) {
logger.error('Error saving watch progress:', error);
logger.error('Error setting watch progress:', error);
}
}
private debouncedNotifySubscribers(): void {
const now = Date.now();
// Clear existing timer
if (this.notificationDebounceTimer) {
clearTimeout(this.notificationDebounceTimer);
}
// If we notified recently, debounce longer
const timeSinceLastNotification = now - this.lastNotificationTime;
if (timeSinceLastNotification < this.MIN_NOTIFICATION_INTERVAL) {
this.notificationDebounceTimer = setTimeout(() => {
this.notifyWatchProgressSubscribers();
}, this.NOTIFICATION_DEBOUNCE_MS);
} else {
// Notify immediately if enough time has passed
this.notifyWatchProgressSubscribers();
}
}
private notifyWatchProgressSubscribers(): void {
this.lastNotificationTime = Date.now();
this.notificationDebounceTimer = null;
// Only notify if we have subscribers
if (this.watchProgressSubscribers.length > 0) {
this.watchProgressSubscribers.forEach(callback => callback());
}
}
public subscribeToWatchProgressUpdates(callback: () => void): () => void {
@ -115,7 +218,8 @@ class StorageService {
type: string,
traktSynced: boolean,
traktProgress?: number,
episodeId?: string
episodeId?: string,
exactTime?: number
): Promise<void> {
try {
const existingProgress = await this.getWatchProgress(id, type, episodeId);
@ -124,7 +228,9 @@ class StorageService {
...existingProgress,
traktSynced,
traktLastSynced: traktSynced ? Date.now() : existingProgress.traktLastSynced,
traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress
traktProgress: traktProgress !== undefined ? traktProgress : existingProgress.traktProgress,
// Update current time with exact time if provided
...(exactTime && exactTime > 0 && { currentTime: exactTime })
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
}
@ -182,60 +288,127 @@ class StorageService {
}
/**
* Merge Trakt progress with local progress
* Merge Trakt progress with local progress using exact time when available
*/
public async mergeWithTraktProgress(
id: string,
type: string,
traktProgress: number,
traktPausedAt: string,
episodeId?: string
episodeId?: string,
exactTime?: number // Optional exact time in seconds from Trakt scrobble data
): Promise<void> {
try {
const localProgress = await this.getWatchProgress(id, type, episodeId);
const traktTimestamp = new Date(traktPausedAt).getTime();
if (!localProgress) {
// No local progress, use Trakt data (estimate duration)
const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600; // Default 1 hour
// No local progress - use stored duration or estimate
let duration = await this.getContentDuration(id, type, episodeId);
let currentTime: number;
if (exactTime && exactTime > 0) {
// Use exact time from Trakt if available
currentTime = exactTime;
if (!duration) {
// Calculate duration from exact time and percentage
duration = (exactTime / traktProgress) * 100;
}
} else {
// Fallback to percentage-based calculation
if (!duration) {
// Use reasonable duration estimates as fallback
if (type === 'movie') {
duration = 6600; // 110 minutes for movies
} else if (episodeId) {
duration = 2700; // 45 minutes for TV episodes
} else {
duration = 3600; // 60 minutes default
}
}
currentTime = (traktProgress / 100) * duration;
}
const newProgress: WatchProgress = {
currentTime: (traktProgress / 100) * estimatedDuration,
duration: estimatedDuration,
currentTime,
duration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, newProgress, episodeId);
const timeSource = exactTime ? 'exact' : 'calculated';
const durationSource = await this.getContentDuration(id, type, episodeId) ? 'stored' : 'estimated';
logger.log(`[StorageService] Created progress from Trakt: ${(currentTime/60).toFixed(1)}min (${timeSource}) of ${(duration/60).toFixed(0)}min (${durationSource})`);
} else {
// Always prioritize Trakt progress when merging
// Local progress exists - merge intelligently
const localProgressPercent = (localProgress.currentTime / localProgress.duration) * 100;
if (localProgress.duration > 0) {
// Use Trakt progress, keeping the existing duration
const updatedProgress: WatchProgress = {
...localProgress,
currentTime: (traktProgress / 100) * localProgress.duration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%)`);
// Only proceed if there's a significant difference (>5% or different completion status)
const progressDiff = Math.abs(traktProgress - localProgressPercent);
if (progressDiff < 5 && traktProgress < 100 && localProgressPercent < 100) {
return; // Skip minor updates
}
let currentTime: number;
let duration = localProgress.duration;
if (exactTime && exactTime > 0 && localProgress.duration > 0) {
// Use exact time from Trakt, keep local duration
currentTime = exactTime;
// If exact time doesn't match the duration well, recalculate duration
const calculatedDuration = (exactTime / traktProgress) * 100;
const durationDiff = Math.abs(calculatedDuration - localProgress.duration);
if (durationDiff > 300) { // More than 5 minutes difference
duration = calculatedDuration;
logger.log(`[StorageService] Updated duration based on exact time: ${(localProgress.duration/60).toFixed(0)}min → ${(duration/60).toFixed(0)}min`);
}
} else if (localProgress.duration > 0) {
// Use percentage calculation with local duration
currentTime = (traktProgress / 100) * localProgress.duration;
} else {
// If no duration, estimate it from Trakt progress
const estimatedDuration = traktProgress > 0 ? (100 / traktProgress) * 100 : 3600;
// No local duration, check stored duration
const storedDuration = await this.getContentDuration(id, type, episodeId);
duration = storedDuration || 0;
if (!duration || duration <= 0) {
if (exactTime && exactTime > 0) {
duration = (exactTime / traktProgress) * 100;
currentTime = exactTime;
} else {
// Final fallback to estimates
if (type === 'movie') {
duration = 6600; // 110 minutes for movies
} else if (episodeId) {
duration = 2700; // 45 minutes for TV episodes
} else {
duration = 3600; // 60 minutes default
}
currentTime = (traktProgress / 100) * duration;
}
} else {
currentTime = exactTime && exactTime > 0 ? exactTime : (traktProgress / 100) * duration;
}
}
const updatedProgress: WatchProgress = {
currentTime: (traktProgress / 100) * estimatedDuration,
duration: estimatedDuration,
...localProgress,
currentTime,
duration,
lastUpdated: traktTimestamp,
traktSynced: true,
traktLastSynced: Date.now(),
traktProgress
};
await this.setWatchProgress(id, type, updatedProgress, episodeId);
logger.log(`[StorageService] Replaced local progress (${localProgressPercent.toFixed(1)}%) with Trakt progress (${traktProgress}%) - estimated duration`);
// Only log significant changes
if (progressDiff > 10 || traktProgress === 100) {
const timeSource = exactTime ? 'exact' : 'calculated';
logger.log(`[StorageService] Updated progress: ${(currentTime/60).toFixed(1)}min (${timeSource}) = ${traktProgress}%`);
}
}
} catch (error) {

View file

@ -535,13 +535,12 @@ class StremioService {
if (hasMetaSupport) {
try {
logger.log(`HTTP GET: ${wouldBeUrl} (preferred addon: ${preferredAddon.name})`);
const response = await this.retryRequest(async () => {
return await axios.get(wouldBeUrl, { timeout: 10000 });
});
if (response.data && response.data.meta) {
logger.log(`✅ Metadata fetched successfully from preferred addon: ${wouldBeUrl}`);
return response.data.meta;
}
} catch (error) {
@ -564,13 +563,12 @@ class StremioService {
for (const baseUrl of cinemetaUrls) {
try {
const url = `${baseUrl}/meta/${type}/${id}.json`;
logger.log(`HTTP GET: ${url}`);
const response = await this.retryRequest(async () => {
return await axios.get(url, { timeout: 10000 });
});
if (response.data && response.data.meta) {
logger.log(`✅ Metadata fetched successfully from: ${url}`);
return response.data.meta;
}
} catch (error) {
@ -619,7 +617,6 @@ class StremioService {
});
if (response.data && response.data.meta) {
logger.log(`✅ Metadata fetched successfully from: ${url}`);
return response.data.meta;
}
} catch (error) {