Ios #35
37
.github/workflows/release.yml
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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 |
|
||||
| :----------------------------------------- | :----------------------------------------- | :--------------------------------------- |
|
||||
|  |  |  |
|
||||
| **Metadata** | **Seasons & Episodes** | **Rating** |
|
||||
|  | |  |
|
||||
| Home & Continue Watching | Discover & Browse | Search & Details |
|
||||
|:-----------------------:|:-----------------:|:----------------:|
|
||||
|  |  |  |
|
||||
| **Content Details** | **Episodes & Seasons** | **Ratings & Info** |
|
||||
|  |  |  |
|
||||
|
||||
## Development
|
||||
## 🚀 Getting Started
|
||||
|
||||
1. You'll need Node.js, npm/yarn, and the Expo Go app (or native build tools like Android Studio/Xcode).
|
||||
2. `git clone https://github.com/nayifleo1/NuvioExpo.git`
|
||||
3. `cd NuvioExpo`
|
||||
4. `npm install` or `yarn install`
|
||||
5. `npx expo start` (Easiest way: Scan QR code with Expo Go app)
|
||||
* Or `npx expo run:android` / `npx expo run:ios` for native builds.
|
||||
### Prerequisites
|
||||
- Node.js 18 or newer
|
||||
- npm or yarn
|
||||
- Expo Go app (for development)
|
||||
- Android Studio (for Android builds)
|
||||
- Xcode (for iOS builds)
|
||||
|
||||
## Found a bug or have an idea?
|
||||
### Development Setup
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/tapframe/NuvioStreaming.git
|
||||
cd NuvioStreaming
|
||||
```
|
||||
|
||||
Great! Please open an [Issue on GitHub](https://github.com/nayifleo1/NuvioExpo/issues). Describe the problem or your suggestion.
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
# or
|
||||
yarn install
|
||||
```
|
||||
|
||||
## Contribution
|
||||
3. Start the development server:
|
||||
```bash
|
||||
npx expo start
|
||||
```
|
||||
|
||||
Contributions are welcome! Fork the repository, make your changes, and submit a Pull Request.
|
||||
4. Run on device/simulator:
|
||||
- Scan QR code with Expo Go app
|
||||
- Or run native builds:
|
||||
```bash
|
||||
npx expo run:android
|
||||
# or
|
||||
npx expo run:ios
|
||||
```
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Here's how you can help:
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch
|
||||
3. Commit your changes
|
||||
4. Push to the branch
|
||||
5. Open a Pull Request
|
||||
|
||||
## 🐛 Bug Reports & Feature Requests
|
||||
|
||||
Found a bug or have an idea? Please open an [issue](https://github.com/tapframe/NuvioStreaming/issues) with:
|
||||
- Clear description of the problem/suggestion
|
||||
- Steps to reproduce (for bugs)
|
||||
- Expected behavior
|
||||
- Screenshots if applicable
|
||||
|
||||
## 📝 Changelog
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for release history and changes.
|
||||
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Built with help from the amazing communities behind:
|
||||
- React Native & Expo
|
||||
- TMDB API
|
||||
- Trakt.tv
|
||||
- Stremio
|
||||
|
||||
---
|
||||
|
||||
Built with help from the communities and tools behind React Native, Expo, TMDB, Trakt, and the Stremio addon system.
|
||||
|
||||
*Happy Streaming!*
|
||||
<p align="center">
|
||||
<em>Happy Streaming! 🎬</em>
|
||||
</p>
|
||||
24
app.json
|
|
@ -24,7 +24,9 @@
|
|||
],
|
||||
"NSLocalNetworkUsageDescription": "App uses the local network to discover and connect to devices.",
|
||||
"NSMicrophoneUsageDescription": "This app does not require microphone access.",
|
||||
"UIBackgroundModes": ["audio"],
|
||||
"UIBackgroundModes": [
|
||||
"audio"
|
||||
],
|
||||
"LSSupportsOpeningDocumentsInPlace": true,
|
||||
"UIFileSharingEnabled": true
|
||||
},
|
||||
|
|
@ -34,8 +36,12 @@
|
|||
{
|
||||
"name": "Matroska Video",
|
||||
"role": "viewer",
|
||||
"utis": ["org.matroska.mkv"],
|
||||
"extensions": ["mkv"]
|
||||
"utis": [
|
||||
"org.matroska.mkv"
|
||||
],
|
||||
"extensions": [
|
||||
"mkv"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -65,6 +71,16 @@
|
|||
"projectId": "909107b8-fe61-45ce-b02f-b02510d306a6"
|
||||
}
|
||||
},
|
||||
"owner": "nayifleo"
|
||||
"owner": "nayifleo",
|
||||
"plugins": [
|
||||
[
|
||||
"@sentry/react-native/expo",
|
||||
{
|
||||
"url": "https://sentry.io/",
|
||||
"project": "react-native",
|
||||
"organization": "tapframe"
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 26 MiB |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1 MiB |
|
Before Width: | Height: | Size: 5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 720 B |
|
Before Width: | Height: | Size: 420 B |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1 KiB |
|
Before Width: | Height: | Size: 429 B |
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 1.6 MiB After Width: | Height: | Size: 1 MiB |
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)']}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||