mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
Compare commits
148 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bf3a344f3 | ||
|
|
14e8e90ee3 | ||
|
|
d52c202518 | ||
|
|
c728f4ea8d | ||
|
|
c20c2713d0 | ||
|
|
d398c73214 | ||
|
|
9e6b455323 | ||
|
|
5a2271c64e | ||
|
|
eb6fcf639f | ||
|
|
a85cc93026 | ||
|
|
56fd18a8e9 | ||
|
|
82d0ebb714 | ||
|
|
df5772d40b | ||
|
|
3030d5961d | ||
|
|
6974768457 | ||
|
|
d31cd2fcdc | ||
|
|
b916bdbcca | ||
|
|
67d53cf5ce | ||
|
|
175d6a173e | ||
|
|
b7140e15a5 | ||
|
|
76310dae1b | ||
|
|
01a041aebf | ||
|
|
031c0c8772 | ||
|
|
fd1e303403 | ||
|
|
45b63cb33f | ||
|
|
c9dfecb68c | ||
|
|
aa6406eae0 | ||
|
|
26e4c6db88 | ||
|
|
2439bd1cd8 | ||
|
|
1fdcdd02bf | ||
|
|
bb94a49662 | ||
|
|
2ebec55bbc | ||
|
|
5fe23c7ad1 | ||
|
|
b6a5c108de | ||
|
|
83ce7cf44d | ||
|
|
28632d192f | ||
|
|
2a265bf716 | ||
|
|
b06800860c | ||
|
|
75702d823f | ||
|
|
f865b737e6 | ||
|
|
2169354f0d | ||
|
|
8dc1217c36 | ||
|
|
0a1511f09f | ||
|
|
73030f150a | ||
|
|
a1f4702647 | ||
|
|
2ddfe63fa4 | ||
|
|
79ffe92864 | ||
|
|
e5178c9414 | ||
|
|
f779febc32 | ||
|
|
5afd3d6b08 | ||
|
|
6005574019 | ||
|
|
645dcecaca | ||
|
|
1686138499 | ||
|
|
cd1ed27f1e | ||
|
|
3b210b06d5 | ||
|
|
0f9c1b03a5 | ||
|
|
217244c367 | ||
|
|
852868cf89 | ||
|
|
a52a2ccc31 | ||
|
|
210ae6b0ee | ||
|
|
c6e55429e4 | ||
|
|
07b27dd485 | ||
|
|
5166dbd446 | ||
|
|
0722923a78 | ||
|
|
a85698b009 | ||
|
|
9b2b619121 | ||
|
|
ac097f6513 | ||
|
|
a383289457 | ||
|
|
e76b44cff1 | ||
|
|
0f9f6bbe5d | ||
|
|
c48670fa74 | ||
|
|
c530619039 | ||
|
|
5e221e7e97 | ||
|
|
65909a5f2e | ||
|
|
bbdd4c0504 | ||
|
|
9924d26ff6 | ||
|
|
b10aab6057 | ||
|
|
ccad48fbb4 | ||
|
|
91e9549ec6 | ||
|
|
066bf6f15d | ||
|
|
56df30a4da | ||
|
|
27ce25f5c5 | ||
|
|
334d0b1863 | ||
|
|
437645d5fd | ||
|
|
280536e93c | ||
|
|
611b37c847 | ||
|
|
5e3198c9c6 | ||
|
|
6ef047db3c | ||
|
|
cdab715463 | ||
|
|
96ac361c8e | ||
|
|
ed4950cd1f | ||
|
|
afddf4bf2d | ||
|
|
9c37ad8b94 | ||
|
|
9877f513e2 | ||
|
|
f4b5082827 | ||
|
|
1627928fb2 | ||
|
|
6ff5aa9e02 | ||
|
|
20601cd7ba | ||
|
|
2d6b4afa2d | ||
|
|
4ce14ec4cc | ||
|
|
0f1d736716 | ||
|
|
edeb6ebe3c | ||
|
|
ab7f008bbb | ||
|
|
1e60af1ffb | ||
|
|
4dd1fca0a7 | ||
|
|
81b97da75e | ||
|
|
6a7d6a1458 | ||
|
|
2835ede747 | ||
|
|
59f77ac831 | ||
|
|
3e63efc178 | ||
|
|
4aa22cc1c3 | ||
|
|
4fdda9a184 | ||
|
|
5bd9f41104 | ||
|
|
486ea63a8a | ||
|
|
0919a40c75 | ||
|
|
3de2fb4809 | ||
|
|
3d5a9ebf42 | ||
|
|
be3e111e63 | ||
|
|
8a0bed7238 | ||
|
|
d2556b6c36 | ||
|
|
506ca4f95c | ||
|
|
5b2c57d5c7 | ||
|
|
7c2b1ac73d | ||
|
|
a55669d16f | ||
|
|
656062bc25 | ||
|
|
b42401a909 | ||
|
|
2c6c110265 | ||
|
|
e7b3458f34 | ||
|
|
e0ad949141 | ||
|
|
28d27128d1 | ||
|
|
ebbe715581 | ||
|
|
af138944b5 | ||
|
|
4603d1dc2a | ||
|
|
e323906083 | ||
|
|
6cb115ed74 | ||
|
|
0149068126 | ||
|
|
7894258a26 | ||
|
|
775242255a | ||
|
|
faa4f341e6 | ||
|
|
a079649563 | ||
|
|
63359532a3 | ||
|
|
5d42a828d2 | ||
|
|
2da03d4931 | ||
|
|
4235e327fc | ||
|
|
0d3454cd24 | ||
|
|
5850650713 | ||
|
|
47f3cb4b71 | ||
|
|
a6a0a8b1b1 |
169 changed files with 24108 additions and 9312 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
# dependencies
|
||||
node_modules/
|
||||
# Un-ignore specific react-native-video source files we patch
|
||||
!node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
|
||||
!node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
|
||||
!node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
|
||||
|
||||
# Expo
|
||||
|
|
|
|||
54
App.tsx
54
App.tsx
|
|
@ -13,6 +13,7 @@ import {
|
|||
Platform,
|
||||
LogBox
|
||||
} from 'react-native';
|
||||
import './src/i18n'; // Initialize i18n
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
|
|
@ -42,7 +43,6 @@ import { aiService } from './src/services/aiService';
|
|||
import { AccountProvider, useAccount } from './src/contexts/AccountContext';
|
||||
import { ToastProvider } from './src/contexts/ToastContext';
|
||||
import { mmkvStorage } from './src/services/mmkvStorage';
|
||||
import AnnouncementOverlay from './src/components/AnnouncementOverlay';
|
||||
import { CampaignManager } from './src/components/promotions/CampaignManager';
|
||||
|
||||
Sentry.init({
|
||||
|
|
@ -90,7 +90,6 @@ const ThemedApp = () => {
|
|||
const { currentTheme } = useTheme();
|
||||
const [isAppReady, setIsAppReady] = useState(false);
|
||||
const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState<boolean | null>(null);
|
||||
const [showAnnouncement, setShowAnnouncement] = useState(false);
|
||||
|
||||
// Update popup functionality
|
||||
const {
|
||||
|
|
@ -105,16 +104,6 @@ const ThemedApp = () => {
|
|||
// GitHub major/minor release overlay
|
||||
const githubUpdate = useGithubMajorUpdate();
|
||||
|
||||
// Announcement data
|
||||
const announcements = [
|
||||
{
|
||||
icon: 'zap',
|
||||
title: 'Debrid Integration',
|
||||
description: 'Unlock 4K high-quality streams with lightning-fast speeds. Connect your TorBox account to access cached premium content with zero buffering.',
|
||||
tag: 'NEW',
|
||||
},
|
||||
];
|
||||
|
||||
// Check onboarding status and initialize services
|
||||
useEffect(() => {
|
||||
const initializeApp = async () => {
|
||||
|
|
@ -134,15 +123,6 @@ const ThemedApp = () => {
|
|||
await aiService.initialize();
|
||||
console.log('AI service initialized');
|
||||
|
||||
// Check if announcement should be shown (version 1.0.0)
|
||||
const announcementShown = await mmkvStorage.getItem('announcement_v1.0.0_shown');
|
||||
if (!announcementShown && onboardingCompleted === 'true') {
|
||||
// Show announcement only after app is ready
|
||||
setTimeout(() => {
|
||||
setShowAnnouncement(true);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
// Default to showing onboarding if we can't check
|
||||
|
|
@ -180,20 +160,6 @@ const ThemedApp = () => {
|
|||
// Navigation reference
|
||||
const navigationRef = React.useRef<any>(null);
|
||||
|
||||
// Handler for navigating to debrid integration
|
||||
const handleNavigateToDebrid = () => {
|
||||
if (navigationRef.current) {
|
||||
navigationRef.current.navigate('DebridIntegration');
|
||||
}
|
||||
};
|
||||
|
||||
// Handler for announcement close
|
||||
const handleAnnouncementClose = async () => {
|
||||
setShowAnnouncement(false);
|
||||
// Mark announcement as shown
|
||||
await mmkvStorage.setItem('announcement_v1.0.0_shown', 'true');
|
||||
};
|
||||
|
||||
// Don't render anything until we know the onboarding status
|
||||
const shouldShowApp = isAppReady && hasCompletedOnboarding !== null;
|
||||
const initialRouteName = hasCompletedOnboarding ? 'MainTabs' : 'Onboarding';
|
||||
|
|
@ -204,7 +170,16 @@ const ThemedApp = () => {
|
|||
<NavigationContainer
|
||||
ref={navigationRef}
|
||||
theme={customNavigationTheme}
|
||||
linking={undefined}
|
||||
linking={{
|
||||
prefixes: ['nuvio://'],
|
||||
config: {
|
||||
screens: {
|
||||
ScraperSettings: {
|
||||
path: 'repo',
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DownloadsProvider>
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
|
|
@ -227,13 +202,6 @@ const ThemedApp = () => {
|
|||
onDismiss={githubUpdate.onDismiss}
|
||||
onLater={githubUpdate.onLater}
|
||||
/>
|
||||
<AnnouncementOverlay
|
||||
visible={showAnnouncement}
|
||||
announcements={announcements}
|
||||
onClose={handleAnnouncementClose}
|
||||
onActionPress={handleNavigateToDebrid}
|
||||
actionButtonText="Connect Now"
|
||||
/>
|
||||
<CampaignManager />
|
||||
</View>
|
||||
</DownloadsProvider>
|
||||
|
|
|
|||
1
NuvioOTA
1
NuvioOTA
|
|
@ -1 +0,0 @@
|
|||
Subproject commit e8c62a9a8fc48ff11c89b85b55fd979cc59bd0c4
|
||||
39
README.md
39
README.md
|
|
@ -1,16 +1,15 @@
|
|||
<!-- Improved compatibility of back to top link -->
|
||||
<a id="readme-top"></a>
|
||||
|
||||
<!-- PROJECT SHIELDS -->
|
||||
[![Contributors][contributors-shield]][contributors-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
[![Stargazers][stars-shield]][stars-url]
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![License][license-shield]][license-url]
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<br />
|
||||
<div align="center">
|
||||
<a id="readme-top"></a>
|
||||
|
||||
[![Contributors][contributors-shield]][contributors-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
[![Stargazers][stars-shield]][stars-url]
|
||||
[![Issues][issues-shield]][issues-url]
|
||||
[![License][license-shield]][license-url]
|
||||
|
||||
<br />
|
||||
<br />
|
||||
<img src="assets/titlelogo.png" alt="Nuvio Logo" width="120" />
|
||||
<h1 align="center">🎬 Nuvio Media Hub</h1>
|
||||
<p align="center">
|
||||
|
|
@ -23,9 +22,9 @@
|
|||
<br />
|
||||
<br />
|
||||
|
||||
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=bug&template=bug_report.md">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=enhancement&template=feature_request.md">Request Feature</a>
|
||||
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=bug&template=bug_report.md">Report Bug</a>
|
||||
·
|
||||
<a href="https://github.com/tapframe/NuvioStreaming/issues/new?labels=enhancement&template=feature_request.md">Request Feature</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -41,7 +40,9 @@
|
|||
<li><a href="#getting-started">Getting Started</a></li>
|
||||
<li><a href="#contributing">Contributing</a></li>
|
||||
<li><a href="#support">Support</a></li>
|
||||
<li><a href="#support">Support</a></li>
|
||||
<li><a href="#license">License</a></li>
|
||||
<li><a href="#legal">Legal</a></li>
|
||||
<li><a href="#contact">Contact</a></li>
|
||||
<li><a href="#acknowledgments">Acknowledgments</a></li>
|
||||
<li><a href="#built-with">Built With</a></li>
|
||||
|
|
@ -83,7 +84,7 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
|
|||
<!-- GETTING STARTED -->
|
||||
## Getting Started
|
||||
|
||||
Follow the steps below to run the app locally for development.
|
||||
Follow the steps below to run the app locally for development. For detailed setup and troubleshooting, see [Project Documentation](docs/DOCUMENTATION.md).
|
||||
|
||||
### Development Build
|
||||
|
||||
|
|
@ -139,6 +140,14 @@ Distributed under the GNU GPLv3 License. See `LICENSE` for more information.
|
|||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Legal
|
||||
|
||||
For comprehensive legal information, including our full disclaimer, third-party extension policy, and DMCA/Copyright information, please visit our **[Legal & Disclaimer Page](https://tapframe.github.io/NuvioStreaming/#legal)**.
|
||||
|
||||
**Disclaimer:** Nuvio functions solely as a client-side interface for browsing metadata and playing media files provided by user-installed extensions. It does not host, store, or distribute any media content.
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Contact
|
||||
|
||||
**Project Links:**
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ android {
|
|||
applicationId 'com.nuvio.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 30
|
||||
versionName "1.3.2"
|
||||
versionCode 33
|
||||
versionName "1.3.5"
|
||||
|
||||
buildConfigField "String", "REACT_NATIVE_RELEASE_LEVEL", "\"${findProperty('reactNativeReleaseLevel') ?: 'stable'}\""
|
||||
}
|
||||
|
|
@ -118,7 +118,7 @@ android {
|
|||
def abiVersionCodes = ['armeabi-v7a': 1, 'arm64-v8a': 2, 'x86': 3, 'x86_64': 4]
|
||||
applicationVariants.all { variant ->
|
||||
variant.outputs.each { output ->
|
||||
def baseVersionCode = 30 // Current versionCode 30 from defaultConfig
|
||||
def baseVersionCode = 33 // Current versionCode 33 from defaultConfig
|
||||
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
|
||||
def versionCode = baseVersionCode * 100 // Base multiplier
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import android.view.Surface
|
|||
import android.view.TextureView
|
||||
import dev.jdtech.mpv.MPVLib
|
||||
|
||||
import com.facebook.react.bridge.LifecycleEventListener
|
||||
import com.facebook.react.bridge.ReactContext
|
||||
|
||||
class MPVView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
|
|
@ -40,9 +43,27 @@ class MPVView @JvmOverloads constructor(
|
|||
var onErrorCallback: ((message: String) -> Unit)? = null
|
||||
var onTracksChangedCallback: ((audioTracks: List<Map<String, Any>>, subtitleTracks: List<Map<String, Any>>) -> Unit)? = null
|
||||
|
||||
private var resumeOnForeground = false
|
||||
private val lifeCycleListener = object : LifecycleEventListener {
|
||||
override fun onHostPause() {
|
||||
resumeOnForeground = !isPaused;
|
||||
if(resumeOnForeground) {
|
||||
Log.d(TAG, "App backgrounded — pausing MPV")
|
||||
setPaused(true)
|
||||
}
|
||||
}
|
||||
override fun onHostResume() {
|
||||
if(resumeOnForeground) {
|
||||
setPaused(false)
|
||||
resumeOnForeground = false
|
||||
}
|
||||
}
|
||||
override fun onHostDestroy() {}
|
||||
}
|
||||
init {
|
||||
surfaceTextureListener = this
|
||||
isOpaque = false
|
||||
(context as? ReactContext)?.addLifecycleEventListener(lifeCycleListener)
|
||||
}
|
||||
|
||||
override fun onSurfaceTextureAvailable(surfaceTexture: SurfaceTexture, width: Int, height: Int) {
|
||||
|
|
@ -80,6 +101,7 @@ class MPVView @JvmOverloads constructor(
|
|||
|
||||
override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
|
||||
Log.d(TAG, "Surface texture destroyed")
|
||||
(context as? ReactContext)?.removeLifecycleEventListener(lifeCycleListener)
|
||||
if (isMpvInitialized) {
|
||||
MPVLib.removeObserver(this)
|
||||
MPVLib.detachSurface()
|
||||
|
|
|
|||
|
|
@ -3,5 +3,5 @@
|
|||
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
|
||||
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
|
||||
<string name="expo_system_ui_user_interface_style" translatable="false">dark</string>
|
||||
<string name="expo_runtime_version">1.3.2</string>
|
||||
<string name="expo_runtime_version">1.3.5</string>
|
||||
</resources>
|
||||
11
app.json
11
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Nuvio",
|
||||
"slug": "nuvio",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.5",
|
||||
"orientation": "default",
|
||||
"backgroundColor": "#020404",
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
|
|
@ -17,7 +17,7 @@
|
|||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"icon": "./assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png",
|
||||
"buildNumber": "30",
|
||||
"buildNumber": "33",
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
"android.permission.WRITE_SETTINGS"
|
||||
],
|
||||
"package": "com.nuvio.app",
|
||||
"versionCode": 30,
|
||||
"versionCode": 33,
|
||||
"architectures": [
|
||||
"arm64-v8a",
|
||||
"armeabi-v7a",
|
||||
|
|
@ -89,8 +89,7 @@
|
|||
"receiverAppId": "CC1AD845",
|
||||
"iosStartDiscoveryAfterFirstTapOnCastButton": true
|
||||
}
|
||||
],
|
||||
"./plugins/mpv-bridge/withMpvBridge"
|
||||
]
|
||||
],
|
||||
"updates": {
|
||||
"enabled": true,
|
||||
|
|
@ -98,6 +97,6 @@
|
|||
"fallbackToCacheTimeout": 30000,
|
||||
"url": "https://ota.nuvioapp.space/api/manifest"
|
||||
},
|
||||
"runtimeVersion": "1.3.2"
|
||||
"runtimeVersion": "1.3.5"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 162 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.6 KiB |
244
docs/DOCUMENTATION.md
Normal file
244
docs/DOCUMENTATION.md
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
# Nuvio Streaming Project Documentation
|
||||
|
||||
This document provides a comprehensive, step-by-step guide on how to build, run, and develop the Nuvio Streaming application for both Android and iOS platforms. It covers prerequisites, initial setup, prebuilding, and native execution.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Prerequisites](#prerequisites)
|
||||
2. [Project Setup](#project-setup)
|
||||
3. [Understanding Prebuild](#understanding-prebuild)
|
||||
4. [Running on Android](#running-on-android)
|
||||
5. [Running on iOS](#running-on-ios)
|
||||
6. [Troubleshooting](#troubleshooting)
|
||||
7. [Useful Commands](#useful-commands)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before you begin, ensure your development environment is correctly set up.
|
||||
|
||||
### General Tools
|
||||
- **Node.js**: Install the Long Term Support (LTS) version (v18 or newer recommended). [Download Node.js](https://nodejs.org/)
|
||||
- **Git**: For version control. [Download Git](https://git-scm.com/)
|
||||
- **Watchman** (macOS users): Highly recommended for better file watching performance.
|
||||
```bash
|
||||
brew install watchman
|
||||
```
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
**All environment variables are optional for development.**
|
||||
The app is designed to run "out of the box" without a `.env` file. Features requiring API keys (like Trakt syncing) will simply be disabled or use default fallbacks.
|
||||
|
||||
3. **Setup (Optional)**:
|
||||
If you wish to enable specific features, create a `.env` file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
**Recommended Variables:**
|
||||
* `EXPO_PUBLIC_TRAKT_CLIENT_ID` (etc): Enables Trakt integration.
|
||||
|
||||
### For Android Development
|
||||
|
||||
1. **Java Development Kit (JDK)**: Install JDK 11 or newer (JDK 17 is often recommended for modern React Native).
|
||||
- [OpenJDK](https://openjdk.org/) or [Azul Zulu](https://www.azul.com/downloads/).
|
||||
- Ensure your `JAVA_HOME` environment variable is set.
|
||||
2. **Android Studio**:
|
||||
- Install [Android Studio](https://developer.android.com/studio).
|
||||
- During installation, ensure the **Android SDK**, **Android SDK Platform-Tools**, and **Android Virtual Device** are selected.
|
||||
- Set up your `ANDROID_HOME` (or `ANDROID_SDK_ROOT`) environment variable pointing to your SDK location.
|
||||
|
||||
### For iOS Development (macOS only)
|
||||
|
||||
1. **Xcode**: Install the latest version of Xcode from the Mac App Store.
|
||||
2. **Xcode Command Line Tools**:
|
||||
```bash
|
||||
xcode-select --install
|
||||
```
|
||||
3. **CocoaPods**: Required for managing iOS dependencies.
|
||||
```bash
|
||||
sudo gem install cocoapods
|
||||
```
|
||||
*Note: On Apple Silicon (M1/M2/M3) Macs, you might need to use Homebrew to install Ruby or manage Cocoapods differently if you encounter issues.*
|
||||
|
||||
---
|
||||
|
||||
## Project Setup
|
||||
|
||||
1. **Clone the Repository**
|
||||
```bash
|
||||
git clone https://github.com/tapframe/NuvioStreaming.git
|
||||
cd NuvioStreaming
|
||||
```
|
||||
|
||||
2. **Install Dependencies**
|
||||
Install the project dependencies using `npm`.
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
*Note: If you encounter peer dependency conflicts, you can try `npm install --legacy-peer-deps`, but typically `npm install` should work if the `package.json` is well-maintained.*
|
||||
|
||||
---
|
||||
|
||||
## Understanding Prebuild
|
||||
|
||||
This project is built with **Expo**. Since it may use native modules that are not included in the standard Expo Go client (Custom Dev Client), we often need to "prebuild" the project to generate the native `android` and `ios` directories.
|
||||
|
||||
**What `npx expo prebuild` does:**
|
||||
- It generates the native `android` and `ios` project directories based on your configuration in `app.json` / `app.config.js`.
|
||||
- It applies any Config Plugins specified.
|
||||
- It prepares the project to be built locally using Android Studio or Xcode tools (Gradle/Podfile).
|
||||
|
||||
You typically run this command before compiling the native app if you have made changes to the native configuration (e.g., icons, splash screens, permissions in `app.json`).
|
||||
|
||||
```bash
|
||||
npx expo prebuild
|
||||
```
|
||||
|
||||
> [!WARNING]
|
||||
> **Important:** Running `npx expo prebuild --clean` will delete the `android` and `ios` directories.
|
||||
> If you have manually modified files in these directories (that are not covered by Expo config plugins), they will be lost.
|
||||
> **Recommendation:** Immediately after running prebuild, use `git status` to see what changed. If important files were deleted or reset, use `git checkout <path/to/file>` to revert them to your custom version.
|
||||
> Example:
|
||||
> ```bash
|
||||
> git checkout android/build.gradle
|
||||
> ```
|
||||
|
||||
To prebuild for a specific platform:
|
||||
```bash
|
||||
npx expo prebuild --platform android
|
||||
npx expo prebuild --platform ios
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running on Android
|
||||
|
||||
Follow these steps to build and run the app on an Android Emulator or connected physical device.
|
||||
|
||||
**Step 1: Start an Emulator or Connect a Device**
|
||||
- **Emulator**: Open Android Studio, go to "Device Manager", and start a virtual device.
|
||||
- **Physical Device**: Connect it via USB, enable **Developer Options** and **USB Debugging**. Verify connection with `adb devices`.
|
||||
|
||||
**Step 2: Generate Native Directories (Prebuild)**
|
||||
If you haven't done so (or if you cleaned the project):
|
||||
```bash
|
||||
npx expo prebuild --platform android
|
||||
```
|
||||
|
||||
**Step 3: Compile and Run**
|
||||
Run the following command to build the Android app and launch it on your device/emulator:
|
||||
```bash
|
||||
npx expo run:android
|
||||
```
|
||||
*This command will start the Metro bundler in a new window/tab and begin the Gradle build process.*
|
||||
|
||||
**Alternative: Open in Android Studio**
|
||||
If you prefer identifying build errors in the IDE:
|
||||
1. Run `npx expo prebuild --platform android`.
|
||||
2. Open Android Studio.
|
||||
3. Select "Open an existing Android Studio Project" and choose the `android` folder inside `NuvioStreaming`.
|
||||
4. Wait for Gradle sync to complete, then press the **Run** (green play) button.
|
||||
|
||||
---
|
||||
|
||||
## Running on iOS
|
||||
|
||||
**Note:** iOS development requires a Mac with Xcode.
|
||||
|
||||
**Step 1: Generate Native Directories (Prebuild)**
|
||||
```bash
|
||||
npx expo prebuild --platform ios
|
||||
```
|
||||
*This will generate the `ios` folder and automatically run `pod install` inside it.*
|
||||
|
||||
**Step 2: Compile and Run**
|
||||
Run the following command to build the iOS app and launch it on the iOS Simulator:
|
||||
```bash
|
||||
npx expo run:ios
|
||||
```
|
||||
*To run on a specific simulator device:*
|
||||
```bash
|
||||
npx expo run:ios --device "iPhone 15 Pro"
|
||||
```
|
||||
|
||||
**Step 3: Running on a Physical iOS Device**
|
||||
1. You need an Apple Developer Account (a free account works for local testing, but requires re-signing every 7 days).
|
||||
2. Open the project in Xcode:
|
||||
```bash
|
||||
xcode-open ios/nuvio.xcworkspace
|
||||
```
|
||||
*(Or simple open `ios/nuvio.xcworkspace` in Xcode manually)*.
|
||||
3. In Xcode, select your project target, go to the **Signing & Capabilities** tab.
|
||||
4. Select your **Team**.
|
||||
5. Connect your device via USB.
|
||||
6. Select your device from the build target dropdown (top bar).
|
||||
7. Press **Cmd + R** to build and run.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "CocoaPods not found" or Pod install errors
|
||||
If `npx expo run:ios` fails during pod installation:
|
||||
```bash
|
||||
cd ios
|
||||
pod install
|
||||
cd ..
|
||||
```
|
||||
If you are on an Apple Silicon Mac and have issues:
|
||||
```bash
|
||||
cd ios
|
||||
arch -x86_64 pod install
|
||||
cd ..
|
||||
```
|
||||
|
||||
### Build Failures after changing dependencies
|
||||
If you install a new library that includes native code, you must rebuild the native app.
|
||||
1. Stop the Metro server.
|
||||
2. Run the platform-specific run command again:
|
||||
```bash
|
||||
npx expo run:android
|
||||
# or
|
||||
npx expo run:ios
|
||||
```
|
||||
|
||||
### General Clean Up
|
||||
If things are acting weird (stale cache, weird build errors), try cleaning the project:
|
||||
|
||||
**1. Clear Metro Cache:**
|
||||
```bash
|
||||
npx expo start -c
|
||||
```
|
||||
|
||||
**2. Clean Native Directories (Drastic Measure):**
|
||||
WARNING: This deletes the `android` and `ios` folders. Only do this if you can regenerate them with `prebuild`.
|
||||
```bash
|
||||
rm -rf android ios
|
||||
npx expo prebuild
|
||||
```
|
||||
*Note: If you have manual changes in `android` or `ios` folders that usually shouldn't be there in a managed workflow, they will be lost. Ensure all native config is configured via Config Plugins in `app.json`.*
|
||||
|
||||
### "SDK location not found" (Android)
|
||||
Create a `local.properties` file in the `android` directory with the path to your SDK:
|
||||
```properties
|
||||
# android/local.properties
|
||||
sdk.dir=/Users/YOUR_USERNAME/Library/Android/sdk
|
||||
```
|
||||
(Replace `YOUR_USERNAME` with your actual username).
|
||||
|
||||
---
|
||||
|
||||
## Useful Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm start` or `npx expo start` | Starts the Metro Bundler (development server). |
|
||||
| `npx expo start --clear` | Starts the bundler with a clear cache. |
|
||||
| `npx expo prebuild` | Generates native `android` and `ios` code. |
|
||||
| `npx expo prebuild --clean` | Deletes existing native folders and regenerates them. |
|
||||
| `npx expo run:android` | Builds and opens the app on Android. |
|
||||
| `npx expo run:ios` | Builds and opens the app on iOS. |
|
||||
| `npx expo install <package>` | Installs a library compatible with your Expo SDK version. |
|
||||
121
index.html
121
index.html
|
|
@ -187,12 +187,25 @@
|
|||
}
|
||||
|
||||
.screenshots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
column-count: 1;
|
||||
column-gap: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.screenshots-grid {
|
||||
column-count: 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.screenshots-grid {
|
||||
column-count: 3;
|
||||
}
|
||||
}
|
||||
|
||||
.screenshot {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
|
@ -913,6 +926,67 @@
|
|||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Legal Section -->
|
||||
<section id="legal" class="privacy-policy">
|
||||
<div class="container">
|
||||
<div class="privacy-content">
|
||||
<button class="privacy-back-btn" onclick="hideAllDetailSections()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M19 12H5M12 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
<h1>Legal & Disclaimer</h1>
|
||||
<p class="last-updated">Last updated: January 2026</p>
|
||||
|
||||
<div class="privacy-section">
|
||||
<h2>Nature of the Application</h2>
|
||||
<p>Nuvio is a media player and metadata management application. It acts solely as a client-side
|
||||
interface for browsing publicly available metadata (movies, TV shows, etc.) and playing media
|
||||
files
|
||||
provided by the user or third-party extensions. Nuvio itself does not host, store, distribute,
|
||||
or
|
||||
index any media content.</p>
|
||||
</div>
|
||||
|
||||
<div class="privacy-section">
|
||||
<h2>Third-Party Extensions</h2>
|
||||
<p>Nuvio uses an extensible architecture that allows users to install third-party plugins
|
||||
(extensions).
|
||||
These extensions are developed and maintained by independent developers not affiliated with
|
||||
Nuvio.
|
||||
We have no control over, and assume no responsibility for, the content, legality, or
|
||||
functionality
|
||||
of any third-party extension.</p>
|
||||
</div>
|
||||
|
||||
<div class="privacy-section">
|
||||
<h2>User Responsibility</h2>
|
||||
<p>Users are solely responsible for the extensions they install and the content they access. By
|
||||
using
|
||||
this application, you agree to ensure that you have the legal right to access any content you
|
||||
view
|
||||
using Nuvio. The developers of Nuvio do not endorse or encourage copyright infringement.</p>
|
||||
</div>
|
||||
|
||||
<div class="privacy-section">
|
||||
<h2>Copyright & DMCA</h2>
|
||||
<p>We respect the intellectual property rights of others. Since Nuvio does not host any content, we
|
||||
cannot remove content from the internet. However, if you believe that the application interface
|
||||
itself infringes on your rights, please contact us.</p>
|
||||
</div>
|
||||
|
||||
<div class="privacy-section">
|
||||
<h2>No Warranty</h2>
|
||||
<p>This software is provided "as is", without warranty of any kind, express or implied. In no event
|
||||
shall the authors or copyright holders be liable for any claim, damages, or other liability
|
||||
arising
|
||||
from the use of this software.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
|
|
@ -945,9 +1019,12 @@
|
|||
<img src="https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png" alt="GitHub">
|
||||
GitHub
|
||||
</a>
|
||||
<a href="#privacy-policy" class="footer-link" onclick="showPrivacyPolicy()">
|
||||
<a href="#privacy-policy" class="footer-link" onclick="showSection('privacy-policy')">
|
||||
Privacy
|
||||
</a>
|
||||
<a href="#legal" class="footer-link" onclick="showSection('legal')">
|
||||
Legal
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="footer-copyright">© 2025 Nuvio. GNU GPLv3. Free and Open Source.</p>
|
||||
|
|
@ -1035,26 +1112,42 @@
|
|||
});
|
||||
});
|
||||
|
||||
// Privacy Policy
|
||||
function showPrivacyPolicy() {
|
||||
document.querySelectorAll('body > *:not(#privacy-policy)').forEach(el => el.style.display = 'none');
|
||||
document.getElementById('privacy-policy').style.display = 'block';
|
||||
// Detail Sections (Privacy, Legal)
|
||||
const detailSections = ['privacy-policy', 'legal'];
|
||||
|
||||
function showSection(sectionId) {
|
||||
// Hide everything except the requested section
|
||||
document.querySelectorAll('body > *:not(#' + sectionId + ')').forEach(el => el.style.display = 'none');
|
||||
document.getElementById(sectionId).style.display = 'block';
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
function hidePrivacyPolicy() {
|
||||
document.getElementById('privacy-policy').style.display = 'none';
|
||||
document.querySelectorAll('body > *:not(#privacy-policy)').forEach(el => el.style.display = '');
|
||||
function hideAllDetailSections() {
|
||||
detailSections.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
});
|
||||
// Show main content
|
||||
document.querySelectorAll('body > *').forEach(el => {
|
||||
if (!detailSections.includes(el.id)) el.style.display = '';
|
||||
});
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', function () {
|
||||
if (window.location.hash === '#privacy-policy') showPrivacyPolicy();
|
||||
else hidePrivacyPolicy();
|
||||
const hash = window.location.hash.substring(1); // remove #
|
||||
if (detailSections.includes(hash)) {
|
||||
showSection(hash);
|
||||
} else {
|
||||
hideAllDetailSections();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
if (window.location.hash === '#privacy-policy') showPrivacyPolicy();
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (detailSections.includes(hash)) {
|
||||
showSection(hash);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,69 +0,0 @@
|
|||
//
|
||||
// KSPlayerManager.m
|
||||
// Nuvio
|
||||
//
|
||||
// Created by KSPlayer integration
|
||||
//
|
||||
|
||||
#import <React/RCTBridgeModule.h>
|
||||
#import <React/RCTEventEmitter.h>
|
||||
#import <React/RCTViewManager.h>
|
||||
|
||||
@interface RCT_EXTERN_MODULE (KSPlayerViewManager, RCTViewManager)
|
||||
|
||||
RCT_EXPORT_VIEW_PROPERTY(source, NSDictionary)
|
||||
RCT_EXPORT_VIEW_PROPERTY(paused, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(volume, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(rate, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(audioTrack, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(textTrack, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(allowsExternalPlayback, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(usesExternalPlaybackWhileExternalScreenIsActive, BOOL)
|
||||
RCT_EXPORT_VIEW_PROPERTY(subtitleBottomOffset, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(subtitleFontSize, NSNumber)
|
||||
RCT_EXPORT_VIEW_PROPERTY(subtitleTextColor, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(subtitleBackgroundColor, NSString)
|
||||
RCT_EXPORT_VIEW_PROPERTY(resizeMode, NSString)
|
||||
|
||||
// Event properties
|
||||
RCT_EXPORT_VIEW_PROPERTY(onLoad, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onProgress, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onBuffering, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onEnd, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onError, RCTDirectEventBlock)
|
||||
RCT_EXPORT_VIEW_PROPERTY(onBufferingProgress, RCTDirectEventBlock)
|
||||
|
||||
RCT_EXTERN_METHOD(seek : (nonnull NSNumber *)node toTime : (nonnull NSNumber *)
|
||||
time)
|
||||
RCT_EXTERN_METHOD(setSource : (nonnull NSNumber *)
|
||||
node source : (nonnull NSDictionary *)source)
|
||||
RCT_EXTERN_METHOD(setPaused : (nonnull NSNumber *)node paused : (BOOL)paused)
|
||||
RCT_EXTERN_METHOD(setVolume : (nonnull NSNumber *)
|
||||
node volume : (nonnull NSNumber *)volume)
|
||||
RCT_EXTERN_METHOD(setPlaybackRate : (nonnull NSNumber *)
|
||||
node rate : (nonnull NSNumber *)rate)
|
||||
RCT_EXTERN_METHOD(setAudioTrack : (nonnull NSNumber *)
|
||||
node trackId : (nonnull NSNumber *)trackId)
|
||||
RCT_EXTERN_METHOD(setTextTrack : (nonnull NSNumber *)
|
||||
node trackId : (nonnull NSNumber *)trackId)
|
||||
RCT_EXTERN_METHOD(getTracks : (nonnull NSNumber *)node resolve : (
|
||||
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(setAllowsExternalPlayback : (nonnull NSNumber *)
|
||||
node allows : (BOOL)allows)
|
||||
RCT_EXTERN_METHOD(setUsesExternalPlaybackWhileExternalScreenIsActive : (
|
||||
nonnull NSNumber *)node uses : (BOOL)uses)
|
||||
RCT_EXTERN_METHOD(getAirPlayState : (nonnull NSNumber *)node resolve : (
|
||||
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(showAirPlayPicker : (nonnull NSNumber *)node)
|
||||
|
||||
@end
|
||||
|
||||
@interface RCT_EXTERN_MODULE (KSPlayerModule, RCTEventEmitter)
|
||||
|
||||
RCT_EXTERN_METHOD(getTracks : (NSNumber *)nodeTag resolve : (
|
||||
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(getAirPlayState : (NSNumber *)nodeTag resolve : (
|
||||
RCTPromiseResolveBlock)resolve reject : (RCTPromiseRejectBlock)reject)
|
||||
RCT_EXTERN_METHOD(showAirPlayPicker : (NSNumber *)nodeTag)
|
||||
|
||||
@end
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
//
|
||||
// KSPlayerModule.swift
|
||||
// Nuvio
|
||||
//
|
||||
// Created by KSPlayer integration
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import KSPlayer
|
||||
import React
|
||||
|
||||
@objc(KSPlayerModule)
|
||||
class KSPlayerModule: RCTEventEmitter {
|
||||
override static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func supportedEvents() -> [String]! {
|
||||
return [
|
||||
"KSPlayer-onLoad",
|
||||
"KSPlayer-onProgress",
|
||||
"KSPlayer-onBuffering",
|
||||
"KSPlayer-onEnd",
|
||||
"KSPlayer-onError"
|
||||
]
|
||||
}
|
||||
|
||||
@objc func getTracks(_ nodeTag: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
guard let nodeTag = nodeTag else {
|
||||
reject("INVALID_ARGUMENT", "nodeTag must not be nil", nil)
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
|
||||
viewManager.getTracks(nodeTag, resolve: resolve, reject: reject)
|
||||
} else {
|
||||
reject("NO_VIEW_MANAGER", "KSPlayerViewManager not found", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getAirPlayState(_ nodeTag: NSNumber?, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
guard let nodeTag = nodeTag else {
|
||||
reject("INVALID_ARGUMENT", "nodeTag must not be nil", nil)
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
|
||||
viewManager.getAirPlayState(nodeTag, resolve: resolve, reject: reject)
|
||||
} else {
|
||||
reject("NO_VIEW_MANAGER", "KSPlayerViewManager not found", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func showAirPlayPicker(_ nodeTag: NSNumber?) {
|
||||
guard let nodeTag = nodeTag else {
|
||||
print("[KSPlayerModule] showAirPlayPicker called with nil nodeTag")
|
||||
return
|
||||
}
|
||||
print("[KSPlayerModule] showAirPlayPicker called for nodeTag: \(nodeTag)")
|
||||
DispatchQueue.main.async {
|
||||
if let viewManager = self.bridge.module(for: KSPlayerViewManager.self) as? KSPlayerViewManager {
|
||||
print("[KSPlayerModule] Found KSPlayerViewManager, calling showAirPlayPicker")
|
||||
viewManager.showAirPlayPicker(nodeTag)
|
||||
} else {
|
||||
print("[KSPlayerModule] Could not find KSPlayerViewManager")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,981 +0,0 @@
|
|||
//
|
||||
// KSPlayerView.swift
|
||||
// Nuvio
|
||||
//
|
||||
// Created by KSPlayer integration
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import KSPlayer
|
||||
import React
|
||||
import AVKit
|
||||
|
||||
@objc(KSPlayerView)
|
||||
class KSPlayerView: UIView {
|
||||
private var playerView: IOSVideoPlayerView!
|
||||
private var currentSource: NSDictionary?
|
||||
private var isPaused = false
|
||||
private var currentVolume: Float = 1.0
|
||||
weak var viewManager: KSPlayerViewManager?
|
||||
|
||||
// Event blocks for Fabric
|
||||
@objc var onLoad: RCTDirectEventBlock?
|
||||
@objc var onProgress: RCTDirectEventBlock?
|
||||
@objc var onBuffering: RCTDirectEventBlock?
|
||||
@objc var onEnd: RCTDirectEventBlock?
|
||||
@objc var onError: RCTDirectEventBlock?
|
||||
@objc var onBufferingProgress: RCTDirectEventBlock?
|
||||
|
||||
// Property setters that React Native will call
|
||||
@objc var source: NSDictionary? {
|
||||
didSet {
|
||||
if let source = source {
|
||||
setSource(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc var paused: Bool = false {
|
||||
didSet {
|
||||
setPaused(paused)
|
||||
}
|
||||
}
|
||||
|
||||
@objc var volume: NSNumber = 1.0 {
|
||||
didSet {
|
||||
setVolume(volume.floatValue)
|
||||
}
|
||||
}
|
||||
|
||||
@objc var rate: NSNumber = 1.0 {
|
||||
didSet {
|
||||
setPlaybackRate(rate.floatValue)
|
||||
}
|
||||
}
|
||||
|
||||
@objc var audioTrack: NSNumber = -1 {
|
||||
didSet {
|
||||
setAudioTrack(audioTrack.intValue)
|
||||
}
|
||||
}
|
||||
|
||||
@objc var textTrack: NSNumber = -1 {
|
||||
didSet {
|
||||
setTextTrack(textTrack.intValue)
|
||||
}
|
||||
}
|
||||
|
||||
// AirPlay properties
|
||||
@objc var allowsExternalPlayback: Bool = true {
|
||||
didSet {
|
||||
setAllowsExternalPlayback(allowsExternalPlayback)
|
||||
}
|
||||
}
|
||||
|
||||
@objc var usesExternalPlaybackWhileExternalScreenIsActive: Bool = true {
|
||||
didSet {
|
||||
setUsesExternalPlaybackWhileExternalScreenIsActive(usesExternalPlaybackWhileExternalScreenIsActive)
|
||||
}
|
||||
}
|
||||
|
||||
// Subtitle customization props removed - using native KSPlayer styling
|
||||
@objc var subtitleBottomOffset: NSNumber = 60
|
||||
@objc var subtitleFontSize: NSNumber = 16
|
||||
@objc var subtitleTextColor: NSString = "#FFFFFF"
|
||||
@objc var subtitleBackgroundColor: NSString = "rgba(0,0,0,0.7)"
|
||||
|
||||
@objc var resizeMode: NSString = "contain" {
|
||||
didSet {
|
||||
print("KSPlayerView: [PROP SETTER] resizeMode setter called with value: \(resizeMode)")
|
||||
applyVideoGravity()
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
setupPlayerView()
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
super.init(coder: coder)
|
||||
setupPlayerView()
|
||||
}
|
||||
|
||||
private func setupPlayerView() {
|
||||
playerView = IOSVideoPlayerView()
|
||||
playerView.translatesAutoresizingMaskIntoConstraints = false
|
||||
// Hide native controls - we use custom React Native controls
|
||||
playerView.isUserInteractionEnabled = false
|
||||
// Hide KSPlayer's built-in overlay/controls
|
||||
playerView.controllerView.isHidden = true
|
||||
playerView.contentOverlayView.isHidden = true
|
||||
playerView.controllerView.alpha = 0
|
||||
playerView.contentOverlayView.alpha = 0
|
||||
playerView.controllerView.gestureRecognizers?.forEach { $0.isEnabled = false }
|
||||
addSubview(playerView)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
playerView.topAnchor.constraint(equalTo: topAnchor),
|
||||
playerView.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
playerView.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
playerView.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
|
||||
// Let KSPlayer handle subtitles natively - no custom positioning
|
||||
// Just set up player delegates and callbacks
|
||||
setupPlayerCallbacks()
|
||||
}
|
||||
|
||||
private func applyVideoGravity() {
|
||||
print("KSPlayerView: [VIDEO GRAVITY] Applying resizeMode: \(resizeMode)")
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
|
||||
let contentMode: UIViewContentMode
|
||||
switch self.resizeMode.lowercased {
|
||||
case "cover":
|
||||
contentMode = .scaleAspectFill
|
||||
case "stretch":
|
||||
contentMode = .scaleToFill
|
||||
case "contain":
|
||||
contentMode = .scaleAspectFit
|
||||
default:
|
||||
contentMode = .scaleAspectFit
|
||||
}
|
||||
|
||||
// Set contentMode on the player itself, not the view
|
||||
self.playerView.playerLayer?.player.contentMode = contentMode
|
||||
print("KSPlayerView: [VIDEO GRAVITY] Set player contentMode to: \(contentMode)")
|
||||
}
|
||||
}
|
||||
|
||||
private func setupPlayerCallbacks() {
|
||||
// Configure KSOptions
|
||||
KSOptions.isAutoPlay = false
|
||||
KSOptions.asynchronousDecompression = true
|
||||
KSOptions.hardwareDecode = true
|
||||
|
||||
// Set default subtitle font size - use smaller size for mobile
|
||||
SubtitleModel.textFontSize = 16.0
|
||||
SubtitleModel.textBold = false
|
||||
|
||||
print("KSPlayerView: [PERF] Global settings: asyncDecomp=\(KSOptions.asynchronousDecompression), hwDecode=\(KSOptions.hardwareDecode)")
|
||||
}
|
||||
|
||||
func setSource(_ source: NSDictionary) {
|
||||
currentSource = source
|
||||
|
||||
guard let uri = source["uri"] as? String else {
|
||||
print("KSPlayerView: No URI provided")
|
||||
sendEvent("onError", ["error": "No URI provided in source"])
|
||||
return
|
||||
}
|
||||
|
||||
// Validate URL before proceeding
|
||||
guard let url = URL(string: uri), url.scheme != nil else {
|
||||
print("KSPlayerView: Invalid URL format: \(uri)")
|
||||
sendEvent("onError", ["error": "Invalid URL format: \(uri)"])
|
||||
return
|
||||
}
|
||||
|
||||
var headers: [String: String] = [:]
|
||||
if let headersDict = source["headers"] as? [String: String] {
|
||||
headers = headersDict
|
||||
}
|
||||
|
||||
// Choose player pipeline based on format
|
||||
let isMKV = uri.lowercased().contains(".mkv")
|
||||
if isMKV {
|
||||
// Prefer MEPlayer (FFmpeg) for MKV
|
||||
KSOptions.firstPlayerType = KSMEPlayer.self
|
||||
KSOptions.secondPlayerType = nil
|
||||
} else {
|
||||
KSOptions.firstPlayerType = KSAVPlayer.self
|
||||
KSOptions.secondPlayerType = KSMEPlayer.self
|
||||
}
|
||||
|
||||
// Create KSPlayerResource with validated URL
|
||||
let resource = KSPlayerResource(url: url, options: createOptions(with: headers), name: "Video")
|
||||
|
||||
print("KSPlayerView: Setting source: \(uri)")
|
||||
print("KSPlayerView: URL scheme: \(url.scheme ?? "unknown"), host: \(url.host ?? "unknown")")
|
||||
|
||||
playerView.set(resource: resource)
|
||||
|
||||
// Set up delegate after setting the resource
|
||||
if let playerLayer = playerView.playerLayer {
|
||||
playerLayer.delegate = self
|
||||
print("KSPlayerView: Delegate set successfully on playerLayer")
|
||||
|
||||
// Apply video gravity after player is set up
|
||||
applyVideoGravity()
|
||||
} else {
|
||||
print("KSPlayerView: ERROR - playerLayer is nil, cannot set delegate")
|
||||
}
|
||||
|
||||
// Apply current state
|
||||
if isPaused {
|
||||
playerView.pause()
|
||||
} else {
|
||||
playerView.play()
|
||||
}
|
||||
|
||||
setVolume(currentVolume)
|
||||
|
||||
// Ensure AirPlay is properly configured after setting source
|
||||
DispatchQueue.main.async {
|
||||
self.setAllowsExternalPlayback(self.allowsExternalPlayback)
|
||||
self.setUsesExternalPlaybackWhileExternalScreenIsActive(self.usesExternalPlaybackWhileExternalScreenIsActive)
|
||||
}
|
||||
}
|
||||
|
||||
private func createOptions(with headers: [String: String]) -> KSOptions {
|
||||
// Use custom HighPerformanceOptions subclass for frame buffer optimization
|
||||
let options = HighPerformanceOptions()
|
||||
// Disable native player remote control center integration; use RN controls
|
||||
options.registerRemoteControll = false
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Buffer durations for smooth high bitrate playback
|
||||
// preferredForwardBufferDuration = 3.0s: Slightly increased to reduce rebuffering during playback
|
||||
options.preferredForwardBufferDuration = 1.0
|
||||
// maxBufferDuration = 120.0s: Increased to allow the player to cache more content ahead of time (2 minutes)
|
||||
options.maxBufferDuration = 120.0
|
||||
|
||||
// Enable "second open" to relax startup/seek buffering thresholds (already enabled)
|
||||
options.isSecondOpen = true
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Fast stream analysis for high bitrate content
|
||||
// Reduces startup latency significantly for large high-bitrate streams
|
||||
options.probesize = 50_000_000 // 50MB for faster format detection
|
||||
options.maxAnalyzeDuration = 5_000_000 // 5 seconds in microseconds for faster stream structure analysis
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Decoder thread optimization
|
||||
// Use all available CPU cores for parallel decoding
|
||||
options.decoderOptions["threads"] = "0" // Use all CPU cores instead of "auto"
|
||||
// refcounted_frames already set to "1" in KSOptions init for memory efficiency
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Hardware decode explicitly enabled
|
||||
// Ensure VideoToolbox hardware acceleration is always preferred for non-simulator
|
||||
// Hardware decode and async decompression
|
||||
options.hardwareDecode = true
|
||||
options.asynchronousDecompression = true
|
||||
|
||||
// HDR handling: Let KSPlayer automatically detect content's native dynamic range
|
||||
// Setting destinationDynamicRange to nil allows KSPlayer to use the content's actual HDR/SDR mode
|
||||
// This prevents forcing HDR tone mapping on SDR content (which causes oversaturation)
|
||||
// KSPlayer will automatically detect HDR10/Dolby Vision/HLG from the video format description
|
||||
options.destinationDynamicRange = nil
|
||||
|
||||
// Configure audio for proper dialogue mixing using FFmpeg's pan filter
|
||||
// This approach uses standard audio engineering practices for multi-channel downmixing
|
||||
|
||||
// Use conservative center channel mixing that preserves spatial audio
|
||||
// c0 (Left) = 70% original left + 30% center (dialogue) + 20% rear left
|
||||
// c1 (Right) = 70% original right + 30% center (dialogue) + 20% rear right
|
||||
// This creates natural dialogue presence without the "playing on both ears" effect
|
||||
options.audioFilters.append("pan=stereo|c0=0.7*c0+0.3*c2+0.2*c4|c1=0.7*c1+0.3*c2+0.2*c5")
|
||||
|
||||
// Alternative: Use FFmpeg's surround filter for more sophisticated downmixing
|
||||
// This provides better spatial audio processing and natural dialogue mixing
|
||||
// options.audioFilters.append("surround=ang=45")
|
||||
|
||||
if !headers.isEmpty {
|
||||
// Clean and validate headers before adding
|
||||
var cleanHeaders: [String: String] = [:]
|
||||
for (key, value) in headers {
|
||||
// Remove any null or empty values
|
||||
if !value.isEmpty && value != "null" {
|
||||
cleanHeaders[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
if !cleanHeaders.isEmpty {
|
||||
options.appendHeader(cleanHeaders)
|
||||
print("KSPlayerView: Added headers: \(cleanHeaders.keys.joined(separator: ", "))")
|
||||
|
||||
if let referer = cleanHeaders["Referer"] ?? cleanHeaders["referer"] {
|
||||
options.referer = referer
|
||||
print("KSPlayerView: Set referer: \(referer)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
print("KSPlayerView: [PERF] High-performance options configured: asyncDecomp=\(options.asynchronousDecompression), hwDecode=\(options.hardwareDecode), buffer=\(options.preferredForwardBufferDuration)s/\(options.maxBufferDuration)s, HDR=\(options.destinationDynamicRange?.description ?? "auto")")
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
func setPaused(_ paused: Bool) {
|
||||
isPaused = paused
|
||||
if paused {
|
||||
playerView.pause()
|
||||
} else {
|
||||
playerView.play()
|
||||
}
|
||||
}
|
||||
|
||||
func setVolume(_ volume: Float) {
|
||||
currentVolume = volume
|
||||
playerView.playerLayer?.player.playbackVolume = volume
|
||||
}
|
||||
|
||||
func setPlaybackRate(_ rate: Float) {
|
||||
playerView.playerLayer?.player.playbackRate = rate
|
||||
print("KSPlayerView: Set playback rate to \(rate)x")
|
||||
}
|
||||
|
||||
func seek(to time: TimeInterval) {
|
||||
guard let playerLayer = playerView.playerLayer,
|
||||
playerLayer.player.isReadyToPlay,
|
||||
playerLayer.player.seekable else {
|
||||
print("KSPlayerView: Cannot seek - player not ready or not seekable")
|
||||
return
|
||||
}
|
||||
|
||||
// Capture the current paused state before seeking
|
||||
let wasPaused = isPaused
|
||||
print("KSPlayerView: Seeking to \(time), paused state before seek: \(wasPaused)")
|
||||
|
||||
playerView.seek(time: time) { [weak self] success in
|
||||
guard let self = self else { return }
|
||||
|
||||
if success {
|
||||
print("KSPlayerView: Seek successful to \(time)")
|
||||
|
||||
// Restore the paused state after seeking
|
||||
// KSPlayer's seek may resume playback, so we need to re-apply the paused state
|
||||
if wasPaused {
|
||||
DispatchQueue.main.async {
|
||||
self.playerView.pause()
|
||||
print("KSPlayerView: Restored paused state after seek")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("KSPlayerView: Seek failed to \(time)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func setAudioTrack(_ trackId: Int) {
|
||||
if let player = playerView.playerLayer?.player {
|
||||
let audioTracks = player.tracks(mediaType: .audio)
|
||||
print("KSPlayerView: Available audio tracks count: \(audioTracks.count)")
|
||||
print("KSPlayerView: Requested track ID: \(trackId)")
|
||||
|
||||
// Debug: Print all track information
|
||||
for (index, track) in audioTracks.enumerated() {
|
||||
print("KSPlayerView: Track \(index) - ID: \(track.trackID), Name: '\(track.name)', Language: '\(track.language ?? "nil")', isEnabled: \(track.isEnabled)")
|
||||
}
|
||||
|
||||
// First try to find track by trackID (proper way)
|
||||
var selectedTrack: MediaPlayerTrack? = nil
|
||||
var trackIndex: Int = -1
|
||||
|
||||
// Try to find by exact trackID match
|
||||
if let track = audioTracks.first(where: { Int($0.trackID) == trackId }) {
|
||||
selectedTrack = track
|
||||
trackIndex = audioTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1
|
||||
print("KSPlayerView: Found track by trackID \(trackId) at index \(trackIndex)")
|
||||
}
|
||||
// Fallback: treat trackId as array index
|
||||
else if trackId >= 0 && trackId < audioTracks.count {
|
||||
selectedTrack = audioTracks[trackId]
|
||||
trackIndex = trackId
|
||||
print("KSPlayerView: Found track by array index \(trackId) (fallback)")
|
||||
}
|
||||
|
||||
if let track = selectedTrack {
|
||||
print("KSPlayerView: Selecting track \(trackId) (index: \(trackIndex)): '\(track.name)' (ID: \(track.trackID))")
|
||||
|
||||
// Use KSPlayer's select method which properly handles track selection
|
||||
player.select(track: track)
|
||||
|
||||
print("KSPlayerView: Successfully selected audio track \(trackId)")
|
||||
|
||||
// Verify the selection worked
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
let tracksAfter = player.tracks(mediaType: .audio)
|
||||
for (index, track) in tracksAfter.enumerated() {
|
||||
print("KSPlayerView: After selection - Track \(index) (ID: \(track.trackID)) isEnabled: \(track.isEnabled)")
|
||||
}
|
||||
}
|
||||
|
||||
// Configure audio downmixing for multi-channel tracks
|
||||
configureAudioDownmixing(for: track)
|
||||
} else if trackId == -1 {
|
||||
// Disable all audio tracks (mute)
|
||||
for track in audioTracks { track.isEnabled = false }
|
||||
print("KSPlayerView: Disabled all audio tracks")
|
||||
} else {
|
||||
print("KSPlayerView: Track \(trackId) not found. Available track IDs: \(audioTracks.map { Int($0.trackID) }), array indices: 0..\(audioTracks.count - 1)")
|
||||
}
|
||||
} else {
|
||||
print("KSPlayerView: No player available for audio track selection")
|
||||
}
|
||||
}
|
||||
|
||||
private func configureAudioDownmixing(for track: MediaPlayerTrack) {
|
||||
// Check if this is a multi-channel audio track that needs downmixing
|
||||
// This is a simplified check - in practice, you might want to check the actual channel layout
|
||||
let trackName = track.name.lowercased()
|
||||
let isMultiChannel = trackName.contains("5.1") || trackName.contains("7.1") ||
|
||||
trackName.contains("truehd") || trackName.contains("dts") ||
|
||||
trackName.contains("dolby") || trackName.contains("atmos")
|
||||
|
||||
if isMultiChannel {
|
||||
print("KSPlayerView: Detected multi-channel audio track '\(track.name)', ensuring proper dialogue mixing")
|
||||
print("KSPlayerView: Using FFmpeg pan filter for natural stereo downmixing")
|
||||
} else {
|
||||
print("KSPlayerView: Stereo or mono audio track '\(track.name)', no additional downmixing needed")
|
||||
}
|
||||
}
|
||||
|
||||
func setTextTrack(_ trackId: Int) {
|
||||
NSLog("KSPlayerView: [SET TEXT TRACK] Starting setTextTrack with trackId: %d", trackId)
|
||||
|
||||
// Small delay to ensure player is ready
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
guard let self = self else {
|
||||
NSLog("KSPlayerView: [SET TEXT TRACK] self is nil, aborting")
|
||||
return
|
||||
}
|
||||
|
||||
NSLog("KSPlayerView: [SET TEXT TRACK] Executing track selection")
|
||||
|
||||
if let player = self.playerView.playerLayer?.player {
|
||||
let textTracks = player.tracks(mediaType: .subtitle)
|
||||
NSLog("KSPlayerView: Available text tracks count: %d", textTracks.count)
|
||||
NSLog("KSPlayerView: Requested text track ID: %d", trackId)
|
||||
|
||||
// First try to find track by trackID (proper way)
|
||||
var selectedTrack: MediaPlayerTrack? = nil
|
||||
var trackIndex: Int = -1
|
||||
|
||||
// Try to find by exact trackID match
|
||||
if let track = textTracks.first(where: { Int($0.trackID) == trackId }) {
|
||||
selectedTrack = track
|
||||
trackIndex = textTracks.firstIndex(where: { $0.trackID == track.trackID }) ?? -1
|
||||
NSLog("KSPlayerView: Found text track by trackID %d at index %d", trackId, trackIndex)
|
||||
}
|
||||
// Fallback: treat trackId as array index
|
||||
else if trackId >= 0 && trackId < textTracks.count {
|
||||
selectedTrack = textTracks[trackId]
|
||||
trackIndex = trackId
|
||||
NSLog("KSPlayerView: Found text track by array index %d (fallback)", trackId)
|
||||
}
|
||||
|
||||
if let track = selectedTrack {
|
||||
NSLog("KSPlayerView: Selecting text track %d (index: %d): '%@' (ID: %d)", trackId, trackIndex, track.name, track.trackID)
|
||||
|
||||
// Disable all tracks first
|
||||
for t in textTracks {
|
||||
t.isEnabled = false
|
||||
}
|
||||
|
||||
// Enable the selected track
|
||||
track.isEnabled = true
|
||||
|
||||
// Use KSPlayer's select method to update player state
|
||||
player.select(track: track)
|
||||
|
||||
// CRITICAL: Cast MediaPlayerTrack to SubtitleInfo and set on srtControl
|
||||
// FFmpegAssetTrack conforms to SubtitleInfo via extension
|
||||
if let subtitleInfo = track as? SubtitleInfo {
|
||||
self.playerView.srtControl.selectedSubtitleInfo = subtitleInfo
|
||||
NSLog("KSPlayerView: Set srtControl.selectedSubtitleInfo to track '%@'", track.name)
|
||||
} else {
|
||||
NSLog("KSPlayerView: Warning - track could not be cast to SubtitleInfo")
|
||||
}
|
||||
|
||||
// Ensure subtitle views are visible
|
||||
self.playerView.subtitleLabel.isHidden = false
|
||||
self.playerView.subtitleBackView.isHidden = false
|
||||
|
||||
NSLog("KSPlayerView: Successfully selected and enabled text track %d", trackId)
|
||||
} else if trackId == -1 {
|
||||
// Disable all subtitles
|
||||
for track in textTracks {
|
||||
track.isEnabled = false
|
||||
}
|
||||
self.playerView.srtControl.selectedSubtitleInfo = nil
|
||||
self.playerView.subtitleLabel.isHidden = true
|
||||
self.playerView.subtitleBackView.isHidden = true
|
||||
NSLog("KSPlayerView: Disabled all text tracks")
|
||||
} else {
|
||||
NSLog("KSPlayerView: Text track %d not found. Available count: %d", trackId, textTracks.count)
|
||||
}
|
||||
} else {
|
||||
NSLog("KSPlayerView: No player available for text track selection")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get available tracks for React Native
|
||||
func getAvailableTracks() -> [String: Any] {
|
||||
guard let player = playerView.playerLayer?.player else {
|
||||
return ["audioTracks": [], "textTracks": []]
|
||||
}
|
||||
|
||||
let audioTracks = player.tracks(mediaType: .audio).enumerated().map { index, track in
|
||||
return [
|
||||
"id": Int(track.trackID), // Use actual track ID, not array index
|
||||
"index": index, // Keep index for backward compatibility
|
||||
"name": track.name,
|
||||
"language": track.language ?? "Unknown",
|
||||
"languageCode": track.languageCode ?? "",
|
||||
"isEnabled": track.isEnabled,
|
||||
"bitRate": track.bitRate,
|
||||
"bitDepth": track.bitDepth
|
||||
]
|
||||
}
|
||||
|
||||
let textTracks = player.tracks(mediaType: .subtitle).enumerated().map { index, track in
|
||||
// Create a better display name for subtitles
|
||||
var displayName = track.name
|
||||
if displayName.isEmpty || displayName == "Unknown" {
|
||||
if let language = track.language, !language.isEmpty && language != "Unknown" {
|
||||
displayName = language
|
||||
} else if let languageCode = track.languageCode, !languageCode.isEmpty {
|
||||
displayName = languageCode.uppercased()
|
||||
} else {
|
||||
displayName = "Subtitle \(index + 1)"
|
||||
}
|
||||
}
|
||||
|
||||
// Add language info if not already in the name
|
||||
if let language = track.language, !language.isEmpty && language != "Unknown" && !displayName.lowercased().contains(language.lowercased()) {
|
||||
displayName += " (\(language))"
|
||||
}
|
||||
|
||||
return [
|
||||
"id": Int(track.trackID), // Use actual track ID, not array index
|
||||
"index": index, // Keep index for backward compatibility
|
||||
"name": displayName,
|
||||
"language": track.language ?? "Unknown",
|
||||
"languageCode": track.languageCode ?? "",
|
||||
"isEnabled": track.isEnabled,
|
||||
"isImageSubtitle": track.isImageSubtitle
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
"audioTracks": audioTracks,
|
||||
"textTracks": textTracks
|
||||
]
|
||||
}
|
||||
|
||||
// AirPlay methods
|
||||
func setAllowsExternalPlayback(_ allows: Bool) {
|
||||
print("[KSPlayerView] Setting allowsExternalPlayback: \(allows)")
|
||||
playerView.playerLayer?.player.allowsExternalPlayback = allows
|
||||
}
|
||||
|
||||
func setUsesExternalPlaybackWhileExternalScreenIsActive(_ uses: Bool) {
|
||||
print("[KSPlayerView] Setting usesExternalPlaybackWhileExternalScreenIsActive: \(uses)")
|
||||
playerView.playerLayer?.player.usesExternalPlaybackWhileExternalScreenIsActive = uses
|
||||
}
|
||||
|
||||
func showAirPlayPicker() {
|
||||
print("[KSPlayerView] showAirPlayPicker called")
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// Create a temporary route picker view for triggering AirPlay
|
||||
let routePickerView = AVRoutePickerView()
|
||||
routePickerView.tintColor = .white
|
||||
routePickerView.alpha = 0.01 // Nearly invisible but still interactive
|
||||
|
||||
// Find the current view controller
|
||||
guard let viewController = self.findHostViewController() else {
|
||||
print("[KSPlayerView] Could not find view controller for AirPlay picker")
|
||||
return
|
||||
}
|
||||
|
||||
// Add to the view controller's view temporarily
|
||||
viewController.view.addSubview(routePickerView)
|
||||
|
||||
// Position it off-screen but still in the view hierarchy
|
||||
routePickerView.frame = CGRect(x: -100, y: -100, width: 44, height: 44)
|
||||
|
||||
// Force layout
|
||||
viewController.view.setNeedsLayout()
|
||||
viewController.view.layoutIfNeeded()
|
||||
|
||||
// Wait a bit for the view to be ready, then trigger
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
// Find and trigger the AirPlay button
|
||||
self.triggerAirPlayButton(routePickerView)
|
||||
|
||||
// Clean up after a delay
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
|
||||
routePickerView.removeFromSuperview()
|
||||
print("[KSPlayerView] Cleaned up temporary AirPlay picker")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func triggerAirPlayButton(_ routePickerView: AVRoutePickerView) {
|
||||
// Recursively find the button in the route picker view
|
||||
func findButton(in view: UIView) -> UIButton? {
|
||||
if let button = view as? UIButton {
|
||||
return button
|
||||
}
|
||||
for subview in view.subviews {
|
||||
if let button = findButton(in: subview) {
|
||||
return button
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let button = findButton(in: routePickerView) {
|
||||
print("[KSPlayerView] Found AirPlay button, triggering tap")
|
||||
button.sendActions(for: .touchUpInside)
|
||||
} else {
|
||||
print("[KSPlayerView] Could not find AirPlay button in route picker")
|
||||
}
|
||||
}
|
||||
|
||||
func getAirPlayState() -> [String: Any] {
|
||||
guard let player = playerView.playerLayer?.player else {
|
||||
return [
|
||||
"allowsExternalPlayback": false,
|
||||
"usesExternalPlaybackWhileExternalScreenIsActive": false,
|
||||
"isExternalPlaybackActive": false
|
||||
]
|
||||
}
|
||||
|
||||
return [
|
||||
"allowsExternalPlayback": player.allowsExternalPlayback,
|
||||
"usesExternalPlaybackWhileExternalScreenIsActive": player.usesExternalPlaybackWhileExternalScreenIsActive,
|
||||
"isExternalPlaybackActive": player.isExternalPlaybackActive
|
||||
]
|
||||
}
|
||||
|
||||
// Get current player state for React Native
|
||||
func getCurrentState() -> [String: Any] {
|
||||
guard let player = playerView.playerLayer?.player else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
return [
|
||||
"currentTime": player.currentPlaybackTime,
|
||||
"duration": player.duration,
|
||||
"buffered": player.playableTime,
|
||||
"isPlaying": !isPaused,
|
||||
"volume": currentVolume
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - Performance Optimization Helpers
|
||||
}
|
||||
|
||||
// MARK: - High Performance KSOptions Subclass
|
||||
|
||||
/// Custom KSOptions subclass that overrides frame buffer capacity for high bitrate content
|
||||
/// More buffered frames absorb decode spikes and network hiccups without quality loss
|
||||
private class HighPerformanceOptions: KSOptions {
|
||||
/// Override to increase frame buffer capacity for high bitrate content
|
||||
/// - Parameters:
|
||||
/// - fps: Video frame rate
|
||||
/// - naturalSize: Video resolution
|
||||
/// - isLive: Whether this is a live stream
|
||||
/// - Returns: Number of frames to buffer
|
||||
override func videoFrameMaxCount(fps: Float, naturalSize: CGSize, isLive: Bool) -> UInt8 {
|
||||
if isLive {
|
||||
// Increased from 4 to 8 for better live stream stability
|
||||
return 8
|
||||
}
|
||||
|
||||
// For high bitrate VOD: increase buffer based on resolution
|
||||
if naturalSize.width >= 3840 || naturalSize.height >= 2160 {
|
||||
// 4K needs more buffer frames to handle decode spikes
|
||||
return 32
|
||||
} else if naturalSize.width >= 1920 || naturalSize.height >= 1080 {
|
||||
// 1080p benefits from more frames
|
||||
return 24
|
||||
}
|
||||
|
||||
// Default for lower resolutions
|
||||
return 16
|
||||
}
|
||||
}
|
||||
|
||||
extension KSPlayerView: KSPlayerLayerDelegate {
|
||||
func player(layer: KSPlayerLayer, state: KSPlayerState) {
|
||||
switch state {
|
||||
case .readyToPlay:
|
||||
// Ensure AirPlay is properly configured when player is ready
|
||||
layer.player.allowsExternalPlayback = allowsExternalPlayback
|
||||
layer.player.usesExternalPlaybackWhileExternalScreenIsActive = usesExternalPlaybackWhileExternalScreenIsActive
|
||||
|
||||
// Debug: Check subtitle data source connection
|
||||
let hasSubtitleDataSource = layer.player.subtitleDataSouce != nil
|
||||
print("KSPlayerView: [READY TO PLAY] subtitle data source available: \(hasSubtitleDataSource)")
|
||||
|
||||
// Keep subtitle views hidden until actual content is displayed
|
||||
// They will be shown in the subtitle rendering callback when there's text to display
|
||||
playerView.subtitleLabel.isHidden = true
|
||||
playerView.subtitleBackView.isHidden = true
|
||||
print("KSPlayerView: [READY TO PLAY] Subtitle views kept hidden until content available")
|
||||
|
||||
// Manually connect subtitle data source to srtControl (this is the missing piece!)
|
||||
if let subtitleDataSouce = layer.player.subtitleDataSouce {
|
||||
print("KSPlayerView: [READY TO PLAY] Connecting subtitle data source to srtControl")
|
||||
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce type: \(type(of: subtitleDataSouce))")
|
||||
|
||||
// Check if subtitle data source has any subtitle infos
|
||||
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce has \(subtitleDataSouce.infos.count) subtitle infos")
|
||||
|
||||
for (index, info) in subtitleDataSouce.infos.enumerated() {
|
||||
print("KSPlayerView: [READY TO PLAY] subtitleDataSouce info[\(index)]: ID=\(info.subtitleID), Name='\(info.name)', Enabled=\(info.isEnabled)")
|
||||
}
|
||||
// Wait 1 second like the original KSPlayer code does
|
||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1) { [weak self] in
|
||||
guard let self = self else { return }
|
||||
print("KSPlayerView: [READY TO PLAY] About to add subtitle data source to srtControl")
|
||||
self.playerView.srtControl.addSubtitle(dataSouce: subtitleDataSouce)
|
||||
print("KSPlayerView: [READY TO PLAY] Subtitle data source connected to srtControl")
|
||||
print("KSPlayerView: [READY TO PLAY] srtControl.subtitleInfos.count: \(self.playerView.srtControl.subtitleInfos.count)")
|
||||
|
||||
// Log all subtitle infos
|
||||
for (index, info) in self.playerView.srtControl.subtitleInfos.enumerated() {
|
||||
print("KSPlayerView: [READY TO PLAY] SubtitleInfo[\(index)]: name=\(info.name), isEnabled=\(info.isEnabled), subtitleID=\(info.subtitleID)")
|
||||
}
|
||||
|
||||
// Try to manually trigger subtitle parsing for the current time
|
||||
let currentTime = self.playerView.playerLayer?.player.currentPlaybackTime ?? 0
|
||||
print("KSPlayerView: [READY TO PLAY] Current playback time: \(currentTime)")
|
||||
|
||||
// Force subtitle search for current time
|
||||
let hasSubtitle = self.playerView.srtControl.subtitle(currentTime: currentTime)
|
||||
print("KSPlayerView: [READY TO PLAY] Manual subtitle search result: \(hasSubtitle)")
|
||||
print("KSPlayerView: [READY TO PLAY] Parts count after manual search: \(self.playerView.srtControl.parts.count)")
|
||||
|
||||
if let firstPart = self.playerView.srtControl.parts.first {
|
||||
print("KSPlayerView: [READY TO PLAY] Found subtitle part: start=\(firstPart.start), end=\(firstPart.end), text='\(firstPart.text?.string ?? "nil")'")
|
||||
}
|
||||
|
||||
// Only auto-select first enabled subtitle if textTrack prop is NOT set to -1 (disabled)
|
||||
// If React Native explicitly set textTrack=-1, user wants subtitles off
|
||||
if self.textTrack.intValue != -1 {
|
||||
// Auto-select first enabled subtitle if none selected
|
||||
if self.playerView.srtControl.selectedSubtitleInfo == nil {
|
||||
self.playerView.srtControl.selectedSubtitleInfo = self.playerView.srtControl.subtitleInfos.first { $0.isEnabled }
|
||||
if let selected = self.playerView.srtControl.selectedSubtitleInfo {
|
||||
print("KSPlayerView: [READY TO PLAY] Auto-selected subtitle: \(selected.name)")
|
||||
} else {
|
||||
print("KSPlayerView: [READY TO PLAY] No enabled subtitle found for auto-selection")
|
||||
}
|
||||
} else {
|
||||
print("KSPlayerView: [READY TO PLAY] Subtitle already selected: \(self.playerView.srtControl.selectedSubtitleInfo?.name ?? "unknown")")
|
||||
}
|
||||
} else {
|
||||
print("KSPlayerView: [READY TO PLAY] textTrack=-1 (disabled), skipping auto-selection")
|
||||
// Ensure subtitles are disabled
|
||||
self.playerView.srtControl.selectedSubtitleInfo = nil
|
||||
self.playerView.subtitleLabel.isHidden = true
|
||||
self.playerView.subtitleBackView.isHidden = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
print("KSPlayerView: [READY TO PLAY] ERROR: No subtitle data source available")
|
||||
}
|
||||
|
||||
// Determine player backend type from actual player instance
|
||||
let playerBackend: String
|
||||
if let _ = layer.player as? KSMEPlayer {
|
||||
playerBackend = "KSMEPlayer"
|
||||
} else {
|
||||
playerBackend = "KSAVPlayer"
|
||||
}
|
||||
|
||||
// Send onLoad event to React Native with track information
|
||||
let p = layer.player
|
||||
let tracks = getAvailableTracks()
|
||||
sendEvent("onLoad", [
|
||||
"duration": p.duration,
|
||||
"currentTime": p.currentPlaybackTime,
|
||||
"naturalSize": [
|
||||
"width": p.naturalSize.width,
|
||||
"height": p.naturalSize.height
|
||||
],
|
||||
"audioTracks": tracks["audioTracks"] ?? [],
|
||||
"textTracks": tracks["textTracks"] ?? [],
|
||||
"playerBackend": playerBackend
|
||||
])
|
||||
case .buffering:
|
||||
sendEvent("onBuffering", ["isBuffering": true])
|
||||
case .bufferFinished:
|
||||
sendEvent("onBuffering", ["isBuffering": false])
|
||||
case .playedToTheEnd:
|
||||
sendEvent("onEnd", [:])
|
||||
case .error:
|
||||
// Error will be handled by the finish delegate method
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func player(layer: KSPlayerLayer, currentTime: TimeInterval, totalTime: TimeInterval) {
|
||||
// Debug: Confirm delegate method is being called
|
||||
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 {
|
||||
print("KSPlayerView: [DELEGATE CALLED] time=\(currentTime), total=\(totalTime)")
|
||||
}
|
||||
|
||||
// Manually implement subtitle rendering logic from VideoPlayerView
|
||||
// This is the critical missing piece that was preventing subtitle rendering
|
||||
|
||||
// Debug: Check srtControl state
|
||||
let subtitleInfoCount = playerView.srtControl.subtitleInfos.count
|
||||
let selectedSubtitle = playerView.srtControl.selectedSubtitleInfo
|
||||
|
||||
// Always log subtitle state every 10 seconds to see when it gets populated
|
||||
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 {
|
||||
print("KSPlayerView: [SUBTITLE DEBUG] time=\(currentTime.truncatingRemainder(dividingBy: 10.0)), subtitleInfos=\(subtitleInfoCount), selected=\(selectedSubtitle?.name ?? "none")")
|
||||
|
||||
// Also check if player has subtitle data source
|
||||
let player = layer.player
|
||||
let hasSubtitleDataSource = player.subtitleDataSouce != nil
|
||||
print("KSPlayerView: [SUBTITLE DEBUG] player has subtitle data source: \(hasSubtitleDataSource)")
|
||||
|
||||
// Log subtitle view states
|
||||
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.isHidden: \(playerView.subtitleLabel.isHidden)")
|
||||
print("KSPlayerView: [SUBTITLE DEBUG] subtitleBackView.isHidden: \(playerView.subtitleBackView.isHidden)")
|
||||
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.text: '\(playerView.subtitleLabel.text ?? "nil")'")
|
||||
print("KSPlayerView: [SUBTITLE DEBUG] subtitleLabel.attributedText: \(playerView.subtitleLabel.attributedText != nil ? "exists" : "nil")")
|
||||
print("KSPlayerView: [SUBTITLE DEBUG] subtitleBackView.image: \(playerView.subtitleBackView.image != nil ? "exists" : "nil")")
|
||||
|
||||
// Log all subtitle infos
|
||||
for (index, info) in playerView.srtControl.subtitleInfos.enumerated() {
|
||||
print("KSPlayerView: [SUBTITLE DEBUG] SubtitleInfo[\(index)]: name=\(info.name), isEnabled=\(info.isEnabled)")
|
||||
}
|
||||
}
|
||||
|
||||
let hasSubtitleParts = playerView.srtControl.subtitle(currentTime: currentTime)
|
||||
|
||||
// Debug: Check subtitle timing every 10 seconds
|
||||
if currentTime.truncatingRemainder(dividingBy: 10.0) < 0.1 && subtitleInfoCount > 0 {
|
||||
print("KSPlayerView: [SUBTITLE TIMING] time=\(currentTime), hasParts=\(hasSubtitleParts), partsCount=\(playerView.srtControl.parts.count)")
|
||||
if let firstPart = playerView.srtControl.parts.first {
|
||||
print("KSPlayerView: [SUBTITLE TIMING] firstPart start=\(firstPart.start), end=\(firstPart.end)")
|
||||
print("KSPlayerView: [SUBTITLE TIMING] firstPart text='\(firstPart.text?.string ?? "nil")'")
|
||||
print("KSPlayerView: [SUBTITLE TIMING] firstPart hasImage=\(firstPart.image != nil)")
|
||||
} else {
|
||||
print("KSPlayerView: [SUBTITLE TIMING] No parts available")
|
||||
}
|
||||
}
|
||||
|
||||
if hasSubtitleParts {
|
||||
if let part = playerView.srtControl.parts.first {
|
||||
print("KSPlayerView: [SUBTITLE RENDER] time=\(currentTime), text='\(part.text?.string ?? "nil")', hasImage=\(part.image != nil)")
|
||||
playerView.subtitleBackView.image = part.image
|
||||
|
||||
// Normalize font size for all subtitles to ensure consistent display
|
||||
if let originalText = part.text {
|
||||
let mutableText = NSMutableAttributedString(attributedString: originalText)
|
||||
// Apply consistent font across the entire text
|
||||
let font = UIFont.systemFont(ofSize: 20.0)
|
||||
mutableText.addAttributes([.font: font], range: NSRange(location: 0, length: mutableText.length))
|
||||
playerView.subtitleLabel.attributedText = mutableText
|
||||
} else {
|
||||
playerView.subtitleLabel.attributedText = nil
|
||||
}
|
||||
|
||||
playerView.subtitleBackView.isHidden = false
|
||||
playerView.subtitleLabel.isHidden = false
|
||||
print("KSPlayerView: [SUBTITLE RENDER] Set subtitle text and made views visible")
|
||||
print("KSPlayerView: [SUBTITLE RENDER] subtitleLabel.isHidden after: \(playerView.subtitleLabel.isHidden)")
|
||||
print("KSPlayerView: [SUBTITLE RENDER] subtitleBackView.isHidden after: \(playerView.subtitleBackView.isHidden)")
|
||||
} else {
|
||||
print("KSPlayerView: [SUBTITLE RENDER] hasParts=true but no parts available - hiding views")
|
||||
playerView.subtitleBackView.image = nil
|
||||
playerView.subtitleLabel.attributedText = nil
|
||||
playerView.subtitleBackView.isHidden = true
|
||||
playerView.subtitleLabel.isHidden = true
|
||||
}
|
||||
} else {
|
||||
// Only log this occasionally to avoid spam
|
||||
if currentTime.truncatingRemainder(dividingBy: 30.0) < 0.1 {
|
||||
print("KSPlayerView: [SUBTITLE RENDER] time=\(currentTime), hasParts=false - no subtitle at this time")
|
||||
}
|
||||
}
|
||||
|
||||
let p = layer.player
|
||||
// Ensure we have valid duration before sending progress updates
|
||||
if totalTime > 0 {
|
||||
sendEvent("onProgress", [
|
||||
"currentTime": currentTime,
|
||||
"duration": totalTime,
|
||||
"bufferTime": p.playableTime,
|
||||
"airPlayState": getAirPlayState()
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
func player(layer: KSPlayerLayer, finish error: Error?) {
|
||||
if let error = error {
|
||||
let errorMessage = error.localizedDescription
|
||||
print("KSPlayerView: Player finished with error: \(errorMessage)")
|
||||
|
||||
// Provide more specific error messages for common issues
|
||||
var detailedError = errorMessage
|
||||
if errorMessage.contains("avformat can't open input") {
|
||||
detailedError = "Unable to open video stream. This could be due to:\n• Invalid or malformed URL\n• Network connectivity issues\n• Server blocking the request\n• Unsupported video format\n• Missing required headers"
|
||||
} else if errorMessage.contains("timeout") {
|
||||
detailedError = "Stream connection timed out. The server may be slow or unreachable."
|
||||
} else if errorMessage.contains("404") || errorMessage.contains("Not Found") {
|
||||
detailedError = "Video stream not found. The URL may be expired or incorrect."
|
||||
} else if errorMessage.contains("403") || errorMessage.contains("Forbidden") {
|
||||
detailedError = "Access denied. The server may be blocking requests or require authentication."
|
||||
}
|
||||
|
||||
sendEvent("onError", ["error": detailedError])
|
||||
}
|
||||
}
|
||||
|
||||
func player(layer: KSPlayerLayer, bufferedCount: Int, consumeTime: TimeInterval) {
|
||||
// Handle buffering progress if needed
|
||||
sendEvent("onBufferingProgress", [
|
||||
"bufferedCount": bufferedCount,
|
||||
"consumeTime": consumeTime
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
extension KSPlayerView {
|
||||
private func sendEvent(_ eventName: String, _ body: [String: Any]) {
|
||||
DispatchQueue.main.async {
|
||||
switch eventName {
|
||||
case "onLoad":
|
||||
self.onLoad?(body)
|
||||
case "onProgress":
|
||||
self.onProgress?(body)
|
||||
case "onBuffering":
|
||||
self.onBuffering?(body)
|
||||
case "onEnd":
|
||||
self.onEnd?([:])
|
||||
case "onError":
|
||||
self.onError?(body)
|
||||
case "onBufferingProgress":
|
||||
self.onBufferingProgress?(body)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Renamed to avoid clashing with React's UIView category method
|
||||
private func findHostViewController() -> UIViewController? {
|
||||
var responder: UIResponder? = self
|
||||
while let nextResponder = responder?.next {
|
||||
if let viewController = nextResponder as? UIViewController {
|
||||
return viewController
|
||||
}
|
||||
responder = nextResponder
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
//
|
||||
// KSPlayerViewManager.swift
|
||||
// Nuvio
|
||||
//
|
||||
// Created by KSPlayer integration
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import KSPlayer
|
||||
import React
|
||||
|
||||
@objc(KSPlayerViewManager)
|
||||
class KSPlayerViewManager: RCTViewManager {
|
||||
|
||||
// Not needed for RCTViewManager-based views; events are exported via Objective-C externs in KSPlayerManager.m
|
||||
override func view() -> UIView! {
|
||||
let view = KSPlayerView()
|
||||
view.viewManager = self
|
||||
return view
|
||||
}
|
||||
|
||||
override static func requiresMainQueueSetup() -> Bool {
|
||||
return true
|
||||
}
|
||||
|
||||
override func constantsToExport() -> [AnyHashable : Any]! {
|
||||
return [
|
||||
"EventTypes": [
|
||||
"onLoad": "onLoad",
|
||||
"onProgress": "onProgress",
|
||||
"onBuffering": "onBuffering",
|
||||
"onEnd": "onEnd",
|
||||
"onError": "onError",
|
||||
"onBufferingProgress": "onBufferingProgress"
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
// No-op: events are sent via direct event blocks on the view
|
||||
|
||||
@objc func seek(_ node: NSNumber, toTime time: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.seek(to: TimeInterval(truncating: time))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setSource(_ node: NSNumber, source: NSDictionary) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setSource(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setPaused(_ node: NSNumber, paused: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setPaused(paused)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setVolume(_ node: NSNumber, volume: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setVolume(Float(truncating: volume))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setPlaybackRate(_ node: NSNumber, rate: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setPlaybackRate(Float(truncating: rate))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setAudioTrack(_ node: NSNumber, trackId: NSNumber) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setAudioTrack(Int(truncating: trackId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setTextTrack(_ node: NSNumber, trackId: NSNumber) {
|
||||
NSLog("[KSPlayerViewManager] setTextTrack called - node: %@, trackId: %@", node, trackId)
|
||||
DispatchQueue.main.async {
|
||||
NSLog("[KSPlayerViewManager] setTextTrack on main queue - looking for view with tag: %@", node)
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
NSLog("[KSPlayerViewManager] Found view, calling setTextTrack(%d)", Int(truncating: trackId))
|
||||
view.setTextTrack(Int(truncating: trackId))
|
||||
} else {
|
||||
NSLog("[KSPlayerViewManager] ERROR - Could not find KSPlayerView for tag: %@", node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getTracks(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
let tracks = view.getAvailableTracks()
|
||||
resolve(tracks)
|
||||
} else {
|
||||
reject("NO_VIEW", "KSPlayerView not found", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AirPlay methods
|
||||
@objc func setAllowsExternalPlayback(_ node: NSNumber, allows: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setAllowsExternalPlayback(allows)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func setUsesExternalPlaybackWhileExternalScreenIsActive(_ node: NSNumber, uses: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
view.setUsesExternalPlaybackWhileExternalScreenIsActive(uses)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func getAirPlayState(_ node: NSNumber, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
let airPlayState = view.getAirPlayState()
|
||||
resolve(airPlayState)
|
||||
} else {
|
||||
reject("NO_VIEW", "KSPlayerView not found", nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@objc func showAirPlayPicker(_ node: NSNumber) {
|
||||
print("[KSPlayerViewManager] showAirPlayPicker called for node: \(node)")
|
||||
DispatchQueue.main.async {
|
||||
if let view = self.bridge.uiManager.view(forReactTag: node) as? KSPlayerView {
|
||||
print("[KSPlayerViewManager] Found KSPlayerView, calling showAirPlayPicker")
|
||||
view.showAirPlayPicker()
|
||||
} else {
|
||||
print("[KSPlayerViewManager] Could not find KSPlayerView for node: \(node)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -11,12 +11,12 @@
|
|||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
2AA769395C1242F225F875AF /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E007C0BAC8C453623E81663 /* ExpoModulesProvider.swift */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
72D4090694139E0DAD9B066E /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 406CD0CEF46F8EAEEAD92BF7 /* libPods-Nuvio.a */; };
|
||||
9FBA88F42E86ECD700892850 /* KSPlayerViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */; };
|
||||
9FBA88F52E86ECD700892850 /* KSPlayerModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */; };
|
||||
9FBA88F62E86ECD700892850 /* KSPlayerManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F02E86ECD700892850 /* KSPlayerManager.m */; };
|
||||
9FBA88F72E86ECD700892850 /* KSPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F22E86ECD700892850 /* KSPlayerView.swift */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
D66ACCC72CB69F1FF14A2585 /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */; };
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F11748412D0307B40044C1D9 /* AppDelegate.swift */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
|
|
@ -24,16 +24,16 @@
|
|||
13B07F961A680F5B00A75B9A /* Nuvio.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nuvio.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Nuvio/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Nuvio/Info.plist; sourceTree = "<group>"; };
|
||||
15864A7148A4384BAA9F0B37 /* Pods-Nuvio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.release.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.release.xcconfig"; sourceTree = "<group>"; };
|
||||
406CD0CEF46F8EAEEAD92BF7 /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
49055D6E250FAFA21141FE49 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Nuvio/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.release.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.release.xcconfig"; sourceTree = "<group>"; };
|
||||
6E007C0BAC8C453623E81663 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Nuvio/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
73BB213C2E9EEAC700EC03F8 /* NuvioRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NuvioRelease.entitlements; path = Nuvio/NuvioRelease.entitlements; sourceTree = "<group>"; };
|
||||
819F6DCD44DFE0C72440FDCF /* Pods-Nuvio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.debug.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9FBA88F02E86ECD700892850 /* KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = KSPlayerManager.m; sourceTree = "<group>"; };
|
||||
9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerModule.swift; sourceTree = "<group>"; };
|
||||
9FBA88F22E86ECD700892850 /* KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerView.swift; sourceTree = "<group>"; };
|
||||
9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KSPlayerViewManager.swift; sourceTree = "<group>"; };
|
||||
904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Nuvio.debug.xcconfig"; path = "Target Support Files/Pods-Nuvio/Pods-Nuvio.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
9FBA88F02E86ECD700892850 /* KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ../KSPlayer/RNBridge/KSPlayerManager.m; sourceTree = "<group>"; };
|
||||
9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerModule.swift; sourceTree = "<group>"; };
|
||||
9FBA88F22E86ECD700892850 /* KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerView.swift; sourceTree = "<group>"; };
|
||||
9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerViewManager.swift; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Nuvio/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
72D4090694139E0DAD9B066E /* libPods-Nuvio.a in Frameworks */,
|
||||
D66ACCC72CB69F1FF14A2585 /* libPods-Nuvio.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
406CD0CEF46F8EAEEAD92BF7 /* libPods-Nuvio.a */,
|
||||
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -131,8 +131,8 @@
|
|||
D90A3959C97EE9926C513293 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
819F6DCD44DFE0C72440FDCF /* Pods-Nuvio.debug.xcconfig */,
|
||||
15864A7148A4384BAA9F0B37 /* Pods-Nuvio.release.xcconfig */,
|
||||
904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */,
|
||||
5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -152,15 +152,15 @@
|
|||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Nuvio" */;
|
||||
buildPhases = (
|
||||
E060D0359630A7C0F4793812 /* [CP] Check Pods Manifest.lock */,
|
||||
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */,
|
||||
99A79B70155E84EE1FB7F466 /* [Expo] Configure project */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
9B977D89FE30470F8C59964C /* Upload Debug Symbols to Sentry */,
|
||||
B84E40F81DF927F34032D68B /* [CP] Embed Pods Frameworks */,
|
||||
AA47AE6072D35F0490B53926 /* [CP] Copy Pods Resources */,
|
||||
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */,
|
||||
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
|
|
@ -234,45 +234,29 @@
|
|||
shellPath = /bin/sh;
|
||||
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n/bin/sh `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\"` `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
|
||||
};
|
||||
99A79B70155E84EE1FB7F466 /* [Expo] Configure project */ = {
|
||||
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
"$(SRCROOT)/Nuvio/Nuvio.entitlements",
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Nuvio/expo-configure-project.sh",
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Nuvio/ExpoModulesProvider.swift",
|
||||
"$(DERIVED_FILE_DIR)/Pods-Nuvio-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Nuvio/expo-configure-project.sh\"\n";
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9B977D89FE30470F8C59964C /* Upload Debug Symbols to Sentry */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Upload Debug Symbols to Sentry";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
||||
};
|
||||
AA47AE6072D35F0490B53926 /* [CP] Copy Pods Resources */ = {
|
||||
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -384,7 +368,45 @@
|
|||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Nuvio/Pods-Nuvio-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
B84E40F81DF927F34032D68B /* [CP] Embed Pods Frameworks */ = {
|
||||
99A79B70155E84EE1FB7F466 /* [Expo] Configure project */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"$(SRCROOT)/.xcode.env",
|
||||
"$(SRCROOT)/.xcode.env.local",
|
||||
"$(SRCROOT)/Nuvio/Nuvio.entitlements",
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Nuvio/expo-configure-project.sh",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(SRCROOT)/Pods/Target Support Files/Pods-Nuvio/ExpoModulesProvider.swift",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Nuvio/expo-configure-project.sh\"\n";
|
||||
};
|
||||
9B977D89FE30470F8C59964C /* Upload Debug Symbols to Sentry */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputPaths = (
|
||||
);
|
||||
name = "Upload Debug Symbols to Sentry";
|
||||
outputPaths = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
||||
};
|
||||
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -406,28 +428,6 @@
|
|||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Nuvio/Pods-Nuvio-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
E060D0359630A7C0F4793812 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Nuvio-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
|
|
@ -449,7 +449,7 @@
|
|||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 819F6DCD44DFE0C72440FDCF /* Pods-Nuvio.debug.xcconfig */;
|
||||
baseConfigurationReference = 904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
|
@ -487,7 +487,7 @@
|
|||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 15864A7148A4384BAA9F0B37 /* Pods-Nuvio.release.xcconfig */;
|
||||
baseConfigurationReference = 5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
|
|
|
|||
|
|
@ -50,8 +50,9 @@ target 'Nuvio' do
|
|||
)
|
||||
|
||||
# KSPlayer dependencies
|
||||
pod 'KSPlayer', :git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main'
|
||||
pod 'DisplayCriteria', :git => 'https://github.com/kingslay/KSPlayer.git', :branch => 'main', :modular_headers => true
|
||||
# Use the local checkout so we can patch subtitle rendering (and other behaviors) without forking.
|
||||
pod 'KSPlayer', :path => '../KSPlayer'
|
||||
pod 'DisplayCriteria', :path => '../KSPlayer', :modular_headers => true
|
||||
pod 'FFmpegKit', :git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main', :modular_headers => true
|
||||
pod 'Libass', :git => 'https://github.com/kingslay/FFmpegKit.git', :branch => 'main'
|
||||
|
||||
|
|
|
|||
|
|
@ -1902,6 +1902,30 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-skia (2.4.14):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React
|
||||
- React-callinvoker
|
||||
- React-Core
|
||||
- React-Core-prebuilt
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-slider (5.1.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
|
|
@ -2736,7 +2760,7 @@ PODS:
|
|||
- Yoga (0.0.0)
|
||||
|
||||
DEPENDENCIES:
|
||||
- DisplayCriteria (from `https://github.com/kingslay/KSPlayer.git`, branch `main`)
|
||||
- DisplayCriteria (from `../KSPlayer`)
|
||||
- EASClient (from `../node_modules/expo-eas-client/ios`)
|
||||
- EXApplication (from `../node_modules/expo-application/ios`)
|
||||
- EXConstants (from `../node_modules/expo-constants/ios`)
|
||||
|
|
@ -2776,7 +2800,7 @@ DEPENDENCIES:
|
|||
- FFmpegKit (from `https://github.com/kingslay/FFmpegKit.git`, branch `main`)
|
||||
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
|
||||
- ImageColors (from `../node_modules/react-native-image-colors/ios`)
|
||||
- KSPlayer (from `https://github.com/kingslay/KSPlayer.git`, branch `main`)
|
||||
- KSPlayer (from `../KSPlayer`)
|
||||
- Libass (from `https://github.com/kingslay/FFmpegKit.git`, branch `main`)
|
||||
- lottie-react-native (from `../node_modules/lottie-react-native`)
|
||||
- NitroMmkv (from `../node_modules/react-native-mmkv`)
|
||||
|
|
@ -2822,6 +2846,7 @@ DEPENDENCIES:
|
|||
- react-native-google-cast (from `../node_modules/react-native-google-cast`)
|
||||
- "react-native-netinfo (from `../node_modules/@react-native-community/netinfo`)"
|
||||
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
|
||||
- "react-native-skia (from `../node_modules/@shopify/react-native-skia`)"
|
||||
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
|
||||
- react-native-video (from `../node_modules/react-native-video`)
|
||||
- React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||
|
|
@ -2885,8 +2910,7 @@ SPEC REPOS:
|
|||
|
||||
EXTERNAL SOURCES:
|
||||
DisplayCriteria:
|
||||
:branch: main
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
:path: "../KSPlayer"
|
||||
EASClient:
|
||||
:path: "../node_modules/expo-eas-client/ios"
|
||||
EXApplication:
|
||||
|
|
@ -2968,8 +2992,7 @@ EXTERNAL SOURCES:
|
|||
ImageColors:
|
||||
:path: "../node_modules/react-native-image-colors/ios"
|
||||
KSPlayer:
|
||||
:branch: main
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
:path: "../KSPlayer"
|
||||
Libass:
|
||||
:branch: main
|
||||
:git: https://github.com/kingslay/FFmpegKit.git
|
||||
|
|
@ -3059,6 +3082,8 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/@react-native-community/netinfo"
|
||||
react-native-safe-area-context:
|
||||
:path: "../node_modules/react-native-safe-area-context"
|
||||
react-native-skia:
|
||||
:path: "../node_modules/@shopify/react-native-skia"
|
||||
react-native-slider:
|
||||
:path: "../node_modules/@react-native-community/slider"
|
||||
react-native-video:
|
||||
|
|
@ -3147,15 +3172,9 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/react-native/ReactCommon/yoga"
|
||||
|
||||
CHECKOUT OPTIONS:
|
||||
DisplayCriteria:
|
||||
:commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
FFmpegKit:
|
||||
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
|
||||
:git: https://github.com/kingslay/FFmpegKit.git
|
||||
KSPlayer:
|
||||
:commit: 101cceed0f2d9b6833ee69cf29b65a042de720a3
|
||||
:git: https://github.com/kingslay/KSPlayer.git
|
||||
Libass:
|
||||
:commit: d7048037a2eb94a3b08113fbf43aa92bdcb332d9
|
||||
:git: https://github.com/kingslay/FFmpegKit.git
|
||||
|
|
@ -3254,6 +3273,7 @@ SPEC CHECKSUMS:
|
|||
react-native-google-cast: 7be68a5d0b7eeb95a5924c3ecef8d319ef6c0a44
|
||||
react-native-netinfo: cec9c4e86083cb5b6aba0e0711f563e2fbbff187
|
||||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
react-native-skia: 268f183f849742e9da216743ee234bd7ad81c69b
|
||||
react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1
|
||||
react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58
|
||||
React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438
|
||||
|
|
@ -3304,6 +3324,6 @@ SPEC CHECKSUMS:
|
|||
SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d
|
||||
Yoga: 051f086b5ccf465ff2ed38a2cf5a558ae01aaaa1
|
||||
|
||||
PODFILE CHECKSUM: 7c74c9cd2c7f3df7ab68b4284d9f324282e54542
|
||||
PODFILE CHECKSUM: b884d1ff07ac4a43323bce2e2e1342592513858c
|
||||
|
||||
COCOAPODS: 1.16.2
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
{
|
||||
"expo.jsEngine": "hermes",
|
||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||
"newArchEnabled": "true"
|
||||
}
|
||||
"newArchEnabled": "true",
|
||||
"ios.deploymentTarget": "16.0"
|
||||
}
|
||||
81
node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
generated
vendored
Normal file
81
node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
generated
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
package com.brentvatne.common.api
|
||||
|
||||
import android.graphics.Color
|
||||
import com.brentvatne.common.toolbox.ReactBridgeUtils
|
||||
import com.facebook.react.bridge.ReadableMap
|
||||
|
||||
/**
|
||||
* Helper file to parse SubtitleStyle prop and build a dedicated class
|
||||
*/
|
||||
class SubtitleStyle public constructor() {
|
||||
var fontSize = -1
|
||||
private set
|
||||
var paddingLeft = 0
|
||||
private set
|
||||
var paddingRight = 0
|
||||
private set
|
||||
var paddingTop = 0
|
||||
private set
|
||||
var paddingBottom = 0
|
||||
private set
|
||||
var opacity = 1f
|
||||
private set
|
||||
var subtitlesFollowVideo = true
|
||||
private set
|
||||
|
||||
// Extended styling (used by ExoPlayerView via Media3 SubtitleView)
|
||||
// Stored as Android color ints to avoid parsing multiple times.
|
||||
var textColor: Int? = null
|
||||
private set
|
||||
var backgroundColor: Int? = null
|
||||
private set
|
||||
var edgeType: String? = null
|
||||
private set
|
||||
var edgeColor: Int? = null
|
||||
private set
|
||||
|
||||
companion object {
|
||||
private const val PROP_FONT_SIZE_TRACK = "fontSize"
|
||||
private const val PROP_PADDING_BOTTOM = "paddingBottom"
|
||||
private const val PROP_PADDING_TOP = "paddingTop"
|
||||
private const val PROP_PADDING_LEFT = "paddingLeft"
|
||||
private const val PROP_PADDING_RIGHT = "paddingRight"
|
||||
private const val PROP_OPACITY = "opacity"
|
||||
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
|
||||
|
||||
// Extended props (optional)
|
||||
private const val PROP_TEXT_COLOR = "textColor"
|
||||
private const val PROP_BACKGROUND_COLOR = "backgroundColor"
|
||||
private const val PROP_EDGE_TYPE = "edgeType"
|
||||
private const val PROP_EDGE_COLOR = "edgeColor"
|
||||
|
||||
private fun parseColorOrNull(value: String?): Int? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
return try {
|
||||
Color.parseColor(value)
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
fun parse(src: ReadableMap?): SubtitleStyle {
|
||||
val subtitleStyle = SubtitleStyle()
|
||||
subtitleStyle.fontSize = ReactBridgeUtils.safeGetInt(src, PROP_FONT_SIZE_TRACK, -1)
|
||||
subtitleStyle.paddingBottom = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_BOTTOM, 0)
|
||||
subtitleStyle.paddingTop = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_TOP, 0)
|
||||
subtitleStyle.paddingLeft = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_LEFT, 0)
|
||||
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
|
||||
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
|
||||
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
|
||||
|
||||
// Extended styling
|
||||
subtitleStyle.textColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_TEXT_COLOR, null))
|
||||
subtitleStyle.backgroundColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_BACKGROUND_COLOR, null))
|
||||
subtitleStyle.edgeType = ReactBridgeUtils.safeGetString(src, PROP_EDGE_TYPE, null)
|
||||
subtitleStyle.edgeColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_EDGE_COLOR, null))
|
||||
|
||||
return subtitleStyle
|
||||
}
|
||||
}
|
||||
}
|
||||
441
node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
generated
vendored
Normal file
441
node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
generated
vendored
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
package com.brentvatne.exoplayer
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.view.View.MeasureSpec
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.CaptionStyleCompat
|
||||
import androidx.media3.ui.DefaultTimeBar
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import com.brentvatne.common.api.ResizeMode
|
||||
import com.brentvatne.common.api.SubtitleStyle
|
||||
|
||||
@UnstableApi
|
||||
class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
|
||||
FrameLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private var localStyle = SubtitleStyle()
|
||||
private var pendingResizeMode: Int? = null
|
||||
private val liveBadge: TextView = TextView(context).apply {
|
||||
text = "LIVE"
|
||||
setTextColor(Color.WHITE)
|
||||
textSize = 12f
|
||||
val drawable = GradientDrawable()
|
||||
drawable.setColor(Color.RED)
|
||||
drawable.cornerRadius = 6f
|
||||
background = drawable
|
||||
setPadding(12, 4, 12, 4)
|
||||
visibility = View.GONE
|
||||
}
|
||||
|
||||
private val playerView = PlayerView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
setShutterBackgroundColor(Color.TRANSPARENT)
|
||||
useController = true
|
||||
controllerAutoShow = true
|
||||
controllerHideOnTouch = true
|
||||
controllerShowTimeoutMs = 5000
|
||||
// Don't show subtitle button by default - will be enabled when tracks are available
|
||||
setShowSubtitleButton(false)
|
||||
// Enable proper surface view handling to prevent rendering issues
|
||||
setUseArtwork(false)
|
||||
setDefaultArtwork(null)
|
||||
// Ensure proper video scaling - start with FIT mode
|
||||
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
/**
|
||||
* Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
|
||||
* This keeps subtitles anchored in-place even when the video surface/content frame moves
|
||||
* due to aspect ratio / resizeMode changes.
|
||||
*
|
||||
* Controlled by SubtitleStyle.subtitlesFollowVideo.
|
||||
*/
|
||||
private val overlaySubtitleView = SubtitleView(context).apply {
|
||||
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
visibility = View.GONE
|
||||
// We control styling via SubtitleStyle; don't pull Android system caption defaults.
|
||||
setApplyEmbeddedStyles(true)
|
||||
setApplyEmbeddedFontSizes(true)
|
||||
}
|
||||
|
||||
private fun updateSubtitleRenderingMode() {
|
||||
val internalSubtitleView = playerView.subtitleView
|
||||
val followVideo = localStyle.subtitlesFollowVideo
|
||||
val shouldShow = localStyle.opacity != 0.0f
|
||||
|
||||
if (followVideo) {
|
||||
internalSubtitleView?.visibility = if (shouldShow) View.VISIBLE else View.GONE
|
||||
overlaySubtitleView.visibility = View.GONE
|
||||
} else {
|
||||
// Hard-disable PlayerView's internal subtitle view. PlayerView can recreate/toggle this view
|
||||
// during resize/layout, so we re-assert this in multiple lifecycle points.
|
||||
internalSubtitleView?.visibility = View.GONE
|
||||
internalSubtitleView?.alpha = 0f
|
||||
overlaySubtitleView.visibility = if (shouldShow) View.VISIBLE else View.GONE
|
||||
overlaySubtitleView.alpha = 1f
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
// Add PlayerView with explicit layout parameters
|
||||
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(playerView, playerViewLayoutParams)
|
||||
|
||||
// Add overlay subtitles above PlayerView (so it doesn't move with video content frame)
|
||||
val subtitleOverlayLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
||||
addView(overlaySubtitleView, subtitleOverlayLayoutParams)
|
||||
|
||||
// Add live badge with its own layout parameters
|
||||
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
|
||||
liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
|
||||
addView(liveBadge, liveBadgeLayoutParams)
|
||||
|
||||
// PlayerView may internally recreate its subtitle view during relayouts (e.g. resizeMode changes).
|
||||
// Ensure our rendering mode is re-applied whenever PlayerView lays out.
|
||||
playerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
|
||||
updateSubtitleRenderingMode()
|
||||
}
|
||||
}
|
||||
|
||||
fun setPlayer(player: ExoPlayer?) {
|
||||
val currentPlayer = playerView.player
|
||||
|
||||
if (currentPlayer != null) {
|
||||
currentPlayer.removeListener(playerListener)
|
||||
}
|
||||
|
||||
playerView.player = player
|
||||
|
||||
if (player != null) {
|
||||
player.addListener(playerListener)
|
||||
|
||||
// Apply pending resize mode if we have one
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
|
||||
// Re-assert subtitle rendering mode for the current style.
|
||||
updateSubtitleRenderingMode()
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
|
||||
fun getPlayerView(): PlayerView = playerView
|
||||
|
||||
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
|
||||
val targetResizeMode = when (resizeMode) {
|
||||
ResizeMode.RESIZE_MODE_FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
|
||||
ResizeMode.RESIZE_MODE_CENTER_CROP -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
ResizeMode.RESIZE_MODE_FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
ResizeMode.RESIZE_MODE_FIXED_WIDTH -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
|
||||
ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
|
||||
else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
}
|
||||
|
||||
// Apply the resize mode to PlayerView immediately
|
||||
playerView.resizeMode = targetResizeMode
|
||||
|
||||
// Store it for reapplication if needed
|
||||
pendingResizeMode = targetResizeMode
|
||||
|
||||
// Force PlayerView to recalculate its layout
|
||||
playerView.requestLayout()
|
||||
|
||||
// Also request layout on the parent to ensure proper sizing
|
||||
requestLayout()
|
||||
}
|
||||
|
||||
fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
localStyle = style
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
|
||||
private fun applySubtitleStyle(style: SubtitleStyle) {
|
||||
updateSubtitleRenderingMode()
|
||||
|
||||
playerView.subtitleView?.let { subtitleView ->
|
||||
// Important:
|
||||
// Avoid inheriting Android system caption settings via setUserDefaultStyle(),
|
||||
// because those can force a background/window that the app doesn't want.
|
||||
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
|
||||
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
|
||||
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
|
||||
|
||||
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
|
||||
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
else -> CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
}
|
||||
|
||||
// windowColor MUST be transparent to avoid the "caption window" background.
|
||||
val captionStyle = CaptionStyleCompat(
|
||||
resolvedTextColor,
|
||||
resolvedBackgroundColor,
|
||||
Color.TRANSPARENT,
|
||||
resolvedEdgeType,
|
||||
resolvedEdgeColor,
|
||||
null
|
||||
)
|
||||
subtitleView.setStyle(captionStyle)
|
||||
|
||||
// Text size: if not provided, fall back to user default size.
|
||||
if (style.fontSize > 0) {
|
||||
// Use DIP so the value matches React Native's dp-based fontSize more closely.
|
||||
// SP would multiply by system fontScale and makes "30" look larger than RN "30".
|
||||
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
|
||||
} else {
|
||||
subtitleView.setUserDefaultTextSize()
|
||||
}
|
||||
|
||||
// Horizontal padding is still useful (safe area); vertical offset is handled via bottomPaddingFraction.
|
||||
subtitleView.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingRight,
|
||||
0
|
||||
)
|
||||
|
||||
// Bottom offset for *internal* subtitles:
|
||||
// Use Media3 SubtitleView's bottomPaddingFraction (moves cues up) rather than raw view padding.
|
||||
if (style.paddingBottom > 0 && playerView.height > 0) {
|
||||
val fraction = (style.paddingBottom.toFloat() / playerView.height.toFloat())
|
||||
.coerceIn(0f, 0.9f)
|
||||
subtitleView.setBottomPaddingFraction(fraction)
|
||||
}
|
||||
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleView.alpha = style.opacity
|
||||
subtitleView.visibility = android.view.View.VISIBLE
|
||||
} else {
|
||||
subtitleView.visibility = android.view.View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
// Apply the same styling to the overlay subtitle view.
|
||||
run {
|
||||
val subtitleView = overlaySubtitleView
|
||||
|
||||
val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
|
||||
val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
|
||||
val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
|
||||
|
||||
val resolvedEdgeType = when (style.edgeType?.lowercase()) {
|
||||
"outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
|
||||
"shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
|
||||
else -> CaptionStyleCompat.EDGE_TYPE_NONE
|
||||
}
|
||||
|
||||
val captionStyle = CaptionStyleCompat(
|
||||
resolvedTextColor,
|
||||
resolvedBackgroundColor,
|
||||
Color.TRANSPARENT,
|
||||
resolvedEdgeType,
|
||||
resolvedEdgeColor,
|
||||
null
|
||||
)
|
||||
subtitleView.setStyle(captionStyle)
|
||||
|
||||
if (style.fontSize > 0) {
|
||||
// Use DIP so the value matches React Native's dp-based fontSize more closely.
|
||||
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
|
||||
} else {
|
||||
subtitleView.setUserDefaultTextSize()
|
||||
}
|
||||
|
||||
subtitleView.setPadding(
|
||||
style.paddingLeft,
|
||||
style.paddingTop,
|
||||
style.paddingRight,
|
||||
0
|
||||
)
|
||||
|
||||
// Bottom offset relative to the full view height (stable even when video content frame moves).
|
||||
val h = height.takeIf { it > 0 } ?: subtitleView.height
|
||||
if (style.paddingBottom > 0 && h > 0) {
|
||||
val fraction = (style.paddingBottom.toFloat() / h.toFloat())
|
||||
.coerceIn(0f, 0.9f)
|
||||
subtitleView.setBottomPaddingFraction(fraction)
|
||||
} else {
|
||||
subtitleView.setBottomPaddingFraction(0f)
|
||||
}
|
||||
|
||||
if (style.opacity != 0.0f) {
|
||||
subtitleView.alpha = style.opacity
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun setShutterColor(color: Int) {
|
||||
playerView.setShutterBackgroundColor(color)
|
||||
}
|
||||
|
||||
fun updateSurfaceView(viewType: Int) {
|
||||
// TODO: Implement proper surface type switching if needed
|
||||
}
|
||||
|
||||
val isPlaying: Boolean
|
||||
get() = playerView.player?.isPlaying ?: false
|
||||
|
||||
fun invalidateAspectRatio() {
|
||||
// PlayerView handles aspect ratio automatically through its internal AspectRatioFrameLayout
|
||||
playerView.requestLayout()
|
||||
|
||||
// Reapply the current resize mode to ensure it's properly set
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
|
||||
fun setUseController(useController: Boolean) {
|
||||
playerView.useController = useController
|
||||
if (useController) {
|
||||
// Ensure proper touch handling when controls are enabled
|
||||
playerView.controllerAutoShow = true
|
||||
playerView.controllerHideOnTouch = true
|
||||
// Show controls immediately when enabled
|
||||
playerView.showController()
|
||||
}
|
||||
}
|
||||
|
||||
fun showController() {
|
||||
playerView.showController()
|
||||
}
|
||||
|
||||
fun hideController() {
|
||||
playerView.hideController()
|
||||
}
|
||||
|
||||
fun setControllerShowTimeoutMs(showTimeoutMs: Int) {
|
||||
playerView.controllerShowTimeoutMs = showTimeoutMs
|
||||
}
|
||||
|
||||
fun setControllerAutoShow(autoShow: Boolean) {
|
||||
playerView.controllerAutoShow = autoShow
|
||||
}
|
||||
|
||||
fun setControllerHideOnTouch(hideOnTouch: Boolean) {
|
||||
playerView.controllerHideOnTouch = hideOnTouch
|
||||
}
|
||||
|
||||
fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
|
||||
playerView.setFullscreenButtonClickListener(listener)
|
||||
}
|
||||
|
||||
fun setShowSubtitleButton(show: Boolean) {
|
||||
playerView.setShowSubtitleButton(show)
|
||||
}
|
||||
|
||||
fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
|
||||
|
||||
fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
|
||||
playerView.setControllerVisibilityListener(listener)
|
||||
}
|
||||
|
||||
override fun addOnLayoutChangeListener(listener: View.OnLayoutChangeListener) {
|
||||
playerView.addOnLayoutChangeListener(listener)
|
||||
}
|
||||
|
||||
override fun setFocusable(focusable: Boolean) {
|
||||
playerView.isFocusable = focusable
|
||||
}
|
||||
|
||||
private fun updateLiveUi() {
|
||||
val player = playerView.player ?: return
|
||||
val isLive = player.isCurrentMediaItemLive
|
||||
val seekable = player.isCurrentMediaItemSeekable
|
||||
|
||||
// Show/hide badge
|
||||
liveBadge.visibility = if (isLive) View.VISIBLE else View.GONE
|
||||
|
||||
// Disable/enable scrubbing based on seekable
|
||||
val timeBar = playerView.findViewById<DefaultTimeBar?>(androidx.media3.ui.R.id.exo_progress)
|
||||
timeBar?.isEnabled = !isLive || seekable
|
||||
}
|
||||
|
||||
private val playerListener = object : Player.Listener {
|
||||
override fun onCues(cueGroup: CueGroup) {
|
||||
// Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering.
|
||||
// When subtitlesFollowVideo=false, overlaySubtitleView is the visible one.
|
||||
updateSubtitleRenderingMode()
|
||||
overlaySubtitleView.setCues(cueGroup.cues)
|
||||
}
|
||||
|
||||
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
|
||||
playerView.post {
|
||||
playerView.requestLayout()
|
||||
// Reapply resize mode to ensure it's properly set after timeline changes
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
}
|
||||
updateLiveUi()
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) ||
|
||||
events.contains(Player.EVENT_IS_PLAYING_CHANGED)
|
||||
) {
|
||||
updateLiveUi()
|
||||
}
|
||||
|
||||
// Handle video size changes which affect aspect ratio
|
||||
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
playerView.requestLayout()
|
||||
requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerView"
|
||||
}
|
||||
|
||||
/**
|
||||
* React Native (Yoga) can sometimes defer layout passes that are required by
|
||||
* PlayerView for its child views (controller overlay, surface view, subtitle view, …).
|
||||
* This helper forces a second measure / layout after RN finishes, ensuring the
|
||||
* internal views receive the final size. The same approach is used in the v7
|
||||
* implementation (see VideoView.kt) and in React Native core (Toolbar example [link]).
|
||||
*/
|
||||
private val layoutRunnable = Runnable {
|
||||
measure(
|
||||
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
|
||||
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
|
||||
)
|
||||
layout(left, top, right, bottom)
|
||||
}
|
||||
|
||||
override fun requestLayout() {
|
||||
super.requestLayout()
|
||||
// Post a second layout pass so the ExoPlayer internal views get correct bounds.
|
||||
post(layoutRunnable)
|
||||
}
|
||||
|
||||
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
|
||||
super.onLayout(changed, left, top, right, bottom)
|
||||
|
||||
if (changed) {
|
||||
pendingResizeMode?.let { resizeMode ->
|
||||
playerView.resizeMode = resizeMode
|
||||
}
|
||||
// Re-apply bottomPaddingFraction once we have a concrete height.
|
||||
updateSubtitleRenderingMode()
|
||||
applySubtitleStyle(localStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,6 +30,30 @@
|
|||
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.3.5",
|
||||
"buildVersion": "33",
|
||||
"date": "2026-01-09",
|
||||
"localizedDescription": "## Update Notes\n\n### ExoPlayer Subtitle Fixes\nThis update mainly focuses on fixing multiple subtitle-related issues in ExoPlayer:\n- Fixed issue where **only the first subtitle index** was being rendered \n- Fixed subtitles **always showing background** due to Android native caption settings \n- Fixed subtitles being **rendered inside the player view** \n- Fixed **subtitle bottom offset** issues \n- Merged PR **#391** by **@saifshaikh1805** \n - Fixes issue **#301**\n\n### KSPlayer Improvements\n- Improved **subtitle behavior** in KSPlayer \n- Merged PR **#394** by **@AdityasahuX07**\n\n### Settings & Persistence\n- Implemented **save and load** functionality for **Discover settings**\n\nThis release improves subtitle rendering accuracy across players and adds better persistence for user preferences.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.5/app-release.apk",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.3.4",
|
||||
"buildVersion": "32",
|
||||
"date": "2026-01-06",
|
||||
"localizedDescription": "## Update Notes\n\n### Player & Playback\n- Fixed **Android player crashes with large files** when using ExoPlayer \n - Merged PR **#361** by **@chrisk325**\n\n### Trakt Improvements\n- Improved **Trakt Continue Watching** section for better accuracy and reliability\n\n### Internationalization\n- Added **multi-language support** across the app using **i18n** \n - More languages will be added through **community contributions** \n - ⚠️ **Arabic UI does not use RTL yet**. RTL support will be added in a future update\n\n### Stability & Fixes\n- Crash optimizations and internal stability improvements\n\nThis update focuses on improving playback stability, Trakt experience, and expanding language support.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.4/app-release.apk",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.3.3",
|
||||
"buildVersion": "31",
|
||||
"date": "2026-01-01",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.3.3\n\n## Update Notes\n\n### Playback & Preferences\n- Added **default audio and subtitle track selection**\n\n### Plugins & Repositories\n- Added support for **multiple active repositories**\n- Improved **plugin fetch logic** for better reliability and performance\n- Changed OTA server.\n\n### Trakt & Metadata Fixes\n- Fixed **TMDB enrichment logic**\n- Fixed **Trakt watch progress not syncing** for older seasons \n - Contributed by **@chrisk325** \n - Fixes #331 and closes #233 \n - ⚠️ It is recommended to **log out and log back into Trakt** inside the app to correctly reflect watched status for older seasons\n\n### UI Improvements & Bug Fixes\n- Minor UI refinements and bug fixes \n- Added **YouTube-style press-and-hold playback speed indicator** \n- Refined **gesture indicator pill** \n- Fixed **OSC not auto-hiding on Android** \n - Contributed by **@AdityasahuX07** \n - Fixes #326 and #298 \n\nThis release includes valuable contributions from the community and focuses on improving playback preferences, plugin handling, Trakt syncing, and overall UI polish.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/1.3.3/Stable_1-3-3.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"version": "1.3.2",
|
||||
"buildVersion": "30",
|
||||
|
|
|
|||
166
package-lock.json
generated
166
package-lock.json
generated
|
|
@ -29,6 +29,7 @@
|
|||
"@react-navigation/stack": "^7.2.10",
|
||||
"@sentry/react-native": "^7.6.0",
|
||||
"@shopify/flash-list": "^2.2.0",
|
||||
"@shopify/react-native-skia": "^2.4.14",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
"axios": "^1.12.2",
|
||||
|
|
@ -63,10 +64,14 @@
|
|||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"i18next": "^25.7.3",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"posthog-react-native": "^4.4.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-boost": "^0.6.2",
|
||||
"react-native-bottom-tabs": "^1.0.2",
|
||||
|
|
@ -86,7 +91,7 @@
|
|||
"react-native-svg": "^15.12.1",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-video": "^6.17.0",
|
||||
"react-native-video": "6.18.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-wheel-color-picker": "^1.3.1",
|
||||
"react-native-worklets": "^0.7.1"
|
||||
|
|
@ -100,7 +105,7 @@
|
|||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"xcode": "^3.0.1"
|
||||
}
|
||||
},
|
||||
|
|
@ -3642,6 +3647,33 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@shopify/react-native-skia": {
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.14.tgz",
|
||||
"integrity": "sha512-zFxjAQbfrdOxoNJoaOCZQzZliuAWXjFkrNZv2PtofG2RAUPWIxWmk2J/oOROpTwXgkmh1JLvFp3uONccTXUthQ==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"canvaskit-wasm": "0.40.0",
|
||||
"react-reconciler": "0.31.0"
|
||||
},
|
||||
"bin": {
|
||||
"setup-skia-web": "scripts/setup-canvaskit.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19.0",
|
||||
"react-native": ">=0.78",
|
||||
"react-native-reanimated": ">=3.19.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native-reanimated": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@sinclair/typebox": {
|
||||
"version": "0.27.8",
|
||||
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz",
|
||||
|
|
@ -4304,6 +4336,12 @@
|
|||
"url": "https://github.com/sponsors/crutchcorn"
|
||||
}
|
||||
},
|
||||
"node_modules/@webgpu/types": {
|
||||
"version": "0.1.21",
|
||||
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.21.tgz",
|
||||
"integrity": "sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/@xmldom/xmldom": {
|
||||
"version": "0.8.11",
|
||||
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
|
||||
|
|
@ -5158,6 +5196,15 @@
|
|||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvaskit-wasm": {
|
||||
"version": "0.40.0",
|
||||
"resolved": "https://registry.npmjs.org/canvaskit-wasm/-/canvaskit-wasm-0.40.0.tgz",
|
||||
"integrity": "sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@webgpu/types": "0.1.21"
|
||||
}
|
||||
},
|
||||
"node_modules/caseless": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
|
||||
|
|
@ -7462,6 +7509,15 @@
|
|||
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/html-parse-stringify": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"void-elements": "3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/htmlparser2-without-node-native": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/htmlparser2-without-node-native/-/htmlparser2-without-node-native-3.9.2.tgz",
|
||||
|
|
@ -7573,6 +7629,37 @@
|
|||
"integrity": "sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "25.7.3",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-25.7.3.tgz",
|
||||
"integrity": "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://locize.com/i18next.html"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
|
|
@ -7700,6 +7787,12 @@
|
|||
"css-in-js-utils": "^3.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/intl-pluralrules": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/intl-pluralrules/-/intl-pluralrules-2.0.1.tgz",
|
||||
"integrity": "sha512-astxTLzIdXPeN0K9Rumi6LfMpm3rvNO0iJE+h/k8Kr/is+wPbRe4ikyDjlLr6VTh/mEfNv8RjN+gu3KwDiuhqg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
|
|
@ -10482,6 +10575,18 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-freeze": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/react-freeze/-/react-freeze-1.0.4.tgz",
|
||||
|
|
@ -10494,6 +10599,33 @@
|
|||
"react": ">=17.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "16.5.1",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-16.5.1.tgz",
|
||||
"integrity": "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"html-parse-stringify": "^3.0.1",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 25.6.2",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"react-native": {
|
||||
"optional": true
|
||||
},
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
|
||||
|
|
@ -11307,6 +11439,27 @@
|
|||
"async-limiter": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler": {
|
||||
"version": "0.31.0",
|
||||
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz",
|
||||
"integrity": "sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.25.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-reconciler/node_modules/scheduler": {
|
||||
"version": "0.25.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
|
||||
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz",
|
||||
|
|
@ -13186,6 +13339,15 @@
|
|||
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/void-elements": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/walker": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@
|
|||
"@react-navigation/stack": "^7.2.10",
|
||||
"@sentry/react-native": "^7.6.0",
|
||||
"@shopify/flash-list": "^2.2.0",
|
||||
"@shopify/react-native-skia": "^2.4.14",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/react-native-video": "^5.0.20",
|
||||
"axios": "^1.12.2",
|
||||
|
|
@ -63,10 +64,14 @@
|
|||
"expo-system-ui": "~6.0.7",
|
||||
"expo-updates": "~29.0.12",
|
||||
"expo-web-browser": "~15.0.8",
|
||||
"i18next": "^25.7.3",
|
||||
"intl-pluralrules": "^2.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
"lottie-react-native": "~7.3.1",
|
||||
"posthog-react-native": "^4.4.0",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-i18next": "^16.5.1",
|
||||
"react-native": "0.81.4",
|
||||
"react-native-boost": "^0.6.2",
|
||||
"react-native-bottom-tabs": "^1.0.2",
|
||||
|
|
@ -86,7 +91,7 @@
|
|||
"react-native-svg": "^15.12.1",
|
||||
"react-native-url-polyfill": "^3.0.0",
|
||||
"react-native-vector-icons": "^10.3.0",
|
||||
"react-native-video": "^6.17.0",
|
||||
"react-native-video": "6.18.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-wheel-color-picker": "^1.3.1",
|
||||
"react-native-worklets": "^0.7.1"
|
||||
|
|
@ -100,7 +105,7 @@
|
|||
"babel-plugin-transform-remove-console": "^6.9.4",
|
||||
"patch-package": "^8.0.1",
|
||||
"react-native-svg-transformer": "^1.5.0",
|
||||
"typescript": "^5.3.3",
|
||||
"typescript": "^5.9.3",
|
||||
"xcode": "^3.0.1"
|
||||
},
|
||||
"private": true
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,94 +0,0 @@
|
|||
const { withDangerousMod, withMainApplication, withMainActivity } = require('@expo/config-plugins');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Copy MPV native files to android project
|
||||
*/
|
||||
function copyMpvFiles(projectRoot) {
|
||||
const sourceDir = path.join(projectRoot, 'plugins', 'mpv-bridge', 'android', 'mpv');
|
||||
const destDir = path.join(projectRoot, 'android', 'app', 'src', 'main', 'java', 'com', 'nuvio', 'app', 'mpv');
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
if (!fs.existsSync(destDir)) {
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Copy all files from source to destination
|
||||
if (fs.existsSync(sourceDir)) {
|
||||
const files = fs.readdirSync(sourceDir);
|
||||
files.forEach(file => {
|
||||
const srcFile = path.join(sourceDir, file);
|
||||
const destFile = path.join(destDir, file);
|
||||
if (fs.statSync(srcFile).isFile()) {
|
||||
fs.copyFileSync(srcFile, destFile);
|
||||
console.log(`[mpv-bridge] Copied ${file} to android project`);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify MainApplication.kt to include MpvPackage
|
||||
*/
|
||||
function withMpvMainApplication(config) {
|
||||
return withMainApplication(config, async (config) => {
|
||||
let contents = config.modResults.contents;
|
||||
|
||||
// Add import for MpvPackage
|
||||
const mpvImport = 'import com.nuvio.app.mpv.MpvPackage';
|
||||
if (!contents.includes(mpvImport)) {
|
||||
// Add import after the last import statement
|
||||
const lastImportIndex = contents.lastIndexOf('import ');
|
||||
const endOfLastImport = contents.indexOf('\n', lastImportIndex);
|
||||
contents = contents.slice(0, endOfLastImport + 1) + mpvImport + '\n' + contents.slice(endOfLastImport + 1);
|
||||
}
|
||||
|
||||
// Add MpvPackage to the packages list
|
||||
const packagesPattern = /override fun getPackages\(\): List<ReactPackage> \{[\s\S]*?return PackageList\(this\)\.packages\.apply \{/;
|
||||
if (contents.match(packagesPattern) && !contents.includes('MpvPackage()')) {
|
||||
contents = contents.replace(
|
||||
packagesPattern,
|
||||
(match) => match + '\n add(MpvPackage())'
|
||||
);
|
||||
}
|
||||
|
||||
config.modResults.contents = contents;
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Modify MainActivity.kt to handle MPV lifecycle if needed
|
||||
*/
|
||||
function withMpvMainActivity(config) {
|
||||
return withMainActivity(config, async (config) => {
|
||||
// Currently no modifications needed for MainActivity
|
||||
// But this hook is available for future enhancements
|
||||
return config;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main plugin function
|
||||
*/
|
||||
function withMpvBridge(config) {
|
||||
// Copy native files during prebuild
|
||||
config = withDangerousMod(config, [
|
||||
'android',
|
||||
async (config) => {
|
||||
copyMpvFiles(config.modRequest.projectRoot);
|
||||
return config;
|
||||
},
|
||||
]);
|
||||
|
||||
// Modify MainApplication to register the package
|
||||
config = withMpvMainApplication(config);
|
||||
|
||||
// Modify MainActivity if needed
|
||||
config = withMpvMainActivity(config);
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = withMpvBridge;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 11 KiB |
|
|
@ -1,10 +0,0 @@
|
|||
<svg width="50" height="14" viewBox="0 0 50 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.5" y="0.5" width="49" height="13" rx="1.5" stroke="#575757"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9999 3.5H3V10.5H11.9999V3.5ZM4.49995 9.5C4.32871 9.5 4.16151 9.48267 3.99995 9.44995V4.55005C4.10415 4.52905 4.21069 4.51416 4.31915 4.50635C4.33529 4.50513 4.35146 4.50439 4.36768 4.50366L4.40562 4.50195C4.43691 4.50073 4.46836 4.5 4.49995 4.5C5.88065 4.5 6.99995 5.61938 6.99995 7C6.99995 8.38062 5.88065 9.5 4.49995 9.5ZM10.4999 9.5C10.5348 9.5 10.5695 9.49927 10.6041 9.4978C10.6614 9.49561 10.7183 9.49146 10.7746 9.48535C10.8508 9.47681 10.926 9.46509 10.9999 9.44995V4.55005C10.9079 4.53149 10.814 4.51782 10.7187 4.50952C10.6466 4.50317 10.5736 4.5 10.4999 4.5C9.11924 4.5 7.99995 5.61938 7.99995 7C7.99995 8.38062 9.11924 9.5 10.4999 9.5Z" fill="#CBCBCB"/>
|
||||
<path d="M20.2905 4.092L17.8875 10.356H16.6095L14.2065 4.092H15.5385L17.2755 8.88L19.0035 4.092H20.2905Z" fill="#CBCBCB"/>
|
||||
<path d="M22.6024 10.356H21.3784V4.092H22.6024V10.356Z" fill="#CBCBCB"/>
|
||||
<path d="M26.4969 10.5C25.7649 10.5 25.1679 10.332 24.7059 9.996C24.2499 9.66 23.9529 9.192 23.8149 8.592H25.0389C25.1169 8.844 25.2819 9.048 25.5339 9.204C25.7919 9.354 26.1069 9.429 26.4789 9.429C26.8329 9.429 27.1239 9.363 27.3519 9.231C27.5859 9.093 27.7029 8.895 27.7029 8.637C27.7029 8.487 27.6699 8.358 27.6039 8.25C27.5379 8.136 27.4149 8.04 27.2349 7.962C27.0549 7.878 26.7939 7.809 26.4519 7.755L25.7499 7.638C25.1679 7.542 24.7299 7.356 24.4359 7.08C24.1479 6.804 24.0039 6.408 24.0039 5.892C24.0039 5.496 24.1089 5.154 24.3189 4.866C24.5349 4.572 24.8229 4.347 25.1829 4.191C25.5489 4.029 25.9569 3.948 26.4069 3.948C27.0609 3.948 27.6099 4.104 28.0539 4.416C28.4979 4.722 28.7709 5.172 28.8729 5.766H27.6489C27.5889 5.514 27.4509 5.328 27.2349 5.208C27.0189 5.082 26.7459 5.019 26.4159 5.019C26.0499 5.019 25.7619 5.088 25.5519 5.226C25.3419 5.358 25.2369 5.544 25.2369 5.784C25.2369 6 25.3149 6.168 25.4709 6.288C25.6329 6.408 25.9509 6.507 26.4249 6.585L27.0999 6.693C28.3299 6.891 28.9449 7.5 28.9449 8.52C28.9449 8.94 28.8399 9.3 28.6299 9.6C28.4199 9.894 28.1289 10.119 27.7569 10.275C27.3909 10.425 26.9709 10.5 26.4969 10.5Z" fill="#CBCBCB"/>
|
||||
<path d="M31.4959 10.356H30.2719V4.092H31.4959V10.356Z" fill="#CBCBCB"/>
|
||||
<path d="M36.0024 10.5C35.5404 10.5 35.1144 10.419 34.7244 10.257C34.3404 10.095 34.0074 9.867 33.7254 9.573C33.4434 9.279 33.2244 8.934 33.0684 8.538C32.9124 8.136 32.8344 7.698 32.8344 7.224C32.8344 6.75 32.9124 6.315 33.0684 5.919C33.2244 5.517 33.4434 5.169 33.7254 4.875C34.0074 4.581 34.3404 4.353 34.7244 4.191C35.1144 4.029 35.5404 3.948 36.0024 3.948C36.4644 3.948 36.8874 4.029 37.2714 4.191C37.6614 4.353 37.9974 4.581 38.2794 4.875C38.5674 5.169 38.7894 5.517 38.9454 5.919C39.1014 6.315 39.1794 6.75 39.1794 7.224C39.1794 7.698 39.1014 8.136 38.9454 8.538C38.7894 8.934 38.5674 9.279 38.2794 9.573C37.9974 9.867 37.6614 10.095 37.2714 10.257C36.8874 10.419 36.4644 10.5 36.0024 10.5ZM36.0024 9.402C36.3804 9.402 36.7134 9.312 37.0014 9.132C37.2954 8.946 37.5234 8.691 37.6854 8.367C37.8534 8.037 37.9374 7.656 37.9374 7.224C37.9374 6.792 37.8534 6.414 37.6854 6.09C37.5234 5.76 37.2954 5.505 37.0014 5.325C36.7134 5.139 36.3804 5.046 36.0024 5.046C35.6304 5.046 35.2974 5.139 35.0034 5.325C34.7154 5.505 34.4874 5.76 34.3194 6.09C34.1574 6.414 34.0764 6.792 34.0764 7.224C34.0764 7.656 34.1574 8.037 34.3194 8.367C34.4874 8.691 34.7154 8.946 35.0034 9.132C35.2974 9.312 35.6304 9.402 36.0024 9.402Z" fill="#CBCBCB"/>
|
||||
<path d="M40.519 10.356V4.092H41.707L44.812 8.484V4.092H46V10.356H44.812L41.707 5.964V10.356H40.519Z" fill="#CBCBCB"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.6 KiB |
BIN
src/assets/tmdb_logo.png
Normal file
BIN
src/assets/tmdb_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.2 KiB |
|
|
@ -7,34 +7,42 @@ interface SplashScreenProps {
|
|||
}
|
||||
|
||||
const SplashScreen = ({ onFinish }: SplashScreenProps) => {
|
||||
// Animation value for opacity
|
||||
const fadeAnim = new Animated.Value(1);
|
||||
|
||||
// TEMPORARILY DISABLED
|
||||
useEffect(() => {
|
||||
// Wait for a short period then start fade out animation
|
||||
const timer = setTimeout(() => {
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
// Call onFinish when animation completes
|
||||
// Immediately call onFinish to skip splash screen
|
||||
onFinish();
|
||||
});
|
||||
}, 300); // Show splash for 0.8 seconds
|
||||
}, [onFinish]);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [fadeAnim, onFinish]);
|
||||
return null;
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.container, { opacity: fadeAnim }]}>
|
||||
<Image
|
||||
source={require('../assets/splash-icon-new.png')}
|
||||
style={styles.image}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</Animated.View>
|
||||
);
|
||||
// Animation value for opacity
|
||||
// const fadeAnim = new Animated.Value(1);
|
||||
|
||||
// useEffect(() => {
|
||||
// // Wait for a short period then start fade out animation
|
||||
// const timer = setTimeout(() => {
|
||||
// Animated.timing(fadeAnim, {
|
||||
// toValue: 0,
|
||||
// duration: 400,
|
||||
// useNativeDriver: true,
|
||||
// }).start(() => {
|
||||
// // Call onFinish when animation completes
|
||||
// onFinish();
|
||||
// });
|
||||
// }, 300); // Show splash for 0.8 seconds
|
||||
|
||||
// return () => clearTimeout(timer);
|
||||
// }, [fadeAnim, onFinish]);
|
||||
|
||||
// return (
|
||||
// <Animated.View style={[styles.container, { opacity: fadeAnim }]}>
|
||||
// <Image
|
||||
// source={require('../assets/splash-icon-new.png')}
|
||||
// style={styles.image}
|
||||
// resizeMode="contain"
|
||||
// />
|
||||
// </Animated.View>
|
||||
// );
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ import { LinearGradient } from 'expo-linear-gradient';
|
|||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { BlurView as ExpoBlurView } from 'expo-blur';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withTiming,
|
||||
withDelay,
|
||||
Easing
|
||||
|
|
@ -44,36 +44,36 @@ interface TabletStreamsLayoutProps {
|
|||
metadata?: any;
|
||||
type: string;
|
||||
currentEpisode?: any;
|
||||
|
||||
|
||||
// Movie logo props
|
||||
movieLogoError: boolean;
|
||||
setMovieLogoError: (error: boolean) => void;
|
||||
|
||||
|
||||
// Stream-related props
|
||||
streamsEmpty: boolean;
|
||||
selectedProvider: string;
|
||||
filterItems: Array<{ id: string; name: string; }>;
|
||||
handleProviderChange: (provider: string) => void;
|
||||
activeFetchingScrapers: string[];
|
||||
|
||||
|
||||
// Loading states
|
||||
isAutoplayWaiting: boolean;
|
||||
autoplayTriggered: boolean;
|
||||
showNoSourcesError: boolean;
|
||||
showInitialLoading: boolean;
|
||||
showStillFetching: boolean;
|
||||
|
||||
|
||||
// Stream rendering props
|
||||
sections: Array<{ title: string; addonId: string; data: Stream[]; isEmptyDueToQualityFilter?: boolean } | null>;
|
||||
renderSectionHeader: ({ section }: { section: { title: string; addonId: string; isEmptyDueToQualityFilter?: boolean } }) => React.ReactElement;
|
||||
handleStreamPress: (stream: Stream) => void;
|
||||
openAlert: (title: string, message: string) => void;
|
||||
|
||||
|
||||
// Settings and theme
|
||||
settings: any;
|
||||
currentTheme: any;
|
||||
colors: any;
|
||||
|
||||
|
||||
// Other props
|
||||
navigation: RootStackNavigationProp;
|
||||
insets: any;
|
||||
|
|
@ -122,19 +122,19 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
hasStremioStreamProviders,
|
||||
}) => {
|
||||
const styles = React.useMemo(() => createStyles(colors), [colors]);
|
||||
|
||||
|
||||
// Animation values for backdrop entrance
|
||||
const backdropOpacity = useSharedValue(0);
|
||||
const backdropScale = useSharedValue(1.05);
|
||||
const [backdropLoaded, setBackdropLoaded] = useState(false);
|
||||
const [backdropError, setBackdropError] = useState(false);
|
||||
|
||||
|
||||
// Animation values for content panels
|
||||
const leftPanelOpacity = useSharedValue(0);
|
||||
const leftPanelTranslateX = useSharedValue(-30);
|
||||
const rightPanelOpacity = useSharedValue(0);
|
||||
const rightPanelTranslateX = useSharedValue(30);
|
||||
|
||||
|
||||
// Get the backdrop source - prioritize episode thumbnail, then show backdrop, then poster
|
||||
// For episodes without thumbnails, use show's backdrop instead of poster
|
||||
const backdropSource = React.useMemo(() => {
|
||||
|
|
@ -148,7 +148,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
backdropError
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// If episodeImage failed to load, skip it and use backdrop
|
||||
if (backdropError && episodeImage && episodeImage !== metadata?.poster) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Episode thumbnail failed, falling back to backdrop');
|
||||
|
|
@ -157,26 +157,55 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
return { uri: bannerImage };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// If episodeImage exists and is not the same as poster, use it (real episode thumbnail)
|
||||
if (episodeImage && episodeImage !== metadata?.poster && !backdropError) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Using episode thumbnail:', episodeImage);
|
||||
return { uri: episodeImage };
|
||||
}
|
||||
|
||||
|
||||
// If episodeImage is the same as poster (fallback case), prioritize backdrop
|
||||
if (bannerImage) {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Using show backdrop:', bannerImage);
|
||||
return { uri: bannerImage };
|
||||
}
|
||||
|
||||
|
||||
// No fallback to poster images
|
||||
|
||||
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] No backdrop source found');
|
||||
return undefined;
|
||||
}, [episodeImage, bannerImage, metadata?.poster, backdropError]);
|
||||
|
||||
// Animate backdrop when it loads, or animate content immediately if no backdrop
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (backdropSource?.uri && !backdropLoaded && !backdropError) {
|
||||
|
||||
const timeoutId = setTimeout(() => {
|
||||
|
||||
leftPanelOpacity.value = withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
leftPanelTranslateX.value = withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
rightPanelOpacity.value = withDelay(200, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
rightPanelTranslateX.value = withDelay(200, withTiming(0, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [backdropSource?.uri, backdropLoaded, backdropError]);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (backdropSource?.uri && backdropLoaded) {
|
||||
// Animate backdrop first
|
||||
|
|
@ -188,7 +217,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
duration: 1000,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
|
||||
// Animate content panels with delay after backdrop starts loading
|
||||
leftPanelOpacity.value = withDelay(300, withTiming(1, {
|
||||
duration: 600,
|
||||
|
|
@ -198,7 +227,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
}));
|
||||
|
||||
|
||||
rightPanelOpacity.value = withDelay(500, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
|
|
@ -217,7 +246,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
});
|
||||
|
||||
|
||||
rightPanelOpacity.value = withDelay(200, withTiming(1, {
|
||||
duration: 600,
|
||||
easing: Easing.out(Easing.cubic)
|
||||
|
|
@ -228,7 +257,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
}));
|
||||
}
|
||||
}, [backdropSource?.uri, backdropLoaded, backdropError]);
|
||||
|
||||
|
||||
// Reset animation when episode changes
|
||||
useEffect(() => {
|
||||
backdropOpacity.value = 0;
|
||||
|
|
@ -240,28 +269,28 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
setBackdropLoaded(false);
|
||||
setBackdropError(false);
|
||||
}, [episodeImage]);
|
||||
|
||||
|
||||
// Animated styles for backdrop
|
||||
const backdropAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: backdropOpacity.value,
|
||||
transform: [{ scale: backdropScale.value }],
|
||||
}));
|
||||
|
||||
|
||||
// Animated styles for content panels
|
||||
const leftPanelAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: leftPanelOpacity.value,
|
||||
transform: [{ translateX: leftPanelTranslateX.value }],
|
||||
}));
|
||||
|
||||
|
||||
const rightPanelAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: rightPanelOpacity.value,
|
||||
transform: [{ translateX: rightPanelTranslateX.value }],
|
||||
}));
|
||||
|
||||
|
||||
const handleBackdropLoad = () => {
|
||||
setBackdropLoaded(true);
|
||||
};
|
||||
|
||||
|
||||
const handleBackdropError = () => {
|
||||
if (__DEV__) console.log('[TabletStreamsLayout] Backdrop image failed to load:', backdropSource?.uri);
|
||||
setBackdropError(true);
|
||||
|
|
@ -294,8 +323,8 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
<Text style={styles.loadingText}>
|
||||
{isAutoplayWaiting ? 'Finding best stream for autoplay...' :
|
||||
showStillFetching ? 'Still fetching streams…' :
|
||||
'Finding available streams...'}
|
||||
showStillFetching ? 'Still fetching streams…' :
|
||||
'Finding available streams...'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
|
@ -311,7 +340,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
|
||||
// Flatten sections into a single list with header items
|
||||
type ListItem = { type: 'header'; title: string; addonId: string } | { type: 'stream'; stream: Stream; index: number };
|
||||
|
||||
|
||||
const flatListData: ListItem[] = [];
|
||||
sections
|
||||
.filter(Boolean)
|
||||
|
|
@ -327,7 +356,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
if (item.type === 'header') {
|
||||
return renderSectionHeader({ section: { title: item.title, addonId: item.addonId } });
|
||||
}
|
||||
|
||||
|
||||
const stream = item.stream;
|
||||
return (
|
||||
<StreamCard
|
||||
|
|
@ -414,7 +443,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
locations={[0, 0.5, 1]}
|
||||
style={styles.tabletFullScreenGradient}
|
||||
/>
|
||||
|
||||
|
||||
{/* Left Panel: Movie Logo/Episode Info */}
|
||||
<Animated.View style={[styles.tabletLeftPanel, leftPanelAnimatedStyle]}>
|
||||
{type === 'movie' && metadata ? (
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
} from 'react-native';
|
||||
import { InteractionManager } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { format, addMonths, subMonths, startOfMonth, endOfMonth, eachDayOfInterval, isSameMonth, isToday, isSameDay } from 'date-fns';
|
||||
import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -16,7 +17,6 @@ import { useTheme } from '../../contexts/ThemeContext';
|
|||
const { width } = Dimensions.get('window');
|
||||
const COLUMN_COUNT = 7; // 7 days in a week
|
||||
const DAY_ITEM_SIZE = (width - 32 - 56) / 7; // Slightly smaller than 1/7 to fit all days
|
||||
const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
interface CalendarEpisode {
|
||||
id: string;
|
||||
|
|
@ -76,8 +76,19 @@ export const CalendarSection: React.FC<CalendarSectionProps> = ({
|
|||
episodes = [],
|
||||
onSelectDate
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
|
||||
const weekDays = [
|
||||
t('common.days_short.sun'),
|
||||
t('common.days_short.mon'),
|
||||
t('common.days_short.tue'),
|
||||
t('common.days_short.wed'),
|
||||
t('common.days_short.thu'),
|
||||
t('common.days_short.fri'),
|
||||
t('common.days_short.sat')
|
||||
];
|
||||
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const [uiReady, setUiReady] = useState(false);
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Image,
|
||||
} from 'react-native';
|
||||
import { NavigationProp, useNavigation, useIsFocused } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
|
@ -144,6 +145,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
onRetry,
|
||||
scrollY: externalScrollY,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const isFocused = useIsFocused();
|
||||
const { currentTheme } = useTheme();
|
||||
|
|
@ -158,7 +160,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const [inLibrary, setInLibrary] = useState(false);
|
||||
const [isInWatchlist, setIsInWatchlist] = useState(false);
|
||||
const [isWatched, setIsWatched] = useState(false);
|
||||
const [playButtonText, setPlayButtonText] = useState('Play');
|
||||
const [shouldResume, setShouldResume] = useState(false);
|
||||
const [type, setType] = useState<'movie' | 'series'>('movie');
|
||||
|
||||
// Create internal scrollY if not provided externally
|
||||
|
|
@ -530,7 +532,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
useEffect(() => {
|
||||
if (currentItem) {
|
||||
const buttonText = getProgressPlayButtonText();
|
||||
setPlayButtonText(buttonText);
|
||||
// Use internal state for resume logic instead of string comparison
|
||||
setShouldResume(buttonText === 'Resume');
|
||||
|
||||
// Update watched state based on progress
|
||||
if (watchProgress) {
|
||||
|
|
@ -987,10 +990,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
<View style={[styles.container, { height: HERO_HEIGHT, marginTop: -insets.top }]}>
|
||||
<View style={styles.noContentContainer}>
|
||||
<MaterialIcons name="theaters" size={48} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.noContentText}>No featured content available</Text>
|
||||
<Text style={styles.noContentText}>{t('home.no_featured_available')}</Text>
|
||||
{onRetry && (
|
||||
<TouchableOpacity style={styles.retryButton} onPress={onRetry} activeOpacity={0.7}>
|
||||
<Text style={styles.retryButtonText}>Retry</Text>
|
||||
<Text style={styles.retryButtonText}>{t('home.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -1242,7 +1245,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
<View style={styles.metadataBadge}>
|
||||
<MaterialIcons name="tv" size={16} color="#fff" />
|
||||
<Text style={styles.metadataText}>
|
||||
{currentItem.type === 'series' ? 'TV Show' : 'Movie'}
|
||||
{currentItem.type === 'series' ? t('home.tv_show') : t('home.movie')}
|
||||
</Text>
|
||||
{currentItem.genres && currentItem.genres.length > 0 && (
|
||||
<>
|
||||
|
|
@ -1262,11 +1265,11 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={playButtonText === 'Resume' ? "replay" : "play-arrow"}
|
||||
name={shouldResume ? "replay" : "play-arrow"}
|
||||
size={24}
|
||||
color="#000"
|
||||
/>
|
||||
<Text style={styles.playButtonText}>{playButtonText}</Text>
|
||||
<Text style={styles.playButtonText}>{shouldResume ? t('home.resume') : t('home.play')}</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Save Button */}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Platform, Dimensions, FlatList } from 'react-native';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { CatalogContent, StreamingContent } from '../../services/catalogService';
|
||||
|
|
@ -8,6 +9,7 @@ import { useTheme } from '../../contexts/ThemeContext';
|
|||
import ContentItem from './ContentItem';
|
||||
import Animated, { FadeIn, Layout } from 'react-native-reanimated';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { getFormattedCatalogName, getCatalogDisplayName } from '../../utils/catalogNameUtils';
|
||||
|
||||
interface CatalogSectionProps {
|
||||
catalog: CatalogContent;
|
||||
|
|
@ -73,9 +75,44 @@ const posterLayout = calculatePosterLayout(width);
|
|||
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||
|
||||
const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Use state for the display name to handle async custom name resolution
|
||||
const [displayName, setDisplayName] = React.useState(catalog.name);
|
||||
|
||||
// Re-resolve and format the name when language or catalog data changes
|
||||
React.useEffect(() => {
|
||||
const resolveName = async () => {
|
||||
// 1. Check for user-defined custom name
|
||||
const customName = await getCatalogDisplayName(
|
||||
catalog.addon,
|
||||
catalog.type,
|
||||
catalog.id,
|
||||
catalog.originalName || catalog.name
|
||||
);
|
||||
|
||||
// 2. If it's a user setting, use it as is
|
||||
if (customName !== (catalog.originalName || catalog.name)) {
|
||||
setDisplayName(customName);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Otherwise, use localized formatting
|
||||
const formatted = getFormattedCatalogName(
|
||||
customName,
|
||||
catalog.type,
|
||||
t('home.movies'),
|
||||
t('home.tv_shows'),
|
||||
t('home.channels')
|
||||
);
|
||||
setDisplayName(formatted);
|
||||
};
|
||||
|
||||
resolveName();
|
||||
}, [catalog.addon, catalog.id, catalog.type, catalog.name, catalog.originalName, i18n.language, t]);
|
||||
|
||||
const handleContentPress = useCallback((id: string, type: string) => {
|
||||
navigation.navigate('Metadata', { id, type, addonId: catalog.addon });
|
||||
}, [navigation, catalog.addon]);
|
||||
|
|
@ -117,7 +154,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{catalog.name}
|
||||
{displayName}
|
||||
</Text>
|
||||
<View
|
||||
style={[
|
||||
|
|
@ -154,7 +191,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
fontSize: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
marginRight: isTV ? 6 : isLargeTablet ? 5 : 4,
|
||||
}
|
||||
]}>View All</Text>
|
||||
]}>{t('home.view_all')}</Text>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useToast } from '../../contexts/ToastContext';
|
||||
import { DeviceEventEmitter } from 'react-native';
|
||||
import { View, TouchableOpacity, ActivityIndicator, StyleSheet, Dimensions, Platform, Text, Share } from 'react-native';
|
||||
|
|
@ -82,6 +83,7 @@ const posterLayout = calculatePosterLayout(width);
|
|||
const POSTER_WIDTH = posterLayout.posterWidth;
|
||||
|
||||
const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, deferMs = 0 }: ContentItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Track inLibrary status locally to force re-render
|
||||
const [inLibrary, setInLibrary] = useState(!!item.inLibrary);
|
||||
|
||||
|
|
@ -182,10 +184,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
case 'library':
|
||||
if (inLibrary) {
|
||||
catalogService.removeFromLibrary(item.type, item.id);
|
||||
showInfo('Removed from Library', 'Removed from your local library');
|
||||
showInfo(t('library.removed_from_library'), t('library.item_removed'));
|
||||
} else {
|
||||
catalogService.addToLibrary(item);
|
||||
showSuccess('Added to Library', 'Added to your local library');
|
||||
showSuccess(t('library.added_to_library'), t('library.item_added'));
|
||||
}
|
||||
break;
|
||||
case 'watched': {
|
||||
|
|
@ -194,7 +196,7 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
try {
|
||||
await mmkvStorage.setItem(`watched:${item.type}:${item.id}`, targetWatched ? 'true' : 'false');
|
||||
} catch { }
|
||||
showInfo(targetWatched ? 'Marked as Watched' : 'Marked as Unwatched', targetWatched ? 'Item marked as watched' : 'Item marked as unwatched');
|
||||
showInfo(targetWatched ? t('library.marked_watched') : t('library.marked_unwatched'), targetWatched ? t('library.item_marked_watched') : t('library.item_marked_unwatched'));
|
||||
setTimeout(() => {
|
||||
DeviceEventEmitter.emit('watchedStatusChanged');
|
||||
}, 100);
|
||||
|
|
@ -240,10 +242,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
case 'trakt-watchlist': {
|
||||
if (isInWatchlist(item.id, item.type as 'movie' | 'show')) {
|
||||
await removeFromWatchlist(item.id, item.type as 'movie' | 'show');
|
||||
showInfo('Removed from Watchlist', 'Removed from your Trakt watchlist');
|
||||
showInfo(t('library.removed_from_watchlist'), t('library.removed_from_watchlist_desc'));
|
||||
} else {
|
||||
await addToWatchlist(item.id, item.type as 'movie' | 'show');
|
||||
showSuccess('Added to Watchlist', 'Added to your Trakt watchlist');
|
||||
showSuccess(t('library.added_to_watchlist'), t('library.added_to_watchlist_desc'));
|
||||
}
|
||||
setMenuVisible(false);
|
||||
break;
|
||||
|
|
@ -251,10 +253,10 @@ const ContentItem = ({ item, onPress, shouldLoadImage: shouldLoadImageProp, defe
|
|||
case 'trakt-collection': {
|
||||
if (isInCollection(item.id, item.type as 'movie' | 'show')) {
|
||||
await removeFromCollection(item.id, item.type as 'movie' | 'show');
|
||||
showInfo('Removed from Collection', 'Removed from your Trakt collection');
|
||||
showInfo(t('library.removed_from_collection'), t('library.removed_from_collection_desc'));
|
||||
} else {
|
||||
await addToCollection(item.id, item.type as 'movie' | 'show');
|
||||
showSuccess('Added to Collection', 'Added to your Trakt collection');
|
||||
showSuccess(t('library.added_to_collection'), t('library.added_to_collection_desc'));
|
||||
}
|
||||
setMenuVisible(false);
|
||||
break;
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -10,6 +10,7 @@ import {
|
|||
Dimensions,
|
||||
Platform
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useTraktContext } from '../../contexts/TraktContext';
|
||||
|
|
@ -39,6 +40,7 @@ interface DropUpMenuProps {
|
|||
}
|
||||
|
||||
export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: isSavedProp, isWatched: isWatchedProp }: DropUpMenuProps) => {
|
||||
const { t } = useTranslation();
|
||||
const translateY = useSharedValue(300);
|
||||
const opacity = useSharedValue(0);
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
|
|
@ -102,12 +104,12 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
|||
let menuOptions = [
|
||||
{
|
||||
icon: 'bookmark',
|
||||
label: isSaved ? 'Remove from Library' : 'Add to Library',
|
||||
label: isSaved ? t('library.remove_from_library') : t('library.add_to_library'),
|
||||
action: 'library'
|
||||
},
|
||||
{
|
||||
icon: 'check-circle',
|
||||
label: isWatched ? 'Mark as Unwatched' : 'Mark as Watched',
|
||||
label: isWatched ? t('library.mark_unwatched') : t('library.mark_watched'),
|
||||
action: 'watched'
|
||||
},
|
||||
/*
|
||||
|
|
@ -119,7 +121,7 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
|||
*/
|
||||
{
|
||||
icon: 'share',
|
||||
label: 'Share',
|
||||
label: t('library.share'),
|
||||
action: 'share'
|
||||
}
|
||||
];
|
||||
|
|
@ -129,12 +131,12 @@ export const DropUpMenu = ({ visible, onClose, item, onOptionSelect, isSaved: is
|
|||
menuOptions.push(
|
||||
{
|
||||
icon: 'playlist-add-check',
|
||||
label: inTraktWatchlist ? 'Remove from Trakt Watchlist' : 'Add to Trakt Watchlist',
|
||||
label: inTraktWatchlist ? t('library.remove_from_watchlist') : t('library.add_to_watchlist'),
|
||||
action: 'trakt-watchlist'
|
||||
},
|
||||
{
|
||||
icon: 'video-library',
|
||||
label: inTraktCollection ? 'Remove from Trakt Collection' : 'Add to Trakt Collection',
|
||||
label: inTraktCollection ? t('library.remove_from_collection') : t('library.add_to_collection'),
|
||||
action: 'trakt-collection'
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import {
|
|||
Platform
|
||||
} from 'react-native';
|
||||
import { NavigationProp, useNavigation } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RootStackParamList } from '../../navigation/AppNavigator';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
|
|
@ -52,6 +53,7 @@ const nowMs = () => Date.now();
|
|||
const since = (start: number) => `${(nowMs() - start).toFixed(0)}ms`;
|
||||
|
||||
const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
|
@ -103,11 +105,11 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
|||
return (
|
||||
<View style={styles.noContentContainer}>
|
||||
<MaterialIcons name="theaters" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={styles.noContentTitle}>{onRetry ? 'Couldn\'t load featured content' : 'No Featured Content'}</Text>
|
||||
<Text style={styles.noContentTitle}>{onRetry ? t('home.couldnt_load_featured') : t('home.no_featured_content')}</Text>
|
||||
<Text style={styles.noContentText}>
|
||||
{onRetry
|
||||
? 'There was a problem fetching featured content. Please check your connection and try again.'
|
||||
: 'Install addons with catalogs or change the content source in your settings.'}
|
||||
? t('home.load_error_desc')
|
||||
: t('home.no_featured_desc')}
|
||||
</Text>
|
||||
<View style={styles.noContentButtons}>
|
||||
{onRetry ? (
|
||||
|
|
@ -115,7 +117,7 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
|||
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={onRetry}
|
||||
>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Retry</Text>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>{t('home.retry')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<>
|
||||
|
|
@ -123,13 +125,13 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
|||
style={[styles.noContentButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={() => navigation.navigate('Addons')}
|
||||
>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>Install Addons</Text>
|
||||
<Text style={[styles.noContentButtonText, { color: currentTheme.colors.white }]}>{t('home.install_addons')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={styles.noContentButton}
|
||||
onPress={() => navigation.navigate('HomeScreenSettings')}
|
||||
>
|
||||
<Text style={styles.noContentButtonText}>Settings</Text>
|
||||
<Text style={styles.noContentButtonText}>{t('home.settings')}</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -139,6 +141,7 @@ const NoFeaturedContent = ({ onRetry }: { onRetry?: () => void }) => {
|
|||
};
|
||||
|
||||
const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loading, onRetry }: FeaturedContentProps) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const [bannerUrl, setBannerUrl] = useState<string | null>(null);
|
||||
|
|
@ -509,7 +512,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name="play-arrow" size={28} color={currentTheme.colors.black} />
|
||||
<Text style={[styles.tabletPlayButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
||||
Play Now
|
||||
{t('home.play_now')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -520,7 +523,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={20} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{isSaved ? "Saved" : "My List"}
|
||||
{isSaved ? t('home.saved') : t('home.my_list')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -531,7 +534,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name="info-outline" size={20} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.tabletSecondaryButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
More Info
|
||||
{t('home.more_info')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
|
@ -626,7 +629,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name={isSaved ? "bookmark" : "bookmark-outline"} size={24} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.myListButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
{isSaved ? "Saved" : "Save"}
|
||||
{isSaved ? t('home.saved') : t('home.save')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -644,7 +647,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name="play-arrow" size={24} color={currentTheme.colors.black} />
|
||||
<Text style={[styles.playButtonText as TextStyle, { color: currentTheme.colors.black }]}>
|
||||
Play
|
||||
{t('home.play')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -655,7 +658,7 @@ const FeaturedContent = ({ featuredContent, isSaved, handleSaveToLibrary, loadin
|
|||
>
|
||||
<MaterialIcons name="info-outline" size={24} color={currentTheme.colors.white} />
|
||||
<Text style={[styles.infoButtonText as TextStyle, { color: currentTheme.colors.white }]}>
|
||||
Info
|
||||
{t('home.info')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useMemo, useState, useEffect, useCallback, memo, useRef } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ViewStyle, TextStyle, ImageStyle, ScrollView, StyleProp, Platform, Image, useWindowDimensions } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Animated, { FadeIn, FadeOut, Easing, useSharedValue, withTiming, useAnimatedStyle, useAnimatedScrollHandler, useAnimatedReaction, runOnJS, SharedValue, interpolate, Extrapolation } from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
|
|
@ -38,6 +39,7 @@ interface HeroCarouselProps {
|
|||
const TOP_TABS_OFFSET = Platform.OS === 'ios' ? 44 : 48;
|
||||
|
||||
const HeroCarousel: React.FC<HeroCarouselProps> = ({ items, loading = false }) => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
|
@ -610,6 +612,7 @@ interface CarouselCardProps {
|
|||
}
|
||||
|
||||
const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFailed, onLogoError, onPressInfo, scrollX, index, flipped, onToggleFlip, interval, cardWidth, cardHeight, isTablet }) => {
|
||||
const { t } = useTranslation();
|
||||
const [bannerLoaded, setBannerLoaded] = useState(false);
|
||||
const [logoLoaded, setLogoLoaded] = useState(false);
|
||||
|
||||
|
|
@ -847,7 +850,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
textShadowRadius: 2,
|
||||
}
|
||||
]}>
|
||||
{item.description || 'No description available'}
|
||||
{item.description || t('home.no_description')}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
|
@ -956,7 +959,7 @@ const CarouselCard: React.FC<CarouselCardProps> = memo(({ item, colors, logoFail
|
|||
textShadowRadius: 2,
|
||||
}
|
||||
]}>
|
||||
{item.description || 'No description available'}
|
||||
{item.description || t('home.no_description')}
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Dimensions
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -58,6 +59,7 @@ interface ThisWeekEpisode {
|
|||
}
|
||||
|
||||
export const ThisWeekSection = React.memo(() => {
|
||||
const { t } = useTranslation();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const { currentTheme } = useTheme();
|
||||
const { calendarData, loading } = useCalendarData();
|
||||
|
|
@ -176,7 +178,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
processedItems.push({
|
||||
...firstEp,
|
||||
id: `group_${firstEp.seriesId}_${firstEp.releaseDate}`, // Unique ID for the group
|
||||
title: `${group.length} New Episodes`,
|
||||
title: t('home.new_episodes', { count: group.length }),
|
||||
isReleased,
|
||||
isGroup: true,
|
||||
episodeCount: group.length,
|
||||
|
|
@ -239,7 +241,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
const renderEpisodeItem = ({ item, index }: { item: ThisWeekEpisode, index: number }) => {
|
||||
// Handle episodes without release dates gracefully
|
||||
const releaseDate = item.releaseDate ? parseISO(item.releaseDate) : null;
|
||||
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : 'TBA';
|
||||
const formattedDate = releaseDate ? format(releaseDate, 'MMM d') : t('home.tba');
|
||||
const isReleased = item.isReleased;
|
||||
|
||||
// Use episode still image if available, fallback to series poster
|
||||
|
|
@ -294,12 +296,12 @@ export const ThisWeekSection = React.memo(() => {
|
|||
locations={[0, 0.4, 0.7, 1]}
|
||||
>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={[
|
||||
<View style={[
|
||||
styles.statusBadge,
|
||||
{ backgroundColor: isReleased ? currentTheme.colors.primary : 'rgba(0,0,0,0.6)' }
|
||||
]}>
|
||||
<Text style={styles.statusText}>
|
||||
{isReleased ? (item.isGroup ? 'Released' : 'New') : formattedDate}
|
||||
{isReleased ? (item.isGroup ? t('home.released') : t('home.new')) : formattedDate}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -357,7 +359,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
color: currentTheme.colors.text,
|
||||
fontSize: isTV ? 32 : isLargeTablet ? 28 : isTablet ? 26 : 24
|
||||
}
|
||||
]}>This Week</Text>
|
||||
]}>{t('home.this_week')}</Text>
|
||||
<View style={[
|
||||
styles.titleUnderline,
|
||||
{
|
||||
|
|
@ -380,7 +382,7 @@ export const ThisWeekSection = React.memo(() => {
|
|||
color: currentTheme.colors.textMuted,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 14
|
||||
}
|
||||
]}>View All</Text>
|
||||
]}>{t('home.view_all')}</Text>
|
||||
<MaterialIcons
|
||||
name="chevron-right"
|
||||
size={isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
|
|
@ -70,6 +71,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
onClose,
|
||||
castMember,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
const [personDetails, setPersonDetails] = useState<PersonDetails | null>(null);
|
||||
|
|
@ -82,14 +84,14 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
if (visible && castMember) {
|
||||
modalOpacity.value = withTiming(1, { duration: 250 });
|
||||
modalScale.value = withSpring(1, { damping: 20, stiffness: 200 });
|
||||
|
||||
|
||||
if (!hasFetched || personDetails?.id !== castMember.id) {
|
||||
fetchPersonDetails();
|
||||
}
|
||||
} else {
|
||||
modalOpacity.value = withTiming(0, { duration: 200 });
|
||||
modalScale.value = withTiming(0.9, { duration: 200 });
|
||||
|
||||
|
||||
if (!visible) {
|
||||
setHasFetched(false);
|
||||
setPersonDetails(null);
|
||||
|
|
@ -99,7 +101,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
|
||||
const fetchPersonDetails = async () => {
|
||||
if (!castMember || loading) return;
|
||||
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const details = await tmdbService.getPersonDetails(castMember.id);
|
||||
|
|
@ -150,11 +152,11 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
const birthDate = new Date(birthday);
|
||||
let age = today.getFullYear() - birthDate.getFullYear();
|
||||
const monthDiff = today.getMonth() - birthDate.getMonth();
|
||||
|
||||
|
||||
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < birthDate.getDate())) {
|
||||
age--;
|
||||
}
|
||||
|
||||
|
||||
return age;
|
||||
};
|
||||
|
||||
|
|
@ -196,8 +198,8 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
height: MODAL_HEIGHT,
|
||||
overflow: 'hidden',
|
||||
borderRadius: isTablet ? 32 : 24,
|
||||
backgroundColor: Platform.OS === 'android'
|
||||
? 'rgba(20, 20, 20, 0.95)'
|
||||
backgroundColor: Platform.OS === 'android'
|
||||
? 'rgba(20, 20, 20, 0.95)'
|
||||
: 'transparent',
|
||||
},
|
||||
modalStyle,
|
||||
|
|
@ -280,7 +282,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{
|
||||
color: '#fff',
|
||||
|
|
@ -296,7 +298,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontSize: isTablet ? 14 : 13,
|
||||
fontWeight: '500',
|
||||
}} numberOfLines={2}>
|
||||
as {castMember.character}
|
||||
{t('cast.as_character', { character: castMember.character })}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
|
@ -336,7 +338,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontSize: 14,
|
||||
marginTop: 12,
|
||||
}}>
|
||||
Loading details...
|
||||
{t('cast.loading_details')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
|
|
@ -352,8 +354,8 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
borderColor: 'rgba(255, 255, 255, 0.06)',
|
||||
}}>
|
||||
{personDetails?.birthday && (
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
<View style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: personDetails?.place_of_birth ? 10 : 0
|
||||
}}>
|
||||
|
|
@ -369,7 +371,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontSize: 13,
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{calculateAge(personDetails.birthday)} years old
|
||||
{t('cast.years_old', { age: calculateAge(personDetails.birthday) })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -389,7 +391,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontWeight: '500',
|
||||
flex: 1,
|
||||
}}>
|
||||
Born in {personDetails.place_of_birth}
|
||||
{t('cast.born_in', { place: personDetails.place_of_birth })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -420,7 +422,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
}}>
|
||||
View Filmography
|
||||
{t('cast.view_filmography')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -454,7 +456,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
textTransform: 'uppercase',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
Also Known As
|
||||
{t('cast.also_known_as')}
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: 'rgba(255, 255, 255, 0.7)',
|
||||
|
|
@ -480,7 +482,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
textAlign: 'center',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
No additional information available
|
||||
{t('cast.no_info_available')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ActivityIndicator,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
|
|
@ -35,6 +36,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
|
|||
onSelectCastMember,
|
||||
isTmdbEnrichmentEnabled = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
// Enhanced responsive sizing for tablets and TV screens
|
||||
|
|
@ -137,7 +139,7 @@ export const CastSection: React.FC<CastSectionProps> = ({
|
|||
fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 18,
|
||||
marginBottom: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||
}
|
||||
]}>Cast</Text>
|
||||
]}>{t('metadata.cast')}</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
horizontal
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
ActivityIndicator,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
|
|
@ -39,6 +40,7 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
|
|||
collectionMovies,
|
||||
loadingCollection
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
||||
|
|
@ -109,9 +111,9 @@ export const CollectionSection: React.FC<CollectionSectionProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error navigating to collection item:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Unable to load this content. Please try again later.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => {} }]);
|
||||
setAlertTitle(t('common.error'));
|
||||
setAlertMessage(t('metadata.something_went_wrong'));
|
||||
setAlertActions([{ label: t('common.ok'), onPress: () => {} }]);
|
||||
setAlertVisible(true);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
Animated,
|
||||
Linking,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import TraktIcon from '../../../assets/rating-icons/trakt.svg';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -186,6 +187,7 @@ const CompactCommentCard: React.FC<{
|
|||
isSpoilerRevealed: boolean;
|
||||
onSpoilerPress: () => void;
|
||||
}> = ({ comment, theme, onPress, isSpoilerRevealed, onSpoilerPress }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isPressed, setIsPressed] = useState(false);
|
||||
const fadeInOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
|
|
@ -262,7 +264,7 @@ const CompactCommentCard: React.FC<{
|
|||
|
||||
// Handle missing user data gracefully
|
||||
const user = comment.user || {};
|
||||
const username = user.name || user.username || 'Anonymous';
|
||||
const username = user.name || user.username || t('common.anonymous_user');
|
||||
|
||||
// Handle spoiler content
|
||||
const hasSpoiler = comment.spoiler;
|
||||
|
|
@ -280,10 +282,10 @@ const CompactCommentCard: React.FC<{
|
|||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffMins < 1) return 'now';
|
||||
if (diffMins < 60) return `${diffMins}m ago`;
|
||||
if (diffHours < 24) return `${diffHours}h ago`;
|
||||
if (diffDays < 7) return `${diffDays}d ago`;
|
||||
if (diffMins < 1) return t('common.time.now');
|
||||
if (diffMins < 60) return t('common.time.minutes_ago', { count: diffMins });
|
||||
if (diffHours < 24) return t('common.time.hours_ago', { count: diffHours });
|
||||
if (diffDays < 7) return t('common.time.days_ago', { count: diffDays });
|
||||
|
||||
// For older dates, show month/day
|
||||
return commentDate.toLocaleDateString('en-US', {
|
||||
|
|
@ -725,6 +727,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
episode,
|
||||
onCommentPress,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = React.useState(false);
|
||||
|
|
@ -823,12 +826,12 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
<View style={styles.emptyContainer}>
|
||||
<MaterialIcons name="chat-bubble-outline" size={48} color={currentTheme.colors.mediumEmphasis} />
|
||||
<Text style={[styles.emptyText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{error ? 'Comments unavailable' : 'No comments on Trakt yet'}
|
||||
{error ? t('comments.unavailable') : t('comments.no_comments')}
|
||||
</Text>
|
||||
<Text style={[styles.emptySubtext, { color: currentTheme.colors.disabled }]}>
|
||||
{error
|
||||
? 'This content may not be in Trakt\'s database yet'
|
||||
: 'Be the first to comment on Trakt.tv'
|
||||
? t('comments.not_in_database')
|
||||
: t('comments.check_trakt')
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
|
|
@ -930,7 +933,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
|
||||
}
|
||||
]}>
|
||||
Trakt Comments
|
||||
{t('comments.title')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
|
@ -945,7 +948,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
onPress={refresh}
|
||||
>
|
||||
<Text style={[styles.retryButtonText, { color: currentTheme.colors.error }]}>
|
||||
Retry
|
||||
{t('common.retry')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -993,7 +996,7 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
) : (
|
||||
<>
|
||||
<Text style={[styles.loadMoreText, { color: currentTheme.colors.primary }]}>
|
||||
Load More
|
||||
{t('common.load_more')}
|
||||
</Text>
|
||||
<MaterialIcons name="chevron-right" size={20} color={currentTheme.colors.primary} />
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import { useToast } from '../../contexts/ToastContext';
|
|||
import { useTraktContext } from '../../contexts/TraktContext';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { TMDBService } from '../../services/tmdbService';
|
||||
import TrailerService from '../../services/trailerService';
|
||||
|
|
@ -149,6 +150,7 @@ const ActionButtons = memo(({
|
|||
onToggleCollection?: () => void;
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved, showSuccess, showInfo } = useToast();
|
||||
|
||||
// Performance optimization: Cache theme colors
|
||||
|
|
@ -235,9 +237,9 @@ const ActionButtons = memo(({
|
|||
|
||||
// Show appropriate toast
|
||||
if (wasInCollection) {
|
||||
showInfo('Removed from Collection', 'Removed from your Trakt collection');
|
||||
showInfo(t('metadata.removed_from_collection_hero'), t('metadata.removed_from_collection_desc_hero'));
|
||||
} else {
|
||||
showSuccess('Added to Collection', 'Added to your Trakt collection');
|
||||
showSuccess(t('metadata.added_to_collection_hero'), t('metadata.added_to_collection_desc_hero'));
|
||||
}
|
||||
}, [onToggleCollection, isInCollection, showSuccess, showInfo]);
|
||||
|
||||
|
|
@ -263,7 +265,7 @@ const ActionButtons = memo(({
|
|||
const finalPlayButtonText = useMemo(() => {
|
||||
// For movies, handle watched state
|
||||
if (type === 'movie') {
|
||||
return isWatched ? 'Watch Again' : playButtonText;
|
||||
return isWatched ? t('metadata.watch_again') : playButtonText;
|
||||
}
|
||||
|
||||
// For series, validate next episode existence for both watched and resume cases
|
||||
|
|
@ -306,7 +308,7 @@ const ActionButtons = memo(({
|
|||
return `Play S${seasonStr}E${episodeStr}`;
|
||||
} else {
|
||||
// If next episode doesn't exist, show generic text
|
||||
return 'Completed';
|
||||
return t('metadata.completed');
|
||||
}
|
||||
} else {
|
||||
// For non-watched episodes, check if current episode exists
|
||||
|
|
@ -320,17 +322,17 @@ const ActionButtons = memo(({
|
|||
return playButtonText;
|
||||
} else {
|
||||
// Current episode doesn't exist, fallback to generic play
|
||||
return 'Play';
|
||||
return t('metadata.play');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback label if parsing fails
|
||||
return isWatched ? 'Play Next Episode' : playButtonText;
|
||||
return isWatched ? t('metadata.play_next_episode') : playButtonText;
|
||||
}
|
||||
|
||||
// Default fallback for non-series or missing data
|
||||
return isWatched ? 'Play' : playButtonText;
|
||||
return isWatched ? t('metadata.play') : playButtonText;
|
||||
}, [isWatched, playButtonText, type, watchProgress, groupedEpisodes]);
|
||||
|
||||
// Count additional buttons (excluding Play and Save) - AI Chat no longer counted
|
||||
|
|
@ -394,7 +396,7 @@ const ActionButtons = memo(({
|
|||
color={inLibrary ? (isAuthenticated && isInWatchlist ? "#E74C3C" : currentTheme.colors.white) : currentTheme.colors.white}
|
||||
/>
|
||||
<Text style={[styles.infoButtonText, isTablet && styles.tabletInfoButtonText]}>
|
||||
{inLibrary ? 'Saved' : 'Save'}
|
||||
{inLibrary ? t('metadata.saved') : t('metadata.save')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -484,6 +486,7 @@ const WatchProgressDisplay = memo(({
|
|||
trailerReady: boolean;
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
const { isAuthenticated: isTraktAuthenticated, forceSyncTraktProgress } = useTraktContext();
|
||||
|
||||
// State to trigger refresh after manual sync
|
||||
|
|
@ -567,7 +570,7 @@ const WatchProgressDisplay = memo(({
|
|||
progressPercent: 100,
|
||||
formattedTime: watchedDate,
|
||||
episodeInfo,
|
||||
displayText: watchedViaTrakt ? 'Watched on Trakt' : 'Watched',
|
||||
displayText: watchedViaTrakt ? t('metadata.watched_on_trakt') : t('metadata.watched'),
|
||||
syncStatus: isTraktAuthenticated && watchProgress?.traktSynced ? '' : '', // Clean look for watched
|
||||
isTraktSynced: watchProgress?.traktSynced && isTraktAuthenticated,
|
||||
isWatched: true
|
||||
|
|
@ -597,22 +600,22 @@ const WatchProgressDisplay = memo(({
|
|||
}
|
||||
|
||||
// Enhanced display text with Trakt integration
|
||||
let displayText = progressPercent >= 85 ? 'Watched' : `${Math.round(progressPercent)}% watched`;
|
||||
let displayText = progressPercent >= 85 ? t('metadata.watched') : t('metadata.percent_watched', { percent: Math.round(progressPercent) });
|
||||
let syncStatus = '';
|
||||
|
||||
// Show Trakt sync status if user is authenticated
|
||||
if (isTraktAuthenticated) {
|
||||
if (isUsingTraktProgress) {
|
||||
syncStatus = ' • Using Trakt progress';
|
||||
syncStatus = ' • ' + t('metadata.using_trakt_progress');
|
||||
if (watchProgress.traktSynced) {
|
||||
syncStatus = ' • Synced with Trakt';
|
||||
syncStatus = ' • ' + t('metadata.synced_with_trakt_progress');
|
||||
}
|
||||
} else if (watchProgress.traktSynced) {
|
||||
syncStatus = ' • Synced with Trakt';
|
||||
syncStatus = ' • ' + t('metadata.synced_with_trakt_progress');
|
||||
// If we have specific Trakt progress that differs from local, mention it
|
||||
if (watchProgress.traktProgress !== undefined &&
|
||||
Math.abs(progressPercent - watchProgress.traktProgress) > 5) {
|
||||
displayText = `${Math.round(progressPercent)}% watched (${Math.round(watchProgress.traktProgress)}% on Trakt)`;
|
||||
displayText = t('metadata.percent_watched_trakt', { percent: Math.round(progressPercent), traktPercent: Math.round(watchProgress.traktProgress) });
|
||||
}
|
||||
} else {
|
||||
// Do not show "Sync pending" label anymore; leave status empty.
|
||||
|
|
|
|||
|
|
@ -233,21 +233,10 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
)}
|
||||
{metadata.imdbRating && !isMDBEnabled && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<FastImage
|
||||
source={{ uri: 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png' }}
|
||||
style={[
|
||||
styles.imdbLogo,
|
||||
{
|
||||
width: isTV ? 42 : isLargeTablet ? 38 : isTablet ? 35 : 35,
|
||||
height: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 18 : 18
|
||||
}
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.ratingText,
|
||||
{
|
||||
color: currentTheme.colors.text,
|
||||
color: '#F5C518',
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 15
|
||||
}
|
||||
]}>{metadata.imdbRating}</Text>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useNavigation, StackActions } from '@react-navigation/native';
|
||||
import { NavigationProp } from '@react-navigation/native';
|
||||
|
|
@ -39,6 +40,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
recommendations,
|
||||
loadingRecommendations
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
|
||||
|
|
@ -112,9 +114,9 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error navigating to recommendation:', error);
|
||||
setAlertTitle('Error');
|
||||
setAlertMessage('Unable to load this content. Please try again later.');
|
||||
setAlertActions([{ label: 'OK', onPress: () => { } }]);
|
||||
setAlertTitle(t('common.error'));
|
||||
setAlertMessage(t('metadata.something_went_wrong'));
|
||||
setAlertActions([{ label: t('common.ok'), onPress: () => { } }]);
|
||||
setAlertVisible(true);
|
||||
}
|
||||
};
|
||||
|
|
@ -149,7 +151,7 @@ export const MoreLikeThisSection: React.FC<MoreLikeThisSectionProps> = ({
|
|||
|
||||
return (
|
||||
<View style={[styles.container, { paddingLeft: 0 }]}>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>More Like This</Text>
|
||||
<Text style={[styles.sectionTitle, { color: currentTheme.colors.highEmphasis, fontSize: isTV ? 24 : isLargeTablet ? 22 : isTablet ? 20 : 20, paddingHorizontal: horizontalPadding }]}>{t('metadata.more_like_this')}</Text>
|
||||
<FlatList
|
||||
data={recommendations}
|
||||
renderItem={renderItem}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import React from 'react';
|
||||
import HDSvg from '../../assets/qualitybadge/HD.svg';
|
||||
import VISIONSvg from '../../assets/qualitybadge/VISION.svg';
|
||||
import ADSvg from '../../assets/qualitybadge/AD.svg';
|
||||
|
||||
interface QualityBadgeProps {
|
||||
|
|
@ -17,8 +16,6 @@ const QualityBadge: React.FC<QualityBadgeProps> = ({ type }) => {
|
|||
switch (type) {
|
||||
case 'HD':
|
||||
return <HDSvg {...svgProps} />;
|
||||
case 'VISION':
|
||||
return <VISIONSvg {...svgProps} />;
|
||||
case 'AD':
|
||||
return <ADSvg {...svgProps} />;
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, Image, Animated, Dimensions } from 'react-native';
|
||||
import { MaterialIcons as MaterialIconsWrapper } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useMDBListRatings } from '../../hooks/useMDBListRatings';
|
||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||
|
|
@ -158,42 +159,49 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
// Define the order and icons/colors for the ratings
|
||||
const ratingConfig = {
|
||||
imdb: {
|
||||
icon: require('../../../assets/rating-icons/imdb.png'),
|
||||
isImage: true,
|
||||
name: 'IMDb',
|
||||
icon: null, // No icon for IMDb
|
||||
isImage: false,
|
||||
color: '#F5C518',
|
||||
transform: (value: number) => value.toFixed(1)
|
||||
},
|
||||
tmdb: {
|
||||
name: 'TMDB',
|
||||
icon: TMDBIcon,
|
||||
isImage: false,
|
||||
color: '#01B4E4',
|
||||
transform: (value: number) => value.toFixed(0)
|
||||
},
|
||||
trakt: {
|
||||
name: 'Trakt',
|
||||
icon: TraktIcon,
|
||||
isImage: false,
|
||||
color: '#ED1C24',
|
||||
transform: (value: number) => value.toFixed(0)
|
||||
},
|
||||
letterboxd: {
|
||||
name: 'Letterboxd',
|
||||
icon: LetterboxdIcon,
|
||||
isImage: false,
|
||||
color: '#00E054',
|
||||
transform: (value: number) => value.toFixed(1)
|
||||
},
|
||||
tomatoes: {
|
||||
name: 'Rotten Tomatoes',
|
||||
icon: RottenTomatoesIcon,
|
||||
isImage: false,
|
||||
color: '#FA320A',
|
||||
transform: (value: number) => Math.round(value).toString() + '%'
|
||||
},
|
||||
audience: {
|
||||
name: 'Audience Score',
|
||||
icon: AudienceScoreIcon,
|
||||
isImage: true,
|
||||
color: '#FA320A',
|
||||
transform: (value: number) => Math.round(value).toString() + '%'
|
||||
},
|
||||
metacritic: {
|
||||
name: 'Metacritic',
|
||||
icon: MetacriticIcon,
|
||||
isImage: true,
|
||||
color: '#FFCC33',
|
||||
|
|
@ -240,13 +248,23 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
style={[styles.compactRatingIcon, { width: iconSize, height: iconSize, marginRight: iconTextGap }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
) : config.icon ? (
|
||||
<View style={[styles.compactSvgContainer, { marginRight: iconTextGap }]}>
|
||||
{React.createElement(config.icon as any, {
|
||||
width: iconSize,
|
||||
height: iconSize,
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
// Text fallback
|
||||
<Text style={{
|
||||
color: config.color,
|
||||
fontSize: textSize,
|
||||
fontWeight: '900',
|
||||
marginRight: iconTextGap
|
||||
}}>
|
||||
{config.name}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[styles.compactRatingValue, { color: config.color, fontSize: textSize }]}>
|
||||
{displayValue}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useState, useRef, useCallback, useMemo, memo } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, ActivityIndicator, Dimensions, useWindowDimensions, useColorScheme, FlatList, Modal, Pressable } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
|
@ -40,7 +41,6 @@ interface SeriesContentProps {
|
|||
const DEFAULT_PLACEHOLDER = 'https://via.placeholder.com/300x450/1a1a1a/666666?text=No+Image';
|
||||
const EPISODE_PLACEHOLDER = 'https://via.placeholder.com/500x280/1a1a1a/666666?text=No+Preview';
|
||||
const TMDB_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/8/89/Tmdb.new.logo.svg/512px-Tmdb.new.logo.svg.png?20200406190906';
|
||||
const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png';
|
||||
|
||||
const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
||||
episodes,
|
||||
|
|
@ -54,6 +54,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
const isDarkMode = useColorScheme() === 'dark';
|
||||
|
||||
|
|
@ -489,9 +490,18 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Add effect to scroll to selected season
|
||||
// Track previous season to only scroll when it actually changes
|
||||
const previousSeasonRef = React.useRef<number | null>(null);
|
||||
|
||||
// Add effect to scroll to selected season (only when season changes, not on every groupedEpisodes update)
|
||||
useEffect(() => {
|
||||
if (selectedSeason && seasonScrollViewRef.current && Object.keys(groupedEpisodes).length > 0) {
|
||||
// Only scroll if the season actually changed (not just groupedEpisodes update)
|
||||
if (previousSeasonRef.current === selectedSeason) {
|
||||
return; // Season didn't change, don't scroll
|
||||
}
|
||||
previousSeasonRef.current = selectedSeason;
|
||||
|
||||
// Find the index of the selected season
|
||||
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
|
||||
const selectedIndex = seasons.findIndex(season => season === selectedSeason);
|
||||
|
|
@ -731,7 +741,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return (
|
||||
<View style={styles.centeredContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>Loading episodes...</Text>
|
||||
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>{t('metadata.loading_episodes')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -740,7 +750,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
return (
|
||||
<View style={styles.centeredContainer}>
|
||||
<MaterialIcons name="error-outline" size={48} color={currentTheme.colors.textMuted} />
|
||||
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>No episodes available</Text>
|
||||
<Text style={[styles.centeredText, { color: currentTheme.colors.text }]}>{t('metadata.no_episodes_available')}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
|
@ -776,7 +786,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
color: currentTheme.colors.highEmphasis,
|
||||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 18
|
||||
}
|
||||
]}>Seasons</Text>
|
||||
]}>{t('metadata.seasons')}</Text>
|
||||
|
||||
{/* Dropdown Toggle Button */}
|
||||
<TouchableOpacity
|
||||
|
|
@ -855,7 +865,6 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
styles.seasonTextButton,
|
||||
{
|
||||
marginRight: seasonButtonSpacing,
|
||||
width: isTV ? 150 : isLargeTablet ? 140 : isTablet ? 130 : 110,
|
||||
paddingVertical: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12,
|
||||
paddingHorizontal: isTV ? 20 : isLargeTablet ? 18 : isTablet ? 16 : 16,
|
||||
borderRadius: isTV ? 16 : isLargeTablet ? 14 : isTablet ? 12 : 12
|
||||
|
|
@ -874,7 +883,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
{ color: currentTheme.colors.highEmphasis }
|
||||
]
|
||||
]} numberOfLines={1}>
|
||||
{season === 0 ? 'Specials' : `Season ${season}`}
|
||||
{season === 0 ? t('metadata.specials') : t('metadata.season_number', { number: season })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -937,7 +946,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
]
|
||||
]}
|
||||
>
|
||||
{season === 0 ? 'Specials' : `Season ${season}`}
|
||||
{season === 0 ? t('metadata.specials') : t('metadata.season_number', { number: season })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -1157,17 +1166,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
<View style={styles.ratingContainer}>
|
||||
{isImdbRating ? (
|
||||
<>
|
||||
<FastImage
|
||||
source={{ uri: IMDb_LOGO }}
|
||||
style={[
|
||||
styles.imdbLogo,
|
||||
{
|
||||
width: isTV ? 32 : isLargeTablet ? 30 : isTablet ? 28 : 28,
|
||||
height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15
|
||||
}
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
|
||||
<Text style={[
|
||||
styles.ratingText,
|
||||
{
|
||||
|
|
@ -1423,17 +1422,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
<View style={styles.ratingContainerHorizontal}>
|
||||
{isImdbRating ? (
|
||||
<>
|
||||
<FastImage
|
||||
source={{ uri: IMDb_LOGO }}
|
||||
style={[
|
||||
styles.imdbLogoHorizontal,
|
||||
{
|
||||
width: isTV ? 32 : isLargeTablet ? 30 : isTablet ? 28 : 28,
|
||||
height: isTV ? 17 : isLargeTablet ? 16 : isTablet ? 15 : 15
|
||||
}
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
|
||||
<Text style={[
|
||||
styles.ratingTextHorizontal,
|
||||
{
|
||||
|
|
@ -1447,7 +1436,17 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
</>
|
||||
) : (
|
||||
<>
|
||||
<MaterialIcons name="star" size={isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14} color="#FFD700" />
|
||||
<FastImage
|
||||
source={{ uri: TMDB_LOGO }}
|
||||
style={[
|
||||
styles.tmdbLogo,
|
||||
{
|
||||
width: isTV ? 22 : isLargeTablet ? 20 : isTablet ? 20 : 20,
|
||||
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14
|
||||
}
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.ratingTextHorizontal,
|
||||
{
|
||||
|
|
@ -1548,7 +1547,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
paddingHorizontal: horizontalPadding
|
||||
}
|
||||
]}>
|
||||
{currentSeasonEpisodes.length} {currentSeasonEpisodes.length === 1 ? 'Episode' : 'Episodes'}
|
||||
{currentSeasonEpisodes.length === 1 ? t('metadata.episode_count', { count: currentSeasonEpisodes.length }) : t('metadata.episode_count_plural', { count: currentSeasonEpisodes.length })}
|
||||
</Text>
|
||||
|
||||
{/* Show message when no episodes are available for selected season */}
|
||||
|
|
@ -1556,10 +1555,10 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
<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}
|
||||
{t('metadata.no_episodes_for_season', { season: selectedSeason })}
|
||||
</Text>
|
||||
<Text style={[styles.centeredSubText, { color: currentTheme.colors.textMuted }]}>
|
||||
Episodes may not be released yet
|
||||
{t('metadata.episodes_not_released')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -1739,7 +1738,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
fontSize: isTV ? 16 : 15,
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{markingAsWatched ? 'Removing...' : 'Mark as Unwatched'}
|
||||
{markingAsWatched ? t('metadata.removing') : t('metadata.mark_as_unwatched')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
|
|
@ -1766,7 +1765,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
fontSize: isTV ? 16 : 15,
|
||||
fontWeight: '600',
|
||||
}}>
|
||||
{markingAsWatched ? 'Marking...' : 'Mark as Watched'}
|
||||
{markingAsWatched ? t('metadata.marking') : t('metadata.mark_as_watched')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)
|
||||
|
|
@ -1798,7 +1797,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
fontWeight: '500',
|
||||
flex: 1, // Allow text to take up space
|
||||
}} numberOfLines={1}>
|
||||
{markingAsWatched ? 'Removing...' : `Unmark Season ${selectedSeason}`}
|
||||
{markingAsWatched ? t('metadata.removing') : t('metadata.unmark_season', { season: selectedSeason })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
|
|
@ -1826,7 +1825,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
fontWeight: '500',
|
||||
flex: 1,
|
||||
}} numberOfLines={1}>
|
||||
{markingAsWatched ? 'Marking...' : `Mark Season ${selectedSeason}`}
|
||||
{markingAsWatched ? t('metadata.marking') : t('metadata.mark_season', { season: selectedSeason })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
|
@ -1845,7 +1844,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
fontSize: isTV ? 15 : 14,
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
Cancel
|
||||
{t('common.cancel')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -1995,10 +1994,6 @@ const styles = StyleSheet.create({
|
|||
width: 20,
|
||||
height: 14,
|
||||
},
|
||||
imdbLogo: {
|
||||
width: 35,
|
||||
height: 18,
|
||||
},
|
||||
ratingText: {
|
||||
color: '#01b4e4',
|
||||
fontSize: 13,
|
||||
|
|
@ -2186,10 +2181,7 @@ const styles = StyleSheet.create({
|
|||
// chip background removed
|
||||
gap: 2,
|
||||
},
|
||||
imdbLogoHorizontal: {
|
||||
width: 35,
|
||||
height: 18,
|
||||
},
|
||||
|
||||
ratingTextHorizontal: {
|
||||
color: '#FFD700',
|
||||
fontSize: 11,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { useTrailer } from '../../contexts/TrailerContext';
|
||||
import { logger } from '../../utils/logger';
|
||||
|
|
@ -19,24 +20,6 @@ import Video, { VideoRef, OnLoadData, OnProgressData } from 'react-native-video'
|
|||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
// Helper function to format trailer type
|
||||
const formatTrailerType = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'Official Trailer';
|
||||
case 'Teaser':
|
||||
return 'Teaser';
|
||||
case 'Clip':
|
||||
return 'Clip';
|
||||
case 'Featurette':
|
||||
return 'Featurette';
|
||||
case 'Behind the Scenes':
|
||||
return 'Behind the Scenes';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
interface TrailerVideo {
|
||||
id: string;
|
||||
key: string;
|
||||
|
|
@ -61,8 +44,28 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
trailer,
|
||||
contentTitle
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const { pauseTrailer, resumeTrailer } = useTrailer();
|
||||
|
||||
// Helper function to format trailer type with translations
|
||||
const formatTrailerType = useCallback((type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return t('trailers.official_trailer');
|
||||
case 'Teaser':
|
||||
return t('trailers.teaser');
|
||||
case 'Clip':
|
||||
return t('trailers.clip');
|
||||
case 'Featurette':
|
||||
return t('trailers.featurette');
|
||||
case 'Behind the Scenes':
|
||||
return t('trailers.behind_the_scenes');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const videoRef = React.useRef<VideoRef>(null);
|
||||
const [trailerUrl, setTrailerUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -126,9 +129,9 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
logger.error('TrailerModal', 'Error loading trailer:', err);
|
||||
|
||||
Alert.alert(
|
||||
'Trailer Unavailable',
|
||||
'This trailer could not be loaded at this time. Please try again later.',
|
||||
[{ text: 'OK', style: 'default' }]
|
||||
t('trailers.unavailable'),
|
||||
t('trailers.unavailable_desc'),
|
||||
[{ text: t('common.ok'), style: 'default' }]
|
||||
);
|
||||
}
|
||||
}, [trailer, contentTitle, pauseTrailer]);
|
||||
|
|
@ -232,7 +235,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
hitSlop={{ top: 10, left: 10, right: 10, bottom: 10 }}
|
||||
>
|
||||
<Text style={[styles.closeButtonText, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Close
|
||||
{t('common.close')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
|
@ -257,7 +260,7 @@ const TrailerModal: React.FC<TrailerModalProps> = memo(({
|
|||
style={[styles.retryButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={loadTrailer}
|
||||
>
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
<Text style={styles.retryButtonText}>{t('common.try_again')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
ScrollView,
|
||||
Modal,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
|
|
@ -59,6 +60,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
contentId,
|
||||
contentTitle
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { currentTheme } = useTheme();
|
||||
const { settings } = useSettings();
|
||||
const { pauseTrailer } = useTrailer();
|
||||
|
|
@ -414,22 +416,22 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
};
|
||||
|
||||
// Format trailer type for display
|
||||
const formatTrailerType = (type: string): string => {
|
||||
const formatTrailerType = useCallback((type: string): string => {
|
||||
switch (type) {
|
||||
case 'Trailer':
|
||||
return 'Official Trailers';
|
||||
return t('trailers.official_trailers');
|
||||
case 'Teaser':
|
||||
return 'Teasers';
|
||||
return t('trailers.teasers');
|
||||
case 'Clip':
|
||||
return 'Clips & Scenes';
|
||||
return t('trailers.clips_scenes');
|
||||
case 'Featurette':
|
||||
return 'Featurettes';
|
||||
return t('trailers.featurettes');
|
||||
case 'Behind the Scenes':
|
||||
return 'Behind the Scenes';
|
||||
return t('trailers.behind_the_scenes');
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// Get icon for trailer type
|
||||
const getTrailerTypeIcon = (type: string): string => {
|
||||
|
|
@ -483,12 +485,12 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
<View style={styles.header}>
|
||||
<MaterialIcons name="movie" size={20} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Trailers
|
||||
{t('trailers.title')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.noTrailersContainer}>
|
||||
<Text style={[styles.noTrailersText, { color: currentTheme.colors.textMuted }]}>
|
||||
No trailers available
|
||||
{t('trailers.no_trailers')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -512,7 +514,7 @@ const TrailersSection: React.FC<TrailersSectionProps> = memo(({
|
|||
fontSize: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 20
|
||||
}
|
||||
]}>
|
||||
Trailers & Videos
|
||||
{t('trailers.title')}
|
||||
</Text>
|
||||
|
||||
{/* Category Selector - Right Aligned */}
|
||||
|
|
|
|||
172
src/components/onboarding/ShapeAnimation.tsx
Normal file
172
src/components/onboarding/ShapeAnimation.tsx
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useWindowDimensions, StyleSheet } from 'react-native';
|
||||
import {
|
||||
Blur,
|
||||
BlurMask,
|
||||
Canvas,
|
||||
Circle,
|
||||
Extrapolate,
|
||||
interpolate,
|
||||
interpolateColors,
|
||||
LinearGradient,
|
||||
Path,
|
||||
RadialGradient,
|
||||
usePathValue,
|
||||
vec,
|
||||
} from '@shopify/react-native-skia';
|
||||
import {
|
||||
Easing,
|
||||
useSharedValue,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
SharedValue,
|
||||
useDerivedValue,
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
import {
|
||||
type Point3D,
|
||||
N_POINTS,
|
||||
ALL_SHAPES,
|
||||
ALL_SHAPES_X,
|
||||
ALL_SHAPES_Y,
|
||||
ALL_SHAPES_Z,
|
||||
} from './shapes';
|
||||
|
||||
// Color palettes for each shape (gradient stops)
|
||||
const COLOR_STOPS = [
|
||||
{ start: '#FFD700', end: '#FF6B00' }, // Star: Gold → Orange
|
||||
{ start: '#7C3AED', end: '#EC4899' }, // Plugin: Purple → Pink
|
||||
{ start: '#00D9FF', end: '#0EA5E9' }, // Search: Cyan → Blue
|
||||
{ start: '#FF006E', end: '#FB7185' }, // Heart: Pink → Rose
|
||||
];
|
||||
|
||||
// ============ 3D UTILITIES ============
|
||||
const rotateX = (p: Point3D, angle: number): Point3D => {
|
||||
'worklet';
|
||||
return {
|
||||
x: p.x,
|
||||
y: p.y * Math.cos(angle) - p.z * Math.sin(angle),
|
||||
z: p.y * Math.sin(angle) + p.z * Math.cos(angle),
|
||||
};
|
||||
};
|
||||
|
||||
const rotateY = (p: Point3D, angle: number): Point3D => {
|
||||
'worklet';
|
||||
return {
|
||||
x: p.x * Math.cos(angle) + p.z * Math.sin(angle),
|
||||
y: p.y,
|
||||
z: -p.x * Math.sin(angle) + p.z * Math.cos(angle),
|
||||
};
|
||||
};
|
||||
|
||||
interface ShapeAnimationProps {
|
||||
scrollX: SharedValue<number>;
|
||||
}
|
||||
|
||||
export const ShapeAnimation: React.FC<ShapeAnimationProps> = ({ scrollX }) => {
|
||||
const iTime = useSharedValue(0.0);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowDimensions();
|
||||
|
||||
// Pre-compute input range once
|
||||
const shapeWidth = windowWidth;
|
||||
const inputRange = ALL_SHAPES.map((_, idx) => shapeWidth * idx);
|
||||
|
||||
// Single optimized path - all 4 shapes batched into one Skia Path
|
||||
const morphPath = usePathValue(skPath => {
|
||||
'worklet';
|
||||
const centerX = windowWidth / 2;
|
||||
const centerY = windowHeight * 0.65;
|
||||
const distance = 350;
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
// Interpolate 3D coordinates between all shapes
|
||||
const baseX = interpolate(scrollX.value, inputRange, ALL_SHAPES_X[i], Extrapolate.CLAMP);
|
||||
const baseY = interpolate(scrollX.value, inputRange, ALL_SHAPES_Y[i], Extrapolate.CLAMP);
|
||||
const baseZ = interpolate(scrollX.value, inputRange, ALL_SHAPES_Z[i], Extrapolate.CLAMP);
|
||||
|
||||
// Apply 3D rotation
|
||||
let p: Point3D = { x: baseX, y: baseY, z: baseZ };
|
||||
p = rotateX(p, 0.2); // Fixed X tilt
|
||||
p = rotateY(p, iTime.value); // Animated Y rotation
|
||||
|
||||
// Perspective projection
|
||||
const scale = distance / (distance + p.z);
|
||||
const screenX = centerX + p.x * scale;
|
||||
const screenY = centerY + p.y * scale;
|
||||
|
||||
// Depth-based radius for parallax effect
|
||||
const radius = Math.max(0.2, 0.5 * scale);
|
||||
skPath.addCircle(screenX, screenY, radius);
|
||||
}
|
||||
|
||||
return skPath;
|
||||
});
|
||||
|
||||
// Interpolate gradient colors based on scroll position
|
||||
const gradientColors = useDerivedValue(() => {
|
||||
const startColors = COLOR_STOPS.map(c => c.start);
|
||||
const endColors = COLOR_STOPS.map(c => c.end);
|
||||
|
||||
const start = interpolateColors(scrollX.value, inputRange, startColors);
|
||||
const end = interpolateColors(scrollX.value, inputRange, endColors);
|
||||
|
||||
return [start, end];
|
||||
});
|
||||
|
||||
// Rotation animation - infinite loop
|
||||
useEffect(() => {
|
||||
iTime.value = 0;
|
||||
iTime.value = withRepeat(
|
||||
withTiming(2 * Math.PI, {
|
||||
duration: 12000,
|
||||
easing: Easing.linear,
|
||||
}),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
style={[
|
||||
styles.canvas,
|
||||
{
|
||||
width: windowWidth,
|
||||
height: windowHeight,
|
||||
},
|
||||
]}>
|
||||
{/* Background glow */}
|
||||
<Circle
|
||||
cx={windowWidth / 2}
|
||||
cy={windowHeight * 0.65}
|
||||
r={windowWidth * 0.6}>
|
||||
<RadialGradient
|
||||
c={vec(windowWidth / 2, windowHeight * 0.65)}
|
||||
r={windowWidth * 0.6}
|
||||
colors={['#ffffff20', 'transparent']}
|
||||
/>
|
||||
<Blur blur={60} />
|
||||
</Circle>
|
||||
|
||||
{/* Single optimized path with interpolated gradient */}
|
||||
<Path path={morphPath} style="fill">
|
||||
<LinearGradient
|
||||
start={vec(0, windowHeight * 0.4)}
|
||||
end={vec(windowWidth, windowHeight * 0.9)}
|
||||
colors={gradientColors}
|
||||
/>
|
||||
<BlurMask blur={5} style="solid" />
|
||||
</Path>
|
||||
</Canvas>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
canvas: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default ShapeAnimation;
|
||||
8
src/components/onboarding/shapes/constants.ts
Normal file
8
src/components/onboarding/shapes/constants.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Fixed number of points for all shapes (for interpolation)
|
||||
// Lower = better FPS, 1000 points is a good balance for smooth 60fps
|
||||
export const N_POINTS = 1000;
|
||||
|
||||
export const GOLDEN_RATIO = (1 + Math.sqrt(5)) / 2;
|
||||
|
||||
// Normalize a shape to have height TARGET_HEIGHT
|
||||
export const TARGET_HEIGHT = 200;
|
||||
35
src/components/onboarding/shapes/cube.ts
Normal file
35
src/components/onboarding/shapes/cube.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Cube - map sphere to cube
|
||||
const generateCubePoints = (size: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const s = size / 2;
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
// Point on unit sphere
|
||||
const sx = Math.sin(phi) * Math.cos(theta);
|
||||
const sy = Math.sin(phi) * Math.sin(theta);
|
||||
const sz = Math.cos(phi);
|
||||
|
||||
// Map to cube (cube mapping)
|
||||
const absX = Math.abs(sx);
|
||||
const absY = Math.abs(sy);
|
||||
const absZ = Math.abs(sz);
|
||||
const max = Math.max(absX, absY, absZ);
|
||||
|
||||
points.push({
|
||||
x: (sx / max) * s,
|
||||
y: (sy / max) * s,
|
||||
z: (sz / max) * s,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const CUBE_POINTS = scaleShape(
|
||||
normalizeShape(generateCubePoints(150)),
|
||||
0.75,
|
||||
);
|
||||
35
src/components/onboarding/shapes/heart.ts
Normal file
35
src/components/onboarding/shapes/heart.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape } from './utils';
|
||||
|
||||
// Heart - starts from Fibonacci sphere, deforms into heart
|
||||
const generateHeartPoints = (scale: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
|
||||
// Use same angular coordinates as sphere
|
||||
const u = theta;
|
||||
const v = phi;
|
||||
const sinV = Math.sin(v);
|
||||
|
||||
// Heart surface with same angular correspondence
|
||||
const hx = sinV * (15 * Math.sin(u) - 4 * Math.sin(3 * u));
|
||||
const hz = 8 * Math.cos(v);
|
||||
const hy =
|
||||
sinV *
|
||||
(15 * Math.cos(u) -
|
||||
5 * Math.cos(2 * u) -
|
||||
2 * Math.cos(3 * u) -
|
||||
Math.cos(4 * u));
|
||||
|
||||
points.push({
|
||||
x: hx * scale * 0.06,
|
||||
y: -hy * scale * 0.06,
|
||||
z: hz * scale * 0.06,
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const HEART_POINTS = normalizeShape(generateHeartPoints(120));
|
||||
28
src/components/onboarding/shapes/index.ts
Normal file
28
src/components/onboarding/shapes/index.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export { type Point3D } from './types';
|
||||
export { N_POINTS } from './constants';
|
||||
|
||||
import { N_POINTS } from './constants';
|
||||
import { STAR_POINTS } from './star'; // Welcome to Nuvio
|
||||
import { PLUGIN_POINTS } from './plugin'; // Powerful Addons
|
||||
import { SEARCH_POINTS } from './search'; // Smart Discovery
|
||||
import { HEART_POINTS } from './heart'; // Your Library (favorites)
|
||||
|
||||
// Array of all shapes - ordered to match onboarding slides
|
||||
export const ALL_SHAPES = [
|
||||
STAR_POINTS, // Slide 1: Welcome
|
||||
PLUGIN_POINTS, // Slide 2: Addons
|
||||
SEARCH_POINTS, // Slide 3: Discovery
|
||||
HEART_POINTS, // Slide 4: Library
|
||||
];
|
||||
|
||||
export const POINTS_ARRAY = new Array(N_POINTS).fill(0);
|
||||
|
||||
export const ALL_SHAPES_X = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].x),
|
||||
);
|
||||
export const ALL_SHAPES_Y = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].y),
|
||||
);
|
||||
export const ALL_SHAPES_Z = POINTS_ARRAY.map((_, pointIndex) =>
|
||||
ALL_SHAPES.map(shape => shape[pointIndex].z),
|
||||
);
|
||||
96
src/components/onboarding/shapes/plugin.ts
Normal file
96
src/components/onboarding/shapes/plugin.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// LEGO Brick shape - perfectly represents "Addons" or "Plugins"
|
||||
const generateLegoPoints = (): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
|
||||
// Dimensions
|
||||
const width = 160;
|
||||
const depth = 80;
|
||||
const height = 48;
|
||||
const studRadius = 12;
|
||||
const studHeight = 16;
|
||||
|
||||
// Distribute points: 70% body, 30% studs
|
||||
const bodyPoints = Math.floor(N_POINTS * 0.7);
|
||||
const studPoints = N_POINTS - bodyPoints;
|
||||
const pointsPerStud = Math.floor(studPoints / 8); // 8 studs (2x4 brick)
|
||||
|
||||
// 1. Main Brick Body (Rectangular Prism)
|
||||
for (let i = 0; i < bodyPoints; i++) {
|
||||
const t1 = Math.random();
|
||||
const t2 = Math.random();
|
||||
const t3 = Math.random();
|
||||
|
||||
// Create density concentration on edges for better definition
|
||||
const x = (Math.pow(t1, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * width / 2;
|
||||
const y = (Math.pow(t2, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * height / 2;
|
||||
const z = (Math.pow(t3, 0.5) * (Math.random() > 0.5 ? 1 : -1)) * depth / 2;
|
||||
|
||||
// Snapping to faces to make it look solid
|
||||
const face = Math.floor(Math.random() * 6);
|
||||
let px = x, py = y, pz = z;
|
||||
|
||||
if (face === 0) px = width / 2;
|
||||
else if (face === 1) px = -width / 2;
|
||||
else if (face === 2) py = height / 2;
|
||||
else if (face === 3) py = -height / 2;
|
||||
else if (face === 4) pz = depth / 2;
|
||||
else if (face === 5) pz = -depth / 2;
|
||||
|
||||
// Add some random noise inside/surface
|
||||
if (Math.random() > 0.8) {
|
||||
points.push({ x: x, y: y, z: z });
|
||||
} else {
|
||||
points.push({ x: px, y: py, z: pz });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Studs (Cylinders on top)
|
||||
// 2x4 Grid positions
|
||||
const studPositions = [
|
||||
{ x: -width * 0.375, z: -depth * 0.25 }, { x: -width * 0.125, z: -depth * 0.25 },
|
||||
{ x: width * 0.125, z: -depth * 0.25 }, { x: width * 0.375, z: -depth * 0.25 },
|
||||
{ x: -width * 0.375, z: depth * 0.25 }, { x: -width * 0.125, z: depth * 0.25 },
|
||||
{ x: width * 0.125, z: depth * 0.25 }, { x: width * 0.375, z: depth * 0.25 },
|
||||
];
|
||||
|
||||
studPositions.forEach((pos, studIndex) => {
|
||||
for (let j = 0; j < pointsPerStud; j++) {
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const r = Math.sqrt(Math.random()) * studRadius;
|
||||
|
||||
// Top face of stud
|
||||
if (Math.random() > 0.5) {
|
||||
points.push({
|
||||
x: pos.x + r * Math.cos(angle),
|
||||
y: -height / 2 - studHeight, // Top
|
||||
z: pos.z + r * Math.sin(angle),
|
||||
});
|
||||
} else {
|
||||
// Side of stud
|
||||
const h = Math.random() * studHeight;
|
||||
points.push({
|
||||
x: pos.x + studRadius * Math.cos(angle),
|
||||
y: -height / 2 - h,
|
||||
z: pos.z + studRadius * Math.sin(angle),
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// FILL remaining points to prevent "undefined" errors
|
||||
while (points.length < N_POINTS) {
|
||||
points.push(points[points.length - 1] || { x: 0, y: 0, z: 0 });
|
||||
}
|
||||
|
||||
// Slice to guarantee exact count
|
||||
return points.slice(0, N_POINTS);
|
||||
};
|
||||
|
||||
export const PLUGIN_POINTS = scaleShape(
|
||||
normalizeShape(generateLegoPoints()),
|
||||
0.4,
|
||||
);
|
||||
57
src/components/onboarding/shapes/search.ts
Normal file
57
src/components/onboarding/shapes/search.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Magnifying glass/search shape - for "Discovery" page
|
||||
const generateSearchPoints = (radius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const handleLength = radius * 0.8;
|
||||
const handleWidth = radius * 0.15;
|
||||
|
||||
// Split points between ring and handle
|
||||
const ringPoints = Math.floor(N_POINTS * 0.7);
|
||||
const handlePoints = N_POINTS - ringPoints;
|
||||
|
||||
// Create the circular ring (lens)
|
||||
for (let i = 0; i < ringPoints; i++) {
|
||||
const t = i / ringPoints;
|
||||
const mainAngle = t * Math.PI * 2;
|
||||
const tubeAngle = (i * 17) % 20 / 20 * Math.PI * 2; // Distribute around tube
|
||||
|
||||
const tubeRadius = radius * 0.12;
|
||||
const centerRadius = radius;
|
||||
|
||||
const cx = centerRadius * Math.cos(mainAngle);
|
||||
const cy = centerRadius * Math.sin(mainAngle);
|
||||
|
||||
points.push({
|
||||
x: cx + tubeRadius * Math.cos(tubeAngle) * Math.cos(mainAngle),
|
||||
y: cy + tubeRadius * Math.cos(tubeAngle) * Math.sin(mainAngle),
|
||||
z: tubeRadius * Math.sin(tubeAngle),
|
||||
});
|
||||
}
|
||||
|
||||
// Create the handle
|
||||
for (let i = 0; i < handlePoints; i++) {
|
||||
const t = i / handlePoints;
|
||||
const handleAngle = (i * 13) % 12 / 12 * Math.PI * 2;
|
||||
|
||||
// Handle position (extends from bottom-right of ring)
|
||||
const handleStart = radius * 0.7;
|
||||
const hx = handleStart + t * handleLength;
|
||||
const hy = handleStart + t * handleLength;
|
||||
|
||||
points.push({
|
||||
x: hx + handleWidth * Math.cos(handleAngle) * 0.3,
|
||||
y: hy + handleWidth * Math.cos(handleAngle) * 0.3,
|
||||
z: handleWidth * Math.sin(handleAngle),
|
||||
});
|
||||
}
|
||||
|
||||
return points;
|
||||
};
|
||||
|
||||
export const SEARCH_POINTS = scaleShape(
|
||||
normalizeShape(generateSearchPoints(80)),
|
||||
1.0,
|
||||
);
|
||||
19
src/components/onboarding/shapes/sphere.ts
Normal file
19
src/components/onboarding/shapes/sphere.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape } from './utils';
|
||||
|
||||
// Sphere
|
||||
const generateSpherePoints = (radius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi } = fibonacciPoint(i, N_POINTS);
|
||||
points.push({
|
||||
x: radius * Math.sin(phi) * Math.cos(theta),
|
||||
y: radius * Math.sin(phi) * Math.sin(theta),
|
||||
z: radius * Math.cos(phi),
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const SPHERE_POINTS = normalizeShape(generateSpherePoints(100));
|
||||
31
src/components/onboarding/shapes/star.ts
Normal file
31
src/components/onboarding/shapes/star.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { fibonacciPoint, normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Star shape - for "Welcome" page
|
||||
const generateStarPoints = (outerRadius: number, innerRadius: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
const numPoints = 5; // 5-pointed star
|
||||
|
||||
for (let i = 0; i < N_POINTS; i++) {
|
||||
const { theta, phi, t } = fibonacciPoint(i, N_POINTS);
|
||||
|
||||
// Create star cross-section
|
||||
const angle = theta * numPoints;
|
||||
const radiusFactor = 0.5 + 0.5 * Math.cos(angle);
|
||||
const radius = innerRadius + (outerRadius - innerRadius) * radiusFactor;
|
||||
|
||||
const sinPhi = Math.sin(phi);
|
||||
points.push({
|
||||
x: radius * sinPhi * Math.cos(theta),
|
||||
y: radius * sinPhi * Math.sin(theta),
|
||||
z: radius * Math.cos(phi) * 0.3, // Flatten z for star shape
|
||||
});
|
||||
}
|
||||
return points;
|
||||
};
|
||||
|
||||
export const STAR_POINTS = scaleShape(
|
||||
normalizeShape(generateStarPoints(100, 40)),
|
||||
0.9,
|
||||
);
|
||||
48
src/components/onboarding/shapes/torus.ts
Normal file
48
src/components/onboarding/shapes/torus.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { N_POINTS } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
import { normalizeShape, scaleShape } from './utils';
|
||||
|
||||
// Torus - uniform grid with same index correspondence
|
||||
const generateTorusPoints = (major: number, minor: number): Point3D[] => {
|
||||
const points: Point3D[] = [];
|
||||
|
||||
// Calculate approximate grid dimensions
|
||||
const ratio = major / minor;
|
||||
const minorSegments = Math.round(Math.sqrt(N_POINTS / ratio));
|
||||
const majorSegments = Math.round(N_POINTS / minorSegments);
|
||||
|
||||
let idx = 0;
|
||||
for (let i = 0; i < majorSegments && idx < N_POINTS; i++) {
|
||||
const u = (i / majorSegments) * Math.PI * 2;
|
||||
|
||||
for (let j = 0; j < minorSegments && idx < N_POINTS; j++) {
|
||||
const v = (j / minorSegments) * Math.PI * 2;
|
||||
|
||||
points.push({
|
||||
x: (major + minor * Math.cos(v)) * Math.cos(u),
|
||||
y: (major + minor * Math.cos(v)) * Math.sin(u),
|
||||
z: minor * Math.sin(v),
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fill missing points if necessary
|
||||
while (points.length < N_POINTS) {
|
||||
const t = points.length / N_POINTS;
|
||||
const u = t * Math.PI * 2 * majorSegments;
|
||||
const v = t * Math.PI * 2 * minorSegments;
|
||||
points.push({
|
||||
x: (major + minor * Math.cos(v)) * Math.cos(u),
|
||||
y: (major + minor * Math.cos(v)) * Math.sin(u),
|
||||
z: minor * Math.sin(v),
|
||||
});
|
||||
}
|
||||
|
||||
return points.slice(0, N_POINTS);
|
||||
};
|
||||
|
||||
export const TORUS_POINTS = scaleShape(
|
||||
normalizeShape(generateTorusPoints(50, 25)),
|
||||
1.2,
|
||||
);
|
||||
1
src/components/onboarding/shapes/types.ts
Normal file
1
src/components/onboarding/shapes/types.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export type Point3D = { x: number; y: number; z: number };
|
||||
54
src/components/onboarding/shapes/utils.ts
Normal file
54
src/components/onboarding/shapes/utils.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { GOLDEN_RATIO, TARGET_HEIGHT } from './constants';
|
||||
import { type Point3D } from './types';
|
||||
|
||||
// Generate Fibonacci points on unit sphere, then map to shape
|
||||
export const fibonacciPoint = (
|
||||
i: number,
|
||||
total: number,
|
||||
): { theta: number; phi: number; t: number } => {
|
||||
const t = i / total;
|
||||
const theta = (2 * Math.PI * i) / GOLDEN_RATIO;
|
||||
const phi = Math.acos(1 - 2 * t);
|
||||
return { theta, phi, t };
|
||||
};
|
||||
|
||||
export const normalizeShape = (points: Point3D[]): Point3D[] => {
|
||||
// Find min/max for each axis
|
||||
let minX = Infinity,
|
||||
maxX = -Infinity;
|
||||
let minY = Infinity,
|
||||
maxY = -Infinity;
|
||||
let minZ = Infinity,
|
||||
maxZ = -Infinity;
|
||||
|
||||
for (const p of points) {
|
||||
minX = Math.min(minX, p.x);
|
||||
maxX = Math.max(maxX, p.x);
|
||||
minY = Math.min(minY, p.y);
|
||||
maxY = Math.max(maxY, p.y);
|
||||
minZ = Math.min(minZ, p.z);
|
||||
maxZ = Math.max(maxZ, p.z);
|
||||
}
|
||||
|
||||
// Calculate current dimensions
|
||||
const currentHeight = maxY - minY;
|
||||
const scale = TARGET_HEIGHT / currentHeight;
|
||||
|
||||
// Center and scale uniformly
|
||||
const centerY = (minY + maxY) / 2;
|
||||
|
||||
return points.map(p => ({
|
||||
x: (p.x - (minX + maxX) / 2) * scale,
|
||||
y: (p.y - centerY) * scale,
|
||||
z: (p.z - (minZ + maxZ) / 2) * scale,
|
||||
}));
|
||||
};
|
||||
|
||||
// Additional scale for single shape
|
||||
export const scaleShape = (points: Point3D[], factor: number): Point3D[] => {
|
||||
return points.map(p => ({
|
||||
x: p.x * factor,
|
||||
y: p.y * factor,
|
||||
z: p.z * factor,
|
||||
}));
|
||||
};
|
||||
|
|
@ -339,7 +339,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
if (data.audioTracks) {
|
||||
const formatted = data.audioTracks.map((t: any, i: number) => ({
|
||||
id: t.index !== undefined ? t.index : i,
|
||||
// react-native-video selectedAudioTrack {type:'index'} uses 0-based list index.
|
||||
id: i,
|
||||
name: t.title || t.name || `Track ${i + 1}`,
|
||||
language: t.language
|
||||
}));
|
||||
|
|
@ -347,7 +348,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}
|
||||
if (data.textTracks) {
|
||||
const formatted = data.textTracks.map((t: any, i: number) => ({
|
||||
id: t.index !== undefined ? t.index : i,
|
||||
// react-native-video selectedTextTrack {type:'index'} uses 0-based list index.
|
||||
// Using `t.index` can be non-unique/misaligned and breaks selection/rendering.
|
||||
id: i,
|
||||
name: t.title || t.name || `Track ${i + 1}`,
|
||||
language: t.language
|
||||
}));
|
||||
|
|
@ -360,7 +363,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Auto-select audio track based on preferences
|
||||
if (data.audioTracks && data.audioTracks.length > 0 && settings?.preferredAudioLanguage) {
|
||||
const formatted = data.audioTracks.map((t: any, i: number) => ({
|
||||
id: t.index !== undefined ? t.index : i,
|
||||
id: i,
|
||||
name: t.title || t.name || `Track ${i + 1}`,
|
||||
language: t.language
|
||||
}));
|
||||
|
|
@ -380,7 +383,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Only pre-select internal if preference is internal or any
|
||||
if (sourcePreference === 'internal' || sourcePreference === 'any') {
|
||||
const formatted = data.textTracks.map((t: any, i: number) => ({
|
||||
id: t.index !== undefined ? t.index : i,
|
||||
id: i,
|
||||
name: t.title || t.name || `Track ${i + 1}`,
|
||||
language: t.language
|
||||
}));
|
||||
|
|
@ -479,11 +482,11 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
useEffect(() => {
|
||||
if (!useCustomSubtitles || customSubtitles.length === 0) return;
|
||||
|
||||
const cueNow = customSubtitles.find(
|
||||
cue => playerState.currentTime >= cue.start && playerState.currentTime <= cue.end
|
||||
);
|
||||
// Apply timing offset for custom/addon subtitles (ExoPlayer internal subtitles do not support offset)
|
||||
const adjustedTime = playerState.currentTime + (subtitleOffsetSec || 0);
|
||||
const cueNow = customSubtitles.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
|
||||
setCurrentSubtitle(cueNow ? cueNow.text : '');
|
||||
}, [playerState.currentTime, useCustomSubtitles, customSubtitles]);
|
||||
}, [playerState.currentTime, subtitleOffsetSec, useCustomSubtitles, customSubtitles]);
|
||||
|
||||
const toggleControls = useCallback(() => {
|
||||
playerState.setShowControls(prev => {
|
||||
|
|
@ -678,8 +681,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
mpvPlayerRef.current.setSubtitleTrack(-1);
|
||||
}
|
||||
|
||||
// Set initial subtitle based on current time
|
||||
const adjustedTime = playerState.currentTime;
|
||||
// Set initial subtitle based on current time (+ any timing offset)
|
||||
const adjustedTime = playerState.currentTime + (subtitleOffsetSec || 0);
|
||||
const cueNow = parsedCues.find(cue => adjustedTime >= cue.start && adjustedTime <= cue.end);
|
||||
setCurrentSubtitle(cueNow ? cueNow.text : '');
|
||||
|
||||
|
|
@ -691,7 +694,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
} finally {
|
||||
setIsLoadingSubtitles(false);
|
||||
}
|
||||
}, [modals, playerState.currentTime, tracksHook]);
|
||||
}, [modals, playerState.currentTime, subtitleOffsetSec, tracksHook]);
|
||||
|
||||
const disableCustomSubtitles = useCallback(() => {
|
||||
setUseCustomSubtitles(false);
|
||||
|
|
@ -817,6 +820,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
subtitleBorderColor={subtitleOutlineColor}
|
||||
subtitleShadowEnabled={subtitleTextShadow}
|
||||
subtitlePosition={Math.max(50, 100 - Math.floor(subtitleBottomOffset * 0.3))} // Scale offset to MPV range
|
||||
subtitleBottomOffset={subtitleBottomOffset}
|
||||
subtitleDelay={subtitleOffsetSec}
|
||||
subtitleAlignment={subtitleAlign}
|
||||
/>
|
||||
|
|
@ -943,6 +947,8 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
type={type || 'movie'}
|
||||
season={season}
|
||||
episode={episode}
|
||||
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
||||
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
||||
currentTime={playerState.currentTime}
|
||||
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
|
||||
controlsVisible={playerState.showControls}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface KSPlayerViewProps {
|
|||
subtitleFontSize?: number;
|
||||
subtitleTextColor?: string;
|
||||
subtitleBackgroundColor?: string;
|
||||
subtitleOutlineEnabled?: boolean;
|
||||
resizeMode?: 'contain' | 'cover' | 'stretch';
|
||||
onLoad?: (data: any) => void;
|
||||
onProgress?: (data: any) => void;
|
||||
|
|
@ -60,6 +61,7 @@ export interface KSPlayerProps {
|
|||
subtitleFontSize?: number;
|
||||
subtitleTextColor?: string;
|
||||
subtitleBackgroundColor?: string;
|
||||
subtitleOutlineEnabled?: boolean;
|
||||
resizeMode?: 'contain' | 'cover' | 'stretch';
|
||||
onLoad?: (data: any) => void;
|
||||
onProgress?: (data: any) => void;
|
||||
|
|
@ -210,6 +212,7 @@ const KSPlayer = forwardRef<KSPlayerRef, KSPlayerProps>((props, ref) => {
|
|||
subtitleFontSize={props.subtitleFontSize}
|
||||
subtitleTextColor={props.subtitleTextColor}
|
||||
subtitleBackgroundColor={props.subtitleBackgroundColor}
|
||||
subtitleOutlineEnabled={props.subtitleOutlineEnabled}
|
||||
resizeMode={props.resizeMode}
|
||||
onLoad={(e: any) => props.onLoad?.(e?.nativeEvent ?? e)}
|
||||
onProgress={(e: any) => props.onProgress?.(e?.nativeEvent ?? e)}
|
||||
|
|
|
|||
|
|
@ -145,18 +145,18 @@ const KSPlayerCore: React.FC = () => {
|
|||
|
||||
// Track previous video session to reset subtitle offset only when video actually changes
|
||||
const previousVideoRef = useRef<{ uri?: string; episodeId?: string }>({});
|
||||
|
||||
|
||||
// Reset subtitle offset when starting a new video session
|
||||
useEffect(() => {
|
||||
const currentVideo = { uri, episodeId };
|
||||
const previousVideo = previousVideoRef.current;
|
||||
|
||||
|
||||
// Only reset if this is actually a new video (uri or episodeId changed)
|
||||
if (previousVideo.uri !== undefined &&
|
||||
(previousVideo.uri !== currentVideo.uri || previousVideo.episodeId !== currentVideo.episodeId)) {
|
||||
if (previousVideo.uri !== undefined &&
|
||||
(previousVideo.uri !== currentVideo.uri || previousVideo.episodeId !== currentVideo.episodeId)) {
|
||||
customSubs.setSubtitleOffsetSec(0);
|
||||
}
|
||||
|
||||
|
||||
// Update the ref for next comparison
|
||||
previousVideoRef.current = currentVideo;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
|
@ -616,6 +616,10 @@ const KSPlayerCore: React.FC = () => {
|
|||
/>
|
||||
|
||||
{/* Video Surface & Pinch Zoom */}
|
||||
{/*
|
||||
For KSPlayer built-in subtitles (internal text tracks), we intentionally force background OFF.
|
||||
Background styling is only supported/used for custom (external/addon) subtitles overlay.
|
||||
*/}
|
||||
<KSPlayerSurface
|
||||
ksPlayerRef={ksPlayerRef}
|
||||
uri={uri}
|
||||
|
|
@ -656,7 +660,20 @@ const KSPlayerCore: React.FC = () => {
|
|||
screenHeight={screenDimensions.height}
|
||||
customVideoStyles={{ width: '100%', height: '100%' }}
|
||||
subtitleTextColor={customSubs.subtitleTextColor}
|
||||
subtitleBackgroundColor={customSubs.subtitleBackground ? `rgba(0,0,0,${customSubs.subtitleBgOpacity})` : 'transparent'}
|
||||
subtitleBackgroundColor={
|
||||
tracks.selectedTextTrack !== null &&
|
||||
tracks.selectedTextTrack >= 0 &&
|
||||
!customSubs.useCustomSubtitles
|
||||
? 'rgba(0,0,0,0)'
|
||||
: (customSubs.subtitleBackground ? `rgba(0,0,0,${customSubs.subtitleBgOpacity})` : 'transparent')
|
||||
}
|
||||
subtitleOutlineEnabled={
|
||||
tracks.selectedTextTrack !== null &&
|
||||
tracks.selectedTextTrack >= 0 &&
|
||||
!customSubs.useCustomSubtitles
|
||||
? customSubs.subtitleOutline
|
||||
: false
|
||||
}
|
||||
subtitleFontSize={customSubs.subtitleSize}
|
||||
subtitleBottomOffset={customSubs.subtitleBottomOffset}
|
||||
/>
|
||||
|
|
@ -750,6 +767,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
visible={speedControl.showSpeedActivatedOverlay}
|
||||
opacity={speedControl.speedActivatedOverlayOpacity}
|
||||
speed={speedControl.holdToSpeedValue}
|
||||
screenDimensions={screenDimensions}
|
||||
/>
|
||||
|
||||
<ResumeOverlay
|
||||
|
|
@ -799,6 +817,8 @@ const KSPlayerCore: React.FC = () => {
|
|||
type={type}
|
||||
season={season}
|
||||
episode={episode}
|
||||
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
||||
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
||||
currentTime={currentTime}
|
||||
onSkip={(endTime) => controls.seekToTime(endTime)}
|
||||
controlsVisible={showControls}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ interface VideoSurfaceProps {
|
|||
subtitleBorderColor?: string;
|
||||
subtitleShadowEnabled?: boolean;
|
||||
subtitlePosition?: number;
|
||||
subtitleBottomOffset?: number;
|
||||
subtitleDelay?: number;
|
||||
subtitleAlignment?: 'left' | 'center' | 'right';
|
||||
}
|
||||
|
|
@ -128,6 +129,7 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
subtitleBorderColor,
|
||||
subtitleShadowEnabled,
|
||||
subtitlePosition,
|
||||
subtitleBottomOffset,
|
||||
subtitleDelay,
|
||||
subtitleAlignment,
|
||||
}) => {
|
||||
|
|
@ -173,15 +175,19 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
console.log('[VideoSurface] ExoPlayer textTracks raw:', JSON.stringify(data.textTracks, null, 2));
|
||||
|
||||
// Extract track information
|
||||
// IMPORTANT:
|
||||
// react-native-video expects selected*Track with { type: 'index', value: <0-based array index> }.
|
||||
// Some RNVideo/Exo track objects expose `index`, but it is not guaranteed to be unique or
|
||||
// aligned with the list index. Using it can cause only the first item to render/select.
|
||||
const audioTracks = data.audioTracks?.map((t: any, i: number) => ({
|
||||
id: t.index ?? i,
|
||||
id: i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
})) ?? [];
|
||||
|
||||
const subtitleTracks = data.textTracks?.map((t: any, i: number) => {
|
||||
const track = {
|
||||
id: t.index ?? i,
|
||||
id: i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
};
|
||||
|
|
@ -281,6 +287,11 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const alphaHex = (opacity01: number) => {
|
||||
const a = Math.max(0, Math.min(1, opacity01));
|
||||
return Math.round(a * 255).toString(16).padStart(2, '0').toUpperCase();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.videoContainer, {
|
||||
width: screenDimensions.width,
|
||||
|
|
@ -314,21 +325,39 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
automaticallyWaitsToMinimizeStalling={true}
|
||||
useTextureView={true}
|
||||
// Subtitle Styling for ExoPlayer
|
||||
// ExoPlayer supports: fontSize, paddingTop/Bottom/Left/Right, opacity, subtitlesFollowVideo
|
||||
// ExoPlayer (via our patched react-native-video) supports:
|
||||
// - fontSize, paddingTop/Bottom/Left/Right, opacity, subtitlesFollowVideo
|
||||
// - PLUS: textColor, backgroundColor, edgeType, edgeColor (outline/shadow)
|
||||
subtitleStyle={{
|
||||
// Convert MPV-scaled size back to ExoPlayer scale (~1.5x conversion was applied)
|
||||
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 18,
|
||||
// Convert MPV-scaled size back to UI size (AndroidVideoPlayer passes MPV-scaled values here)
|
||||
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 28,
|
||||
paddingTop: 0,
|
||||
// Convert MPV position (0=top, 100=bottom) to paddingBottom
|
||||
// Higher MPV position = less padding from bottom
|
||||
paddingBottom: subtitlePosition ? Math.max(20, Math.round((100 - subtitlePosition) * 2)) : 60,
|
||||
// IMPORTANT:
|
||||
// Use the same unit as external subtitles (RN CustomSubtitles uses dp bottomOffset directly).
|
||||
// Using MPV's subtitlePosition mapping makes internal/external offsets feel inconsistent.
|
||||
paddingBottom: Math.max(0, Math.round(subtitleBottomOffset ?? 0)),
|
||||
paddingLeft: 16,
|
||||
paddingRight: 16,
|
||||
// Opacity controls entire subtitle view visibility
|
||||
// Always keep text visible (opacity 1), background control is limited in ExoPlayer
|
||||
opacity: 1,
|
||||
subtitlesFollowVideo: false,
|
||||
}}
|
||||
// Extended styling (requires our patched RNVideo on Android)
|
||||
textColor: subtitleColor || '#FFFFFFFF',
|
||||
// Android Color.parseColor doesn't accept rgba(...). Use #AARRGGBB.
|
||||
backgroundColor:
|
||||
subtitleBackgroundOpacity && subtitleBackgroundOpacity > 0
|
||||
? `#${alphaHex(subtitleBackgroundOpacity)}000000`
|
||||
: '#00000000',
|
||||
edgeType:
|
||||
subtitleBorderSize && subtitleBorderSize > 0
|
||||
? 'outline'
|
||||
: (subtitleShadowEnabled ? 'shadow' : 'none'),
|
||||
edgeColor:
|
||||
(subtitleBorderSize && subtitleBorderSize > 0 && subtitleBorderColor)
|
||||
? subtitleBorderColor
|
||||
: (subtitleShadowEnabled ? '#FF000000' : 'transparent'),
|
||||
} as any}
|
||||
/>
|
||||
) : (
|
||||
/* MPV Player fallback */
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
|
|||
}) => {
|
||||
const { width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
|
||||
// Get episode image
|
||||
let episodeImage = EPISODE_PLACEHOLDER;
|
||||
if (episode.still_path) {
|
||||
|
|
@ -42,11 +42,11 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
|
|||
} else if (metadata?.poster) {
|
||||
episodeImage = metadata.poster;
|
||||
}
|
||||
|
||||
|
||||
const episodeNumber = typeof episode.episode_number === 'number' ? episode.episode_number.toString() : '';
|
||||
const seasonNumber = typeof episode.season_number === 'number' ? episode.season_number.toString() : '';
|
||||
const episodeString = seasonNumber && episodeNumber ? `S${seasonNumber.padStart(2, '0')}E${episodeNumber.padStart(2, '0')}` : '';
|
||||
|
||||
|
||||
// Get episode progress
|
||||
const episodeId = episode.stremioId || `${metadata?.id}:${episode.season_number}:${episode.episode_number}`;
|
||||
const tmdbOverride = tmdbEpisodeOverrides?.[`${metadata?.id}:${episode.season_number}:${episode.episode_number}`];
|
||||
|
|
@ -60,7 +60,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
|
|||
const progress = episodeProgress?.[episodeId];
|
||||
const progressPercent = progress ? (progress.currentTime / progress.duration) * 100 : 0;
|
||||
const showProgress = progress && progressPercent < 85;
|
||||
|
||||
|
||||
const formatRuntime = (runtime: number) => {
|
||||
if (!runtime) return null;
|
||||
const hours = Math.floor(runtime / 60);
|
||||
|
|
@ -70,7 +70,7 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
|
|||
}
|
||||
return `${minutes}m`;
|
||||
};
|
||||
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
|
|
@ -106,11 +106,11 @@ export const EpisodeCard: React.FC<EpisodeCardProps> = ({
|
|||
</View>
|
||||
{showProgress && (
|
||||
<View style={styles.progressBarContainer}>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{ width: `${progressPercent}%`, backgroundColor: currentTheme.colors.primary }
|
||||
]}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Ionicons } from '@expo/vector-icons';
|
|||
import Feather from 'react-native-vector-icons/Feather';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Slider from '@react-native-community/slider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { styles } from '../utils/playerStyles'; // Updated styles
|
||||
import { getTrackDisplayName } from '../utils/playerUtils';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
|
|
@ -99,6 +100,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
useExoPlayer,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
||||
|
||||
/* Responsive Spacing */
|
||||
|
|
@ -115,6 +117,13 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
/* Animations - State & Refs */
|
||||
const [showBackwardSign, setShowBackwardSign] = React.useState(false);
|
||||
const [showForwardSign, setShowForwardSign] = React.useState(false);
|
||||
const [previewTime, setPreviewTime] = React.useState(currentTime);
|
||||
const isSlidingRef = React.useRef(false);
|
||||
React.useEffect(() => {
|
||||
if (!isSlidingRef.current) {
|
||||
setPreviewTime(currentTime);
|
||||
}
|
||||
}, [currentTime]);
|
||||
|
||||
/* Separate Animations for Each Button */
|
||||
const backwardPressAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
|
@ -280,10 +289,22 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
}}
|
||||
minimumValue={0}
|
||||
maximumValue={duration || 1}
|
||||
value={currentTime}
|
||||
onValueChange={onSliderValueChange}
|
||||
onSlidingStart={onSlidingStart}
|
||||
onSlidingComplete={onSlidingComplete}
|
||||
|
||||
value={previewTime}
|
||||
|
||||
onValueChange={(v) => setPreviewTime(v)}
|
||||
|
||||
onSlidingStart={() => {
|
||||
isSlidingRef.current = true;
|
||||
onSlidingStart();
|
||||
}}
|
||||
|
||||
onSlidingComplete={(v) => {
|
||||
isSlidingRef.current = false;
|
||||
setPreviewTime(v);
|
||||
onSlidingComplete(v);
|
||||
}}
|
||||
|
||||
minimumTrackTintColor={currentTheme.colors.primary}
|
||||
maximumTrackTintColor={currentTheme.colors.mediumEmphasis}
|
||||
thumbTintColor={Platform.OS === 'android' ? currentTheme.colors.white : undefined}
|
||||
|
|
@ -291,7 +312,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
/>
|
||||
<View style={[styles.timeDisplay, { paddingHorizontal: 14 }]}>
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.duration}>{formatTime(currentTime)}</Text>
|
||||
<Text style={styles.duration}>{formatTime(previewTime)}</Text>
|
||||
</View>
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.duration}>{formatTime(duration)}</Text>
|
||||
|
|
@ -319,7 +340,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
{/* Show year and provider (quality chip removed) */}
|
||||
<View style={styles.metadataRow}>
|
||||
{year && <Text style={styles.metadataText}>{year}</Text>}
|
||||
{streamName && <Text style={styles.providerText}>via {streamName}</Text>}
|
||||
{streamName && <Text style={styles.providerText}>{t('player_ui.via', { name: streamName })}</Text>}
|
||||
</View>
|
||||
{playerBackend && (
|
||||
<View style={styles.metadataRow}>
|
||||
|
|
@ -380,12 +401,13 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
transform: [{ scale: backwardScaleAnim }]
|
||||
}
|
||||
]}>
|
||||
<Ionicons
|
||||
name="reload-outline"
|
||||
size={seekIconSize}
|
||||
color="white"
|
||||
style={{ transform: [{ scaleX: -1 }] }}
|
||||
/>
|
||||
<View style={{ transform: [{ scaleX: -1 }] }}>
|
||||
<Ionicons
|
||||
name="reload-outline"
|
||||
size={seekIconSize}
|
||||
color="white"
|
||||
/>
|
||||
</View>
|
||||
<Animated.View style={[
|
||||
styles.buttonCircle,
|
||||
{
|
||||
|
|
@ -608,4 +630,4 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default PlayerControls;
|
||||
export default PlayerControls;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import { storageService } from '../../../services/storageService';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { useSettings } from '../../../hooks/useSettings';
|
||||
|
|
@ -35,6 +36,44 @@ export const useWatchProgress = (
|
|||
durationRef.current = duration;
|
||||
}, [duration]);
|
||||
|
||||
// Keep latest traktAutosync ref to avoid dependency cycles in listeners
|
||||
const traktAutosyncRef = useRef(traktAutosync);
|
||||
useEffect(() => {
|
||||
traktAutosyncRef.current = traktAutosync;
|
||||
}, [traktAutosync]);
|
||||
|
||||
// AppState Listener for background save
|
||||
useEffect(() => {
|
||||
const subscription = AppState.addEventListener('change', async (nextAppState) => {
|
||||
if (nextAppState.match(/inactive|background/)) {
|
||||
if (id && type && durationRef.current > 0) {
|
||||
logger.log('[useWatchProgress] App backgrounded, saving progress');
|
||||
|
||||
// Local save
|
||||
const progress = {
|
||||
currentTime: currentTimeRef.current,
|
||||
duration: durationRef.current,
|
||||
lastUpdated: Date.now(),
|
||||
addonId: addonId
|
||||
};
|
||||
try {
|
||||
await storageService.setWatchProgress(id, type, progress, episodeId);
|
||||
|
||||
// Trakt sync (end session)
|
||||
// Use 'user_close' to force immediate sync
|
||||
await traktAutosyncRef.current.handlePlaybackEnd(currentTimeRef.current, durationRef.current, 'user_close');
|
||||
} catch (error) {
|
||||
logger.error('[useWatchProgress] Error saving background progress:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [id, type, episodeId, addonId]);
|
||||
|
||||
// Load Watch Progress
|
||||
useEffect(() => {
|
||||
const loadWatchProgress = async () => {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ interface KSPlayerSurfaceProps {
|
|||
subtitleBackgroundColor?: string;
|
||||
subtitleFontSize?: number;
|
||||
subtitleBottomOffset?: number;
|
||||
subtitleOutlineEnabled?: boolean;
|
||||
}
|
||||
|
||||
export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
|
||||
|
|
@ -74,7 +75,8 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
|
|||
subtitleTextColor,
|
||||
subtitleBackgroundColor,
|
||||
subtitleFontSize,
|
||||
subtitleBottomOffset
|
||||
subtitleBottomOffset,
|
||||
subtitleOutlineEnabled
|
||||
}) => {
|
||||
const pinchRef = useRef<PinchGestureHandler>(null);
|
||||
|
||||
|
|
@ -146,6 +148,7 @@ export const KSPlayerSurface: React.FC<KSPlayerSurfaceProps> = ({
|
|||
subtitleBackgroundColor={subtitleBackgroundColor}
|
||||
subtitleFontSize={subtitleFontSize}
|
||||
subtitleBottomOffset={subtitleBottomOffset}
|
||||
subtitleOutlineEnabled={subtitleOutlineEnabled}
|
||||
onLoad={handleLoad}
|
||||
onProgress={onProgress}
|
||||
onBuffering={handleBuffering}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, ScrollView, useWindowDimensions, StyleSheet, Platform } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
|
|
@ -25,6 +26,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
|||
selectedAudioTrack,
|
||||
selectAudioTrack,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { width, height } = useWindowDimensions();
|
||||
|
||||
// Size constants matching SubtitleModal aesthetics
|
||||
|
|
@ -67,7 +69,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
|||
>
|
||||
{/* Header with shared aesthetics */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 20, position: 'relative' }}>
|
||||
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>Audio Tracks</Text>
|
||||
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>{t('player_ui.audio_tracks')}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
|
|
@ -111,7 +113,7 @@ export const AudioTrackModal: React.FC<AudioTrackModalProps> = ({
|
|||
{ksAudioTracks.length === 0 && (
|
||||
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
|
||||
<MaterialIcons name="volume-off" size={32} color="white" />
|
||||
<Text style={{ color: 'white', marginTop: 10 }}>No audio tracks available</Text>
|
||||
<Text style={{ color: 'white', marginTop: 10 }}>{t('player_ui.no_audio_tracks')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Animated, {
|
|||
SlideInRight,
|
||||
SlideOutRight,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Episode } from '../../../types/metadata';
|
||||
import { Stream } from '../../../types/streams';
|
||||
import { stremioService } from '../../../services/stremioService';
|
||||
|
|
@ -58,6 +59,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
onSelectStream,
|
||||
metadata,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
const MENU_WIDTH = Math.min(width * 0.85, 400);
|
||||
|
||||
|
|
@ -177,7 +179,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<View style={{ flex: 1, marginRight: 10 }}>
|
||||
<Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }} numberOfLines={1}>
|
||||
{episode?.name || 'Sources'}
|
||||
{episode?.name || t('player_ui.sources')}
|
||||
</Text>
|
||||
{episode && (
|
||||
<Text style={{ color: 'rgba(255,255,255,0.5)', fontSize: 13, marginTop: 4 }}>
|
||||
|
|
@ -195,7 +197,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
{isLoading && sortedProviders.length === 0 && (
|
||||
<View style={{ padding: 40, alignItems: 'center' }}>
|
||||
<ActivityIndicator color="white" />
|
||||
<Text style={{ color: 'white', marginTop: 15, opacity: 0.6 }}>Finding sources...</Text>
|
||||
<Text style={{ color: 'white', marginTop: 15, opacity: 0.6 }}>{t('player_ui.finding_sources')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
|
|
@ -237,7 +239,7 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
<View style={{ flex: 1 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 4 }}>
|
||||
<Text style={{ color: 'white', fontWeight: '700', fontSize: 14, flex: 1 }} numberOfLines={1}>
|
||||
{stream.name || 'Unknown Source'}
|
||||
{stream.name || t('player_ui.unknown_source')}
|
||||
</Text>
|
||||
<QualityBadge quality={quality} />
|
||||
</View>
|
||||
|
|
@ -258,13 +260,13 @@ export const EpisodeStreamsModal: React.FC<EpisodeStreamsModalProps> = ({
|
|||
{!isLoading && sortedProviders.length === 0 && (
|
||||
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
|
||||
<MaterialIcons name="cloud-off" size={48} color="white" />
|
||||
<Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>No sources found</Text>
|
||||
<Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>{t('player_ui.no_sources_found')}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{hasErrors.length > 0 && (
|
||||
<View style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', borderRadius: 12, padding: 12, marginTop: 10 }}>
|
||||
<Text style={{ color: '#EF4444', fontSize: 11 }}>Sources might be limited due to provider errors.</Text>
|
||||
<Text style={{ color: '#EF4444', fontSize: 11 }}>{t('player_ui.sources_limited')}</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Animated, {
|
|||
SlideInRight,
|
||||
SlideOutRight,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Episode } from '../../../types/metadata';
|
||||
import { EpisodeCard } from '../cards/EpisodeCard';
|
||||
import { storageService } from '../../../services/storageService';
|
||||
|
|
@ -32,6 +33,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
|
|||
onSelectEpisode,
|
||||
tmdbEpisodeOverrides
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
const [selectedSeason, setSelectedSeason] = useState<number>(currentEpisode?.season || 1);
|
||||
const [episodeProgress, setEpisodeProgress] = useState<{ [key: string]: any }>({});
|
||||
|
|
@ -117,7 +119,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
|
|||
>
|
||||
<View style={{ paddingTop: Platform.OS === 'ios' ? 60 : 20, paddingHorizontal: 20 }}>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 10 }}>
|
||||
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>Episodes</Text>
|
||||
<Text style={{ color: 'white', fontSize: 22, fontWeight: '700' }}>{t('player_ui.episodes')}</Text>
|
||||
</View>
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 15, gap: 8 }}>
|
||||
|
|
@ -143,7 +145,7 @@ export const EpisodesModal: React.FC<EpisodesModalProps> = ({
|
|||
color: selectedSeason === season ? 'black' : 'white',
|
||||
fontWeight: selectedSeason === season ? '700' : '500'
|
||||
}}>
|
||||
{season === 0 ? 'Specials' : `Season ${season}`}
|
||||
{season === 0 ? t('player_ui.specials') : t('player_ui.season', { season })}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import * as ExpoClipboard from 'expo-clipboard';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, useWindowDimensions } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
|
|
@ -22,6 +23,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
|||
errorDetails,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const { width } = useWindowDimensions();
|
||||
const MODAL_WIDTH = Math.min(width * 0.8, 400);
|
||||
|
|
@ -79,7 +81,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
|||
marginBottom: 8,
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
Playback Error
|
||||
{t('player_ui.playback_error')}
|
||||
</Text>
|
||||
|
||||
<Text
|
||||
|
|
@ -93,7 +95,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
|||
lineHeight: 22
|
||||
}}
|
||||
>
|
||||
{errorDetails || 'An unknown error occurred during playback.'}
|
||||
{errorDetails || t('player_ui.unknown_error')}
|
||||
</Text>
|
||||
|
||||
<TouchableOpacity
|
||||
|
|
@ -114,7 +116,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
|||
style={{ marginRight: 6 }}
|
||||
/>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 13, fontWeight: '500' }}>
|
||||
{copied ? 'Copied to clipboard' : 'Copy error details'}
|
||||
{copied ? t('player_ui.copied_to_clipboard') : t('player_ui.copy_error')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
|
@ -135,7 +137,7 @@ export const ErrorModal: React.FC<ErrorModalProps> = ({
|
|||
fontSize: 16,
|
||||
fontWeight: '700'
|
||||
}}>
|
||||
Dismiss
|
||||
{t('player_ui.dismiss')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, { useEffect } from 'react';
|
|||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { styles } from '../utils/playerStyles';
|
||||
import { formatTime } from '../utils/playerUtils';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
|
@ -27,6 +28,7 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
|
|||
handleResume,
|
||||
handleStartFromBeginning,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
useEffect(() => {
|
||||
// Removed excessive logging for props changes
|
||||
}, [showResumeOverlay, resumePosition, duration, title]);
|
||||
|
|
@ -35,9 +37,9 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
|
|||
// Removed excessive logging for overlay visibility
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Removed excessive logging for overlay rendering
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.resumeOverlay}>
|
||||
<LinearGradient
|
||||
|
|
@ -49,18 +51,18 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
|
|||
<Ionicons name="play-circle" size={40} color="#E50914" />
|
||||
</View>
|
||||
<View style={styles.resumeTextContainer}>
|
||||
<Text style={styles.resumeTitle}>Continue Watching</Text>
|
||||
<Text style={styles.resumeTitle}>{t('player_ui.continue_watching')}</Text>
|
||||
<Text style={styles.resumeInfo}>
|
||||
{title}
|
||||
{season && episode && ` • S${season}E${episode}`}
|
||||
</Text>
|
||||
<View style={styles.resumeProgressContainer}>
|
||||
<View style={styles.resumeProgressBar}>
|
||||
<View
|
||||
<View
|
||||
style={[
|
||||
styles.resumeProgressFill,
|
||||
styles.resumeProgressFill,
|
||||
{ width: `${duration > 0 ? (resumePosition / duration) * 100 : 0}%` }
|
||||
]}
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.resumeTimeText}>
|
||||
|
|
@ -71,19 +73,19 @@ export const ResumeOverlay: React.FC<ResumeOverlayProps> = ({
|
|||
</View>
|
||||
|
||||
<View style={styles.resumeButtons}>
|
||||
<TouchableOpacity
|
||||
style={styles.resumeButton}
|
||||
<TouchableOpacity
|
||||
style={styles.resumeButton}
|
||||
onPress={handleStartFromBeginning}
|
||||
>
|
||||
<Ionicons name="refresh" size={16} color="white" style={styles.buttonIcon} />
|
||||
<Text style={styles.resumeButtonText}>Start Over</Text>
|
||||
<Text style={styles.resumeButtonText}>{t('player_ui.start_over')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.resumeButton, styles.resumeFromButton]}
|
||||
<TouchableOpacity
|
||||
style={[styles.resumeButton, styles.resumeFromButton]}
|
||||
onPress={handleResume}
|
||||
>
|
||||
<Ionicons name="play" size={16} color="white" style={styles.buttonIcon} />
|
||||
<Text style={styles.resumeButtonText}>Resume</Text>
|
||||
<Text style={styles.resumeButtonText}>{t('player_ui.resume')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import Animated, {
|
|||
SlideInRight,
|
||||
SlideOutRight,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Stream } from '../../../types/streams';
|
||||
|
||||
interface SourcesModalProps {
|
||||
|
|
@ -57,6 +58,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
onSelectStream,
|
||||
isChangingSource = false,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
const MENU_WIDTH = Math.min(width * 0.85, 400);
|
||||
|
||||
|
|
@ -123,7 +125,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
alignItems: 'center'
|
||||
}}>
|
||||
<Text style={{ color: 'white', fontSize: 20, fontWeight: '700' }}>
|
||||
Change Source
|
||||
{t('player_ui.change_source')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
|
|
@ -142,7 +144,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
}}>
|
||||
<ActivityIndicator size="small" color="#22C55E" />
|
||||
<Text style={{ color: '#22C55E', fontSize: 14, fontWeight: '600', marginLeft: 10 }}>
|
||||
Switching source...
|
||||
{t('player_ui.switching_source')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -191,7 +193,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
fontSize: 14,
|
||||
flex: 1,
|
||||
}} numberOfLines={1}>
|
||||
{stream.title || stream.name || `Stream ${index + 1}`}
|
||||
{stream.title || stream.name || t('player_ui.stream', { number: index + 1 })}
|
||||
</Text>
|
||||
<QualityBadge quality={quality} />
|
||||
</View>
|
||||
|
|
@ -237,7 +239,7 @@ export const SourcesModal: React.FC<SourcesModalProps> = ({
|
|||
<View style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
|
||||
<MaterialIcons name="cloud-off" size={48} color="white" />
|
||||
<Text style={{ color: 'white', marginTop: 16, textAlign: 'center', fontWeight: '600' }}>
|
||||
No sources found
|
||||
{t('player_ui.no_sources_found')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet } from 'react-native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
|
|
@ -55,6 +56,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
|
|||
holdToSpeedValue,
|
||||
setHoldToSpeedValue,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { width } = useWindowDimensions();
|
||||
const speedPresets = [0.5, 1.0, 1.25, 1.5, 2.0, 2.5];
|
||||
const holdSpeedOptions = [1.0, 2.0, 3.0];
|
||||
|
|
@ -85,7 +87,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
|
|||
}}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 20, alignItems: 'center' }}>
|
||||
<Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>Playback Speed</Text>
|
||||
<Text style={{ color: 'white', fontSize: 16, fontWeight: '600' }}>{t('player_ui.playback_speed')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Speed Selection Row */}
|
||||
|
|
@ -108,7 +110,7 @@ const SpeedModal: React.FC<SpeedModalProps> = ({
|
|||
onPress={() => setHoldToSpeedEnabled(!holdToSpeedEnabled)}
|
||||
style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: holdToSpeedEnabled ? 15 : 0 }}
|
||||
>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }}>On Hold</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 14 }}>{t('player_ui.on_hold')}</Text>
|
||||
<View style={{
|
||||
width: 34, height: 18, borderRadius: 10,
|
||||
backgroundColor: holdToSpeedEnabled ? 'white' : 'rgba(255,255,255,0.2)',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import Animated, {
|
|||
useAnimatedStyle,
|
||||
withTiming,
|
||||
} from 'react-native-reanimated';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { WyzieSubtitle, SubtitleCue } from '../utils/playerTypes';
|
||||
import { getTrackDisplayName, formatLanguage } from '../utils/playerUtils';
|
||||
|
||||
|
|
@ -96,6 +97,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
selectedExternalSubtitleId,
|
||||
onOpenSyncModal,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { width, height } = useWindowDimensions();
|
||||
const isIos = Platform.OS === 'ios';
|
||||
const isLandscape = width > height;
|
||||
|
|
@ -108,7 +110,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
const isCompact = width < 360 || height < 640;
|
||||
// Internal subtitle is active when a built-in track is selected AND not using custom/addon subtitles
|
||||
const isUsingInternalSubtitle = selectedTextTrack >= 0 && !useCustomSubtitles;
|
||||
// ExoPlayer has limited styling support - hide unsupported options when using ExoPlayer with internal subs
|
||||
// ExoPlayer internal subtitles have limited styling support
|
||||
const isExoPlayerInternal = useExoPlayer && isUsingInternalSubtitle;
|
||||
const sectionPad = isCompact ? 12 : 16;
|
||||
const chipPadH = isCompact ? 8 : 12;
|
||||
|
|
@ -120,7 +122,9 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
const menuMaxHeight = height * 0.95;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (showSubtitleModal && !isLoadingSubtitleList && availableSubtitles.length === 0) fetchAvailableSubtitles();
|
||||
if (showSubtitleModal && !isLoadingSubtitleList && availableSubtitles.length === 0) {
|
||||
fetchAvailableSubtitles();
|
||||
}
|
||||
}, [showSubtitleModal]);
|
||||
|
||||
const handleClose = () => setShowSubtitleModal(false);
|
||||
|
|
@ -151,14 +155,14 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
>
|
||||
{/* Header */}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', padding: 20, position: 'relative' }}>
|
||||
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>Subtitles</Text>
|
||||
<Text style={{ color: 'white', fontSize: 18, fontWeight: '700' }}>{t('player_ui.subtitles')}</Text>
|
||||
</View>
|
||||
|
||||
{/* Tab Bar */}
|
||||
<View style={{ flexDirection: 'row', gap: 15, paddingHorizontal: 70, marginBottom: 20 }}>
|
||||
<MorphingTab label="Built-in" isSelected={activeTab === 'built-in'} onPress={() => setActiveTab('built-in')} />
|
||||
<MorphingTab label="Addons" isSelected={activeTab === 'addon'} onPress={() => setActiveTab('addon')} />
|
||||
<MorphingTab label="Style" isSelected={activeTab === 'appearance'} onPress={() => setActiveTab('appearance')} />
|
||||
<MorphingTab label={t('player_ui.built_in')} isSelected={activeTab === 'built-in'} onPress={() => setActiveTab('built-in')} />
|
||||
<MorphingTab label={t('player_ui.addons')} isSelected={activeTab === 'addon'} onPress={() => setActiveTab('addon')} />
|
||||
<MorphingTab label={t('player_ui.style')} isSelected={activeTab === 'appearance'} onPress={() => setActiveTab('appearance')} />
|
||||
</View>
|
||||
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
|
|
@ -174,7 +178,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
}}
|
||||
style={{ padding: 10, borderRadius: 12, backgroundColor: selectedTextTrack === -1 ? 'white' : 'rgba(242, 184, 181)' }}
|
||||
>
|
||||
<Text style={{ color: selectedTextTrack === -1 ? 'black' : 'rgba(96, 20, 16)', fontWeight: '600' }}>None</Text>
|
||||
<Text style={{ color: selectedTextTrack === -1 ? 'black' : 'rgba(96, 20, 16)', fontWeight: '600' }}>{t('player_ui.none')}</Text>
|
||||
</TouchableOpacity>
|
||||
{ksTextTracks.map((track) => (
|
||||
<TouchableOpacity
|
||||
|
|
@ -199,7 +203,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
{availableSubtitles.length === 0 ? (
|
||||
<TouchableOpacity onPress={fetchAvailableSubtitles} style={{ padding: 40, alignItems: 'center', opacity: 0.5 }}>
|
||||
<MaterialIcons name="cloud-download" size={32} color="white" />
|
||||
<Text style={{ color: 'white', marginTop: 10 }}>Search Online Subtitles</Text>
|
||||
<Text style={{ color: 'white', marginTop: 10 }}>{t('player_ui.search_online_subtitles')}</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
availableSubtitles.map((sub) => (
|
||||
|
|
@ -230,12 +234,13 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 8 }}>
|
||||
<MaterialIcons name="visibility" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Preview</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.preview')}</Text>
|
||||
</View>
|
||||
<View style={{ height: previewHeight, justifyContent: 'flex-end' }}>
|
||||
<View style={{ alignItems: subtitleAlign === 'center' ? 'center' : subtitleAlign === 'left' ? 'flex-start' : 'flex-end', marginBottom: Math.min(80, subtitleBottomOffset) }}>
|
||||
<View style={{
|
||||
backgroundColor: subtitleBackground ? `rgba(0,0,0,${subtitleBgOpacity})` : 'transparent',
|
||||
// Built-in (KSPlayer internal) subtitles: force background off in UI preview.
|
||||
backgroundColor: isUsingInternalSubtitle ? 'transparent' : (subtitleBackground ? `rgba(0,0,0,${subtitleBgOpacity})` : 'transparent'),
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: isCompact ? 10 : 12,
|
||||
paddingVertical: isCompact ? 6 : 8,
|
||||
|
|
@ -257,12 +262,12 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
|
||||
{/* Quick Presets - Hidden for ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
{/* Quick Presets - only for CustomSubtitles overlay */}
|
||||
{!isUsingInternalSubtitle && (
|
||||
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 10 }}>
|
||||
<MaterialIcons name="star" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Quick Presets</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.quick_presets')}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||
<TouchableOpacity
|
||||
|
|
@ -274,7 +279,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
}}
|
||||
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 11 : 12 }}>Default</Text>
|
||||
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.default')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
|
|
@ -282,7 +287,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
}}
|
||||
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(255,215,0,0.12)', borderWidth: 1, borderColor: 'rgba(255,215,0,0.35)' }}
|
||||
>
|
||||
<Text style={{ color: '#FFD700', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Yellow</Text>
|
||||
<Text style={{ color: '#FFD700', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.yellow')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
|
|
@ -290,7 +295,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
}}
|
||||
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(34,197,94,0.12)', borderWidth: 1, borderColor: 'rgba(34,197,94,0.35)' }}
|
||||
>
|
||||
<Text style={{ color: '#22C55E', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>High Contrast</Text>
|
||||
<Text style={{ color: '#22C55E', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.high_contrast')}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
|
|
@ -298,7 +303,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
}}
|
||||
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 20, backgroundColor: 'rgba(59,130,246,0.12)', borderWidth: 1, borderColor: 'rgba(59,130,246,0.35)' }}
|
||||
>
|
||||
<Text style={{ color: '#3B82F6', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>Large</Text>
|
||||
<Text style={{ color: '#3B82F6', fontWeight: '700', fontSize: isCompact ? 11 : 12 }}>{t('player_ui.large')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -308,12 +313,12 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', marginBottom: 2 }}>
|
||||
<MaterialIcons name="tune" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>Core</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{t('player_ui.core')}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="format-size" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Font Size</Text>
|
||||
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>{t('player_ui.font_size')}</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
|
||||
<TouchableOpacity onPress={decreaseSubtitleSize} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', justifyContent: 'center', alignItems: 'center' }}>
|
||||
|
|
@ -327,12 +332,12 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* Show Background - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
{/* Background is only for CustomSubtitles (external/addon). Built-in subtitles force background OFF. */}
|
||||
{!isUsingInternalSubtitle && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="layers" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>Show Background</Text>
|
||||
<Text style={{ color: '#fff', fontWeight: '600', marginLeft: 8 }}>{t('player_ui.show_background')}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={{ width: isCompact ? 48 : 54, height: isCompact ? 28 : 30, backgroundColor: subtitleBackground ? 'white' : 'rgba(255,255,255,0.25)', borderRadius: 15, justifyContent: 'center', alignItems: subtitleBackground ? 'flex-end' : 'flex-start', paddingHorizontal: 3 }}
|
||||
|
|
@ -344,30 +349,29 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
)}
|
||||
</View>
|
||||
|
||||
{/* Advanced controls - Limited for ExoPlayer */}
|
||||
{/* Advanced controls */}
|
||||
<View style={{ backgroundColor: 'rgba(255,255,255,0.05)', borderRadius: 16, padding: sectionPad, gap: isCompact ? 10 : 14 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="build" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{isExoPlayerInternal ? 'Position' : 'Advanced'}</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12, marginLeft: 6, fontWeight: '600' }}>{isUsingInternalSubtitle ? t('player_ui.position') : t('player_ui.advanced')}</Text>
|
||||
</View>
|
||||
{/* Text Color - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: 'white', marginLeft: 8, fontWeight: '600' }}>Text Color</Text>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => (
|
||||
<TouchableOpacity key={c} onPress={() => setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
|
||||
))}
|
||||
</View>
|
||||
{/* Text Color - supported for MPV built-in, and for CustomSubtitles */}
|
||||
<View style={{ marginTop: 8, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<MaterialIcons name="palette" size={16} color="rgba(255,255,255,0.7)" />
|
||||
<Text style={{ color: 'white', marginLeft: 8, fontWeight: '600' }}>{t('player_ui.text_color')}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Align - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8, justifyContent: 'flex-end' }}>
|
||||
{['#FFFFFF', '#FFD700', '#00E5FF', '#FF5C5C', '#00FF88', '#9b59b6', '#f97316'].map(c => (
|
||||
<TouchableOpacity key={c} onPress={() => setSubtitleTextColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleTextColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Align - only supported for CustomSubtitles overlay */}
|
||||
{!isUsingInternalSubtitle && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Align</Text>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.align')}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
{([{ key: 'left', icon: 'format-align-left' }, { key: 'center', icon: 'format-align-center' }, { key: 'right', icon: 'format-align-right' }] as const).map(a => (
|
||||
<TouchableOpacity key={a.key} onPress={() => setSubtitleAlign(a.key)} style={{ paddingHorizontal: isCompact ? 8 : 10, paddingVertical: isCompact ? 4 : 6, borderRadius: 8, backgroundColor: subtitleAlign === a.key ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}>
|
||||
|
|
@ -378,7 +382,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</View>
|
||||
)}
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Bottom Offset</Text>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.bottom_offset')}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||
<TouchableOpacity onPress={() => setSubtitleBottomOffset(Math.max(0, subtitleBottomOffset - 5))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MaterialIcons name="keyboard-arrow-down" color="#fff" size={20} />
|
||||
|
|
@ -391,10 +395,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* Background Opacity - Not supported on ExoPlayer internal subtitles */}
|
||||
{!isExoPlayerInternal && (
|
||||
{/* Background Opacity (CustomSubtitles only) */}
|
||||
{!isUsingInternalSubtitle && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Background Opacity</Text>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.background_opacity')}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||
<TouchableOpacity onPress={() => setSubtitleBgOpacity(Math.max(0, +(subtitleBgOpacity - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MaterialIcons name="remove" color="#fff" size={18} />
|
||||
|
|
@ -410,16 +414,28 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
)}
|
||||
{!isUsingInternalSubtitle && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Text Shadow</Text>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.text_shadow')}</Text>
|
||||
<TouchableOpacity onPress={() => setSubtitleTextShadow(!subtitleTextShadow)} style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleTextShadow ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? 'On' : 'Off'}</Text>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleTextShadow ? t('player_ui.on') : t('player_ui.off')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{!isUsingInternalSubtitle && (
|
||||
{/* Outline controls (now supported for ExoPlayer internal via native patch) */}
|
||||
{isUsingInternalSubtitle ? (
|
||||
// KSPlayer built-in subtitles: only expose an Outline on/off toggle (no width control).
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.outline')}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => setSubtitleOutline(!subtitleOutline)}
|
||||
style={{ paddingHorizontal: 10, paddingVertical: 8, borderRadius: 10, backgroundColor: subtitleOutline ? 'rgba(255,255,255,0.18)' : 'rgba(255,255,255,0.08)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)', alignItems: 'center' }}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>{subtitleOutline ? t('player_ui.on') : t('player_ui.off')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white' }}>Outline Color</Text>
|
||||
<Text style={{ color: 'white' }}>{t('player_ui.outline_color')}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
{['#000000', '#FFFFFF', '#00E5FF', '#FF5C5C'].map(c => (
|
||||
<TouchableOpacity key={c} onPress={() => setSubtitleOutlineColor(c)} style={{ width: 22, height: 22, borderRadius: 11, backgroundColor: c, borderWidth: 2, borderColor: subtitleOutlineColor === c ? '#fff' : 'rgba(255,255,255,0.3)' }} />
|
||||
|
|
@ -427,7 +443,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white' }}>Outline Width</Text>
|
||||
<Text style={{ color: 'white' }}>{t('player_ui.outline_width')}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||
<TouchableOpacity onPress={() => setSubtitleOutlineWidth(Math.max(0, subtitleOutlineWidth - 1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MaterialIcons name="remove" color="#fff" size={18} />
|
||||
|
|
@ -445,7 +461,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
{!isUsingInternalSubtitle && (
|
||||
<View style={{ flexDirection: isCompact ? 'column' : 'row', justifyContent: 'space-between', gap: 12 }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Letter Spacing</Text>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.letter_spacing')}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<TouchableOpacity onPress={() => setSubtitleLetterSpacing(Math.max(0, +(subtitleLetterSpacing - 0.5).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MaterialIcons name="remove" color="#fff" size={18} />
|
||||
|
|
@ -459,7 +475,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Line Height</Text>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.line_height')}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<TouchableOpacity onPress={() => setSubtitleLineHeightMultiplier(Math.max(1, +(subtitleLineHeightMultiplier - 0.1).toFixed(1)))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MaterialIcons name="remove" color="#fff" size={18} />
|
||||
|
|
@ -478,7 +494,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
{!isExoPlayerInternal && (
|
||||
<View style={{ marginTop: 4 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>Timing Offset (s)</Text>
|
||||
<Text style={{ color: 'white', fontWeight: '600' }}>{t('player_ui.timing_offset')}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8, alignItems: 'center' }}>
|
||||
<TouchableOpacity onPress={() => setSubtitleOffsetSec(+(subtitleOffsetSec - 0.1).toFixed(1))} style={{ width: controlBtn.size, height: controlBtn.size, borderRadius: controlBtn.radius, backgroundColor: 'rgba(255,255,255,0.18)', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<MaterialIcons name="remove" color="#fff" size={18} />
|
||||
|
|
@ -511,10 +527,10 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
}}
|
||||
>
|
||||
<MaterialIcons name="sync" color="#fff" size={18} style={{ marginRight: 8 }} />
|
||||
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 14 }}>Visual Sync</Text>
|
||||
<Text style={{ color: '#fff', fontWeight: '600', fontSize: 14 }}>{t('player_ui.visual_sync')}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>Nudge subtitles earlier (-) or later (+) to sync if needed.</Text>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.6)', fontSize: 11, marginTop: 6 }}>{t('player_ui.timing_hint')}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={{ alignItems: 'flex-end', marginTop: 8 }}>
|
||||
|
|
@ -527,7 +543,7 @@ export const SubtitleModals: React.FC<SubtitleModalsProps> = ({
|
|||
}}
|
||||
style={{ paddingHorizontal: chipPadH, paddingVertical: chipPadV, borderRadius: 8, backgroundColor: 'rgba(255,255,255,0.1)', borderWidth: 1, borderColor: 'rgba(255,255,255,0.15)' }}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 14 }}>Reset to defaults</Text>
|
||||
<Text style={{ color: '#fff', fontWeight: '600', fontSize: isCompact ? 12 : 14 }}>{t('player_ui.reset_defaults')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -74,6 +74,7 @@ export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
|
|||
const hasShownRef = useRef(false);
|
||||
const hideTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const fadeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const prevShouldShowRef = useRef<boolean>(false);
|
||||
|
||||
// Animation values
|
||||
const lineHeight = useSharedValue(0);
|
||||
|
|
@ -130,9 +131,51 @@ export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
|
|||
fetchData();
|
||||
}, [imdbId, type, season, episode]);
|
||||
|
||||
// Trigger animation when shouldShow becomes true
|
||||
// Handle show/hide based on shouldShow (controls visibility)
|
||||
useEffect(() => {
|
||||
if (shouldShow && warnings.length > 0 && !hasShownRef.current) {
|
||||
// When controls are shown (shouldShow becomes false), immediately hide overlay
|
||||
if (!shouldShow && isVisible) {
|
||||
// Clear any pending timeouts
|
||||
if (hideTimeoutRef.current) {
|
||||
clearTimeout(hideTimeoutRef.current);
|
||||
hideTimeoutRef.current = null;
|
||||
}
|
||||
if (fadeTimeoutRef.current) {
|
||||
clearTimeout(fadeTimeoutRef.current);
|
||||
fadeTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Immediately hide overlay with quick fade out
|
||||
const count = warnings.length;
|
||||
// FADE OUT: Items fade out in reverse order (bottom to top)
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const reverseDelay = (count - 1 - i) * 40;
|
||||
itemOpacities[i].value = withDelay(
|
||||
reverseDelay,
|
||||
withTiming(0, { duration: 100 })
|
||||
);
|
||||
}
|
||||
|
||||
// Line shrinks after items are gone
|
||||
const lineDelay = count * 40 + 50;
|
||||
lineHeight.value = withDelay(lineDelay, withTiming(0, {
|
||||
duration: 200,
|
||||
easing: Easing.in(Easing.cubic),
|
||||
}));
|
||||
|
||||
// Container fades out last
|
||||
containerOpacity.value = withDelay(lineDelay + 100, withTiming(0, { duration: 150 }));
|
||||
|
||||
// Set invisible after all animations complete
|
||||
fadeTimeoutRef.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
// Don't reset hasShownRef here - only reset on content change
|
||||
}, lineDelay + 300);
|
||||
}
|
||||
|
||||
// When controls are hidden (shouldShow becomes true), show overlay if not already shown for this content
|
||||
// Only show if transitioning from false to true (controls just hidden)
|
||||
if (shouldShow && !prevShouldShowRef.current && warnings.length > 0 && !hasShownRef.current) {
|
||||
hasShownRef.current = true;
|
||||
setIsVisible(true);
|
||||
|
||||
|
|
@ -182,10 +225,14 @@ export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
|
|||
// Set invisible after all animations complete
|
||||
fadeTimeoutRef.current = setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
// Don't reset hasShownRef - only reset on content change
|
||||
}, lineDelay + 500);
|
||||
}, 5000);
|
||||
}
|
||||
}, [shouldShow, warnings.length]);
|
||||
|
||||
// Update previous shouldShow value
|
||||
prevShouldShowRef.current = shouldShow;
|
||||
}, [shouldShow, isVisible, warnings.length]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
|
|
@ -198,6 +245,7 @@ export const ParentalGuideOverlay: React.FC<ParentalGuideOverlayProps> = ({
|
|||
// Reset when content changes
|
||||
useEffect(() => {
|
||||
hasShownRef.current = false;
|
||||
prevShouldShowRef.current = false;
|
||||
setWarnings([]);
|
||||
setIsVisible(false);
|
||||
lineHeight.value = 0;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import Animated, {
|
|||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { introService, IntroTimestamps } from '../../../services/introService';
|
||||
import { introService, SkipInterval, SkipType } from '../../../services/introService';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { logger } from '../../../utils/logger';
|
||||
|
||||
|
|
@ -19,6 +19,8 @@ interface SkipIntroButtonProps {
|
|||
type: 'movie' | 'series' | string;
|
||||
season?: number;
|
||||
episode?: number;
|
||||
malId?: string;
|
||||
kitsuId?: string;
|
||||
currentTime: number;
|
||||
onSkip: (endTime: number) => void;
|
||||
controlsVisible?: boolean;
|
||||
|
|
@ -30,6 +32,8 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
type,
|
||||
season,
|
||||
episode,
|
||||
malId,
|
||||
kitsuId,
|
||||
currentTime,
|
||||
onSkip,
|
||||
controlsVisible = false,
|
||||
|
|
@ -37,10 +41,15 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [introData, setIntroData] = useState<IntroTimestamps | null>(null);
|
||||
|
||||
// State
|
||||
const [skipIntervals, setSkipIntervals] = useState<SkipInterval[]>([]);
|
||||
const [currentInterval, setCurrentInterval] = useState<SkipInterval | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasSkipped, setHasSkipped] = useState(false);
|
||||
const [hasSkippedCurrent, setHasSkippedCurrent] = useState(false);
|
||||
const [autoHidden, setAutoHidden] = useState(false);
|
||||
|
||||
// Refs
|
||||
const fetchedRef = useRef(false);
|
||||
const lastEpisodeRef = useRef<string>('');
|
||||
const autoHideTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
|
@ -50,14 +59,13 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
const scale = useSharedValue(0.8);
|
||||
const translateY = useSharedValue(0);
|
||||
|
||||
// Fetch intro data when episode changes
|
||||
// Fetch skip data when episode changes
|
||||
useEffect(() => {
|
||||
const episodeKey = `${imdbId}-${season}-${episode}`;
|
||||
const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`;
|
||||
|
||||
// Skip if not a series or missing required data
|
||||
if (type !== 'series' || !imdbId || !season || !episode) {
|
||||
logger.log(`[SkipIntroButton] Skipping fetch - type: ${type}, imdbId: ${imdbId}, season: ${season}, episode: ${episode}`);
|
||||
setIntroData(null);
|
||||
// Skip if not a series or missing required data (though MAL/Kitsu ID might be enough for some cases, usually need season/ep)
|
||||
if (type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) {
|
||||
setSkipIntervals([]);
|
||||
fetchedRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
|
@ -69,45 +77,76 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
|
||||
lastEpisodeRef.current = episodeKey;
|
||||
fetchedRef.current = true;
|
||||
setHasSkipped(false);
|
||||
setHasSkippedCurrent(false);
|
||||
setAutoHidden(false);
|
||||
setSkipIntervals([]);
|
||||
|
||||
const fetchIntroData = async () => {
|
||||
logger.log(`[SkipIntroButton] Fetching intro data for ${imdbId} S${season}E${episode}...`);
|
||||
const fetchSkipData = async () => {
|
||||
logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`);
|
||||
try {
|
||||
const data = await introService.getIntroTimestamps(imdbId, season, episode);
|
||||
setIntroData(data);
|
||||
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId);
|
||||
setSkipIntervals(intervals);
|
||||
|
||||
if (data) {
|
||||
logger.log(`[SkipIntroButton] ✓ Found intro: ${data.start_sec}s - ${data.end_sec}s (confidence: ${data.confidence})`);
|
||||
if (intervals.length > 0) {
|
||||
logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals);
|
||||
} else {
|
||||
logger.log(`[SkipIntroButton] ✗ No intro data available for this episode`);
|
||||
logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[SkipIntroButton] Error fetching intro data:', error);
|
||||
setIntroData(null);
|
||||
logger.error('[SkipIntroButton] Error fetching skip data:', error);
|
||||
setSkipIntervals([]);
|
||||
}
|
||||
};
|
||||
|
||||
fetchIntroData();
|
||||
}, [imdbId, type, season, episode]);
|
||||
fetchSkipData();
|
||||
}, [imdbId, type, season, episode, malId, kitsuId]);
|
||||
|
||||
// Determine if button should show based on current playback position
|
||||
// Determine active interval based on current playback position
|
||||
useEffect(() => {
|
||||
if (skipIntervals.length === 0) {
|
||||
setCurrentInterval(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find an interval that contains the current time
|
||||
const active = skipIntervals.find(
|
||||
interval => currentTime >= interval.startTime && currentTime < (interval.endTime - 0.5)
|
||||
);
|
||||
|
||||
if (active) {
|
||||
// If we found a new active interval that is different from the previous one
|
||||
if (!currentInterval ||
|
||||
active.startTime !== currentInterval.startTime ||
|
||||
active.type !== currentInterval.type) {
|
||||
logger.log(`[SkipIntroButton] Entering interval: ${active.type} (${active.startTime}-${active.endTime})`);
|
||||
setCurrentInterval(active);
|
||||
setHasSkippedCurrent(false); // Reset skipped state for new interval
|
||||
setAutoHidden(false); // Reset auto-hide for new interval
|
||||
}
|
||||
} else {
|
||||
// No active interval
|
||||
if (currentInterval) {
|
||||
logger.log('[SkipIntroButton] Exiting interval');
|
||||
setCurrentInterval(null);
|
||||
}
|
||||
}
|
||||
}, [currentTime, skipIntervals]);
|
||||
|
||||
// Determine if button should show
|
||||
const shouldShowButton = useCallback(() => {
|
||||
if (!introData || hasSkipped) return false;
|
||||
// Show when within intro range, with a small buffer at the end
|
||||
const inIntroRange = currentTime >= introData.start_sec && currentTime < (introData.end_sec - 0.5);
|
||||
if (!currentInterval || hasSkippedCurrent) return false;
|
||||
|
||||
// If auto-hidden, only show when controls are visible
|
||||
if (autoHidden && !controlsVisible) return false;
|
||||
return inIntroRange;
|
||||
}, [introData, currentTime, hasSkipped, autoHidden, controlsVisible]);
|
||||
|
||||
return true;
|
||||
}, [currentInterval, hasSkippedCurrent, autoHidden, controlsVisible]);
|
||||
|
||||
// Handle visibility animations
|
||||
useEffect(() => {
|
||||
const shouldShow = shouldShowButton();
|
||||
|
||||
if (shouldShow && !isVisible) {
|
||||
logger.log(`[SkipIntroButton] Showing button - currentTime: ${currentTime.toFixed(1)}s, intro: ${introData?.start_sec}s - ${introData?.end_sec}s`);
|
||||
setIsVisible(true);
|
||||
opacity.value = withTiming(1, { duration: 300, easing: Easing.out(Easing.cubic) });
|
||||
scale.value = withSpring(1, { damping: 15, stiffness: 150 });
|
||||
|
|
@ -115,8 +154,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
// Start 15-second auto-hide timer
|
||||
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
|
||||
autoHideTimerRef.current = setTimeout(() => {
|
||||
if (!hasSkipped) {
|
||||
logger.log('[SkipIntroButton] Auto-hiding after 15 seconds');
|
||||
if (!hasSkippedCurrent) {
|
||||
setAutoHidden(true);
|
||||
opacity.value = withTiming(0, { duration: 200 });
|
||||
scale.value = withTiming(0.8, { duration: 200 });
|
||||
|
|
@ -124,25 +162,20 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
}
|
||||
}, 15000);
|
||||
} else if (!shouldShow && isVisible) {
|
||||
logger.log(`[SkipIntroButton] Hiding button - currentTime: ${currentTime.toFixed(1)}s, hasSkipped: ${hasSkipped}`);
|
||||
if (autoHideTimerRef.current) clearTimeout(autoHideTimerRef.current);
|
||||
opacity.value = withTiming(0, { duration: 200 });
|
||||
scale.value = withTiming(0.8, { duration: 200 });
|
||||
// Delay hiding to allow animation to complete
|
||||
setTimeout(() => setIsVisible(false), 250);
|
||||
}
|
||||
}, [shouldShowButton, isVisible]);
|
||||
}, [shouldShowButton, isVisible, hasSkippedCurrent]);
|
||||
|
||||
// Re-show when controls become visible (if still in intro range and was auto-hidden)
|
||||
// Re-show when controls become visible (if still in interval and was auto-hidden)
|
||||
useEffect(() => {
|
||||
if (controlsVisible && autoHidden && introData && !hasSkipped) {
|
||||
const inIntroRange = currentTime >= introData.start_sec && currentTime < (introData.end_sec - 0.5);
|
||||
if (inIntroRange) {
|
||||
logger.log('[SkipIntroButton] Re-showing button because controls became visible');
|
||||
setAutoHidden(false);
|
||||
}
|
||||
if (controlsVisible && autoHidden && currentInterval && !hasSkippedCurrent) {
|
||||
setAutoHidden(false);
|
||||
}
|
||||
}, [controlsVisible, autoHidden, introData, hasSkipped, currentTime]);
|
||||
}, [controlsVisible, autoHidden, currentInterval, hasSkippedCurrent]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
useEffect(() => {
|
||||
|
|
@ -162,12 +195,32 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
|
||||
// Handle skip action
|
||||
const handleSkip = useCallback(() => {
|
||||
if (!introData) return;
|
||||
if (!currentInterval) return;
|
||||
|
||||
logger.log(`[SkipIntroButton] User pressed Skip Intro - seeking to ${introData.end_sec}s (from ${currentTime.toFixed(1)}s)`);
|
||||
setHasSkipped(true);
|
||||
onSkip(introData.end_sec);
|
||||
}, [introData, onSkip, currentTime]);
|
||||
logger.log(`[SkipIntroButton] User pressed Skip - seeking to ${currentInterval.endTime}s`);
|
||||
setHasSkippedCurrent(true);
|
||||
onSkip(currentInterval.endTime);
|
||||
}, [currentInterval, onSkip]);
|
||||
|
||||
// Get display text based on skip type
|
||||
const getButtonText = () => {
|
||||
if (!currentInterval) return 'Skip';
|
||||
|
||||
switch (currentInterval.type) {
|
||||
case 'op':
|
||||
case 'mixed-op':
|
||||
case 'intro':
|
||||
return 'Skip Intro';
|
||||
case 'ed':
|
||||
case 'mixed-ed':
|
||||
case 'outro':
|
||||
return 'Skip Ending';
|
||||
case 'recap':
|
||||
return 'Skip Recap';
|
||||
default:
|
||||
return 'Skip';
|
||||
}
|
||||
};
|
||||
|
||||
// Animated styles
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
|
|
@ -175,8 +228,8 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
transform: [{ scale: scale.value }, { translateY: translateY.value }],
|
||||
}));
|
||||
|
||||
// Don't render if not visible or no intro data
|
||||
if (!isVisible || !introData) {
|
||||
// Don't render if not visible
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -208,7 +261,7 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
color="#FFFFFF"
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text style={styles.text}>Skip Intro</Text>
|
||||
<Text style={styles.text}>{getButtonText()}</Text>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.accentBar,
|
||||
|
|
|
|||
155
src/components/search/AddonSection.tsx
Normal file
155
src/components/search/AddonSection.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { View, Text, FlatList } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AddonSearchResults, StreamingContent } from '../../services/catalogService';
|
||||
import { SearchResultItem } from './SearchResultItem';
|
||||
import { isTablet, isLargeTablet, isTV } from './searchUtils';
|
||||
import { searchStyles as styles } from './searchStyles';
|
||||
|
||||
interface AddonSectionProps {
|
||||
addonGroup: AddonSearchResults;
|
||||
addonIndex: number;
|
||||
onItemPress: (item: StreamingContent) => void;
|
||||
onItemLongPress: (item: StreamingContent) => void;
|
||||
currentTheme: any;
|
||||
}
|
||||
|
||||
export const AddonSection = React.memo(({
|
||||
addonGroup,
|
||||
addonIndex,
|
||||
onItemPress,
|
||||
onItemLongPress,
|
||||
currentTheme,
|
||||
}: AddonSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const movieResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type === 'movie'),
|
||||
[addonGroup.results]
|
||||
);
|
||||
const seriesResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type === 'series'),
|
||||
[addonGroup.results]
|
||||
);
|
||||
const otherResults = useMemo(() =>
|
||||
addonGroup.results.filter(item => item.type !== 'movie' && item.type !== 'series'),
|
||||
[addonGroup.results]
|
||||
);
|
||||
|
||||
return (
|
||||
<View>
|
||||
{/* Addon Header */}
|
||||
<View style={styles.addonHeaderContainer}>
|
||||
<Text style={[styles.addonHeaderText, { color: currentTheme.colors.white }]}>
|
||||
{addonGroup.addonName}
|
||||
</Text>
|
||||
<View style={[styles.addonHeaderBadge, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<Text style={[styles.addonHeaderBadgeText, { color: currentTheme.colors.lightGray }]}>
|
||||
{addonGroup.results.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Movies */}
|
||||
{movieResults.length > 0 && (
|
||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{t('search.movies')} ({movieResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={movieResults}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
onPress={onItemPress}
|
||||
onLongPress={onItemLongPress}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => `${addonGroup.addonId}-movie-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* TV Shows */}
|
||||
{seriesResults.length > 0 && (
|
||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{t('search.tv_shows')} ({seriesResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={seriesResults}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
onPress={onItemPress}
|
||||
onLongPress={onItemLongPress}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => `${addonGroup.addonId}-series-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Other types */}
|
||||
{otherResults.length > 0 && (
|
||||
<View style={[styles.carouselContainer, { marginBottom: isTV ? 40 : isLargeTablet ? 36 : isTablet ? 32 : 24 }]}>
|
||||
<Text style={[
|
||||
styles.carouselSubtitle,
|
||||
{
|
||||
color: currentTheme.colors.lightGray,
|
||||
fontSize: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 14,
|
||||
marginBottom: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 8,
|
||||
paddingHorizontal: isTV ? 24 : isLargeTablet ? 20 : isTablet ? 16 : 16
|
||||
}
|
||||
]}>
|
||||
{otherResults[0].type.charAt(0).toUpperCase() + otherResults[0].type.slice(1)} ({otherResults.length})
|
||||
</Text>
|
||||
<FlatList
|
||||
data={otherResults}
|
||||
renderItem={({ item, index }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
index={index}
|
||||
onPress={onItemPress}
|
||||
onLongPress={onItemLongPress}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => `${addonGroup.addonId}-${item.type}-${item.id}`}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.horizontalListContent}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Only re-render if this section's reference changed
|
||||
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
|
||||
});
|
||||
|
||||
AddonSection.displayName = 'AddonSection';
|
||||
275
src/components/search/DiscoverBottomSheets.tsx
Normal file
275
src/components/search/DiscoverBottomSheets.tsx
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import React, { useMemo, useCallback, forwardRef, RefObject, useState } from 'react';
|
||||
import { View, Text, TouchableOpacity } from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet';
|
||||
import { DiscoverCatalog } from './searchUtils';
|
||||
import { searchStyles as styles } from './searchStyles';
|
||||
import { useBottomSheetBackHandler } from '../../hooks/useBottomSheetBackHandler';
|
||||
|
||||
interface DiscoverBottomSheetsProps {
|
||||
typeSheetRef: RefObject<BottomSheetModal>;
|
||||
catalogSheetRef: RefObject<BottomSheetModal>;
|
||||
genreSheetRef: RefObject<BottomSheetModal>;
|
||||
selectedDiscoverType: 'movie' | 'series';
|
||||
selectedCatalog: DiscoverCatalog | null;
|
||||
selectedDiscoverGenre: string | null;
|
||||
filteredCatalogs: DiscoverCatalog[];
|
||||
availableGenres: string[];
|
||||
onTypeSelect: (type: 'movie' | 'series') => void;
|
||||
onCatalogSelect: (catalog: DiscoverCatalog) => void;
|
||||
onGenreSelect: (genre: string | null) => void;
|
||||
currentTheme: any;
|
||||
}
|
||||
|
||||
export const DiscoverBottomSheets = ({
|
||||
typeSheetRef,
|
||||
catalogSheetRef,
|
||||
genreSheetRef,
|
||||
selectedDiscoverType,
|
||||
selectedCatalog,
|
||||
selectedDiscoverGenre,
|
||||
filteredCatalogs,
|
||||
availableGenres,
|
||||
onTypeSelect,
|
||||
onCatalogSelect,
|
||||
onGenreSelect,
|
||||
currentTheme,
|
||||
}: DiscoverBottomSheetsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const typeSnapPoints = useMemo(() => ['25%'], []);
|
||||
const catalogSnapPoints = useMemo(() => ['50%'], []);
|
||||
const genreSnapPoints = useMemo(() => ['50%'], []);
|
||||
const [activeBottomSheetRef, setActiveBottomSheetRef] = useState(null);
|
||||
const {onDismiss, onChange} = useBottomSheetBackHandler();
|
||||
|
||||
const renderBackdrop = useCallback(
|
||||
(props: any) => (
|
||||
<BottomSheetBackdrop
|
||||
{...props}
|
||||
disappearsOnIndex={-1}
|
||||
appearsOnIndex={0}
|
||||
opacity={0.5}
|
||||
/>
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Catalog Selection Bottom Sheet */}
|
||||
<BottomSheetModal
|
||||
ref={catalogSheetRef}
|
||||
index={0}
|
||||
snapPoints={catalogSnapPoints}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose={true}
|
||||
backdropComponent={renderBackdrop}
|
||||
backgroundStyle={{
|
||||
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: currentTheme.colors.mediumGray,
|
||||
}}
|
||||
onDismiss={onDismiss(catalogSheetRef)}
|
||||
onChange={onChange(catalogSheetRef)}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
|
||||
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.select_catalog')}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => catalogSheetRef.current?.dismiss()}>
|
||||
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
>
|
||||
{filteredCatalogs.map((catalog, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${catalog.addonId}-${catalog.catalogId}-${index}`}
|
||||
style={[
|
||||
styles.bottomSheetItem,
|
||||
selectedCatalog?.catalogId === catalog.catalogId &&
|
||||
selectedCatalog?.addonId === catalog.addonId &&
|
||||
styles.bottomSheetItemSelected
|
||||
]}
|
||||
onPress={() => onCatalogSelect(catalog)}
|
||||
>
|
||||
<View style={styles.bottomSheetItemContent}>
|
||||
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
||||
{catalog.catalogName}
|
||||
</Text>
|
||||
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
{catalog.addonName}
|
||||
</Text>
|
||||
</View>
|
||||
{selectedCatalog?.catalogId === catalog.catalogId &&
|
||||
selectedCatalog?.addonId === catalog.addonId && (
|
||||
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
|
||||
{/* Genre Selection Bottom Sheet */}
|
||||
<BottomSheetModal
|
||||
ref={genreSheetRef}
|
||||
index={0}
|
||||
snapPoints={genreSnapPoints}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose={true}
|
||||
backdropComponent={renderBackdrop}
|
||||
android_keyboardInputMode="adjustResize"
|
||||
animateOnMount={true}
|
||||
backgroundStyle={{
|
||||
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: currentTheme.colors.mediumGray,
|
||||
}}
|
||||
onDismiss={onDismiss(genreSheetRef)}
|
||||
onChange={onChange(genreSheetRef)}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
|
||||
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.select_genre')}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => genreSheetRef.current?.dismiss()}>
|
||||
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
>
|
||||
{/* All Genres option */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.bottomSheetItem,
|
||||
!selectedDiscoverGenre && styles.bottomSheetItemSelected
|
||||
]}
|
||||
onPress={() => onGenreSelect(null)}
|
||||
>
|
||||
<View style={styles.bottomSheetItemContent}>
|
||||
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.all_genres')}
|
||||
</Text>
|
||||
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
{t('search.show_all_content')}
|
||||
</Text>
|
||||
</View>
|
||||
{!selectedDiscoverGenre && (
|
||||
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Genre options */}
|
||||
{availableGenres.map((genre, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${genre}-${index}`}
|
||||
style={[
|
||||
styles.bottomSheetItem,
|
||||
selectedDiscoverGenre === genre && styles.bottomSheetItemSelected
|
||||
]}
|
||||
onPress={() => onGenreSelect(genre)}
|
||||
>
|
||||
<View style={styles.bottomSheetItemContent}>
|
||||
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
||||
{genre}
|
||||
</Text>
|
||||
</View>
|
||||
{selectedDiscoverGenre === genre && (
|
||||
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
|
||||
{/* Type Selection Bottom Sheet */}
|
||||
<BottomSheetModal
|
||||
ref={typeSheetRef}
|
||||
index={0}
|
||||
snapPoints={typeSnapPoints}
|
||||
enableDynamicSizing={false}
|
||||
enablePanDownToClose={true}
|
||||
backdropComponent={renderBackdrop}
|
||||
backgroundStyle={{
|
||||
backgroundColor: currentTheme.colors.darkGray || '#0A0C0C',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
}}
|
||||
handleIndicatorStyle={{
|
||||
backgroundColor: currentTheme.colors.mediumGray,
|
||||
}}
|
||||
onDismiss={onDismiss(typeSheetRef)}
|
||||
onChange={onChange(typeSheetRef)}
|
||||
>
|
||||
<View style={[styles.bottomSheetHeader, { backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }]}>
|
||||
<Text style={[styles.bottomSheetTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.select_type')}
|
||||
</Text>
|
||||
<TouchableOpacity onPress={() => typeSheetRef.current?.dismiss()}>
|
||||
<MaterialIcons name="close" size={24} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<BottomSheetScrollView
|
||||
style={{ backgroundColor: currentTheme.colors.darkGray || '#0A0C0C' }}
|
||||
contentContainerStyle={styles.bottomSheetContent}
|
||||
>
|
||||
{/* Movies option */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.bottomSheetItem,
|
||||
selectedDiscoverType === 'movie' && styles.bottomSheetItemSelected
|
||||
]}
|
||||
onPress={() => onTypeSelect('movie')}
|
||||
>
|
||||
<View style={styles.bottomSheetItemContent}>
|
||||
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.movies')}
|
||||
</Text>
|
||||
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
{t('search.browse_movies')}
|
||||
</Text>
|
||||
</View>
|
||||
{selectedDiscoverType === 'movie' && (
|
||||
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* TV Shows option */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.bottomSheetItem,
|
||||
selectedDiscoverType === 'series' && styles.bottomSheetItemSelected
|
||||
]}
|
||||
onPress={() => onTypeSelect('series')}
|
||||
>
|
||||
<View style={styles.bottomSheetItemContent}>
|
||||
<Text style={[styles.bottomSheetItemTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.tv_shows')}
|
||||
</Text>
|
||||
<Text style={[styles.bottomSheetItemSubtitle, { color: currentTheme.colors.lightGray }]}>
|
||||
{t('search.browse_tv')}
|
||||
</Text>
|
||||
</View>
|
||||
{selectedDiscoverType === 'series' && (
|
||||
<MaterialIcons name="check" size={24} color={currentTheme.colors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</BottomSheetScrollView>
|
||||
</BottomSheetModal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DiscoverBottomSheets.displayName = 'DiscoverBottomSheets';
|
||||
159
src/components/search/DiscoverResultItem.tsx
Normal file
159
src/components/search/DiscoverResultItem.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import React, { useMemo, useEffect, useState } from 'react';
|
||||
import { View, Text, TouchableOpacity, Dimensions, DeviceEventEmitter } from 'react-native';
|
||||
import { MaterialIcons, Feather } from '@expo/vector-icons';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { StreamingContent, catalogService } from '../../services/catalogService';
|
||||
import { mmkvStorage } from '../../services/mmkvStorage';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
import {
|
||||
isTablet,
|
||||
isLargeTablet,
|
||||
isTV,
|
||||
HORIZONTAL_ITEM_WIDTH,
|
||||
HORIZONTAL_POSTER_HEIGHT,
|
||||
PLACEHOLDER_POSTER,
|
||||
} from './searchUtils';
|
||||
import { searchStyles as styles } from './searchStyles';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
interface DiscoverResultItemProps {
|
||||
item: StreamingContent;
|
||||
index: number;
|
||||
navigation: any;
|
||||
setSelectedItem: (item: StreamingContent) => void;
|
||||
setMenuVisible: (visible: boolean) => void;
|
||||
currentTheme: any;
|
||||
isGrid?: boolean;
|
||||
}
|
||||
|
||||
export const DiscoverResultItem = React.memo(({
|
||||
item,
|
||||
index,
|
||||
navigation,
|
||||
setSelectedItem,
|
||||
setMenuVisible,
|
||||
currentTheme,
|
||||
isGrid = false
|
||||
}: DiscoverResultItemProps) => {
|
||||
const { settings } = useSettings();
|
||||
const [inLibrary, setInLibrary] = useState(!!item.inLibrary);
|
||||
const [watched, setWatched] = useState(false);
|
||||
|
||||
// Calculate dimensions based on poster shape
|
||||
const { itemWidth, aspectRatio } = useMemo(() => {
|
||||
const shape = item.posterShape || 'poster';
|
||||
const baseHeight = HORIZONTAL_POSTER_HEIGHT;
|
||||
|
||||
let w = HORIZONTAL_ITEM_WIDTH;
|
||||
let r = 2 / 3;
|
||||
|
||||
if (isGrid) {
|
||||
// Grid Calculation: (Window Width - Padding) / Columns
|
||||
const columns = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3;
|
||||
const totalPadding = 32;
|
||||
const totalGap = 12 * (Math.max(3, columns) - 1);
|
||||
const availableWidth = width - totalPadding - totalGap;
|
||||
w = availableWidth / Math.max(3, columns);
|
||||
} else {
|
||||
if (shape === 'landscape') {
|
||||
r = 16 / 9;
|
||||
w = baseHeight * r;
|
||||
} else if (shape === 'square') {
|
||||
r = 1;
|
||||
w = baseHeight;
|
||||
}
|
||||
}
|
||||
return { itemWidth: w, aspectRatio: r };
|
||||
}, [item.posterShape, isGrid]);
|
||||
|
||||
useEffect(() => {
|
||||
const updateWatched = () => {
|
||||
mmkvStorage.getItem(`watched:${item.type}:${item.id}`).then(val => setWatched(val === 'true'));
|
||||
};
|
||||
updateWatched();
|
||||
const sub = DeviceEventEmitter.addListener('watchedStatusChanged', updateWatched);
|
||||
return () => sub.remove();
|
||||
}, [item.id, item.type]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = catalogService.subscribeToLibraryUpdates((items) => {
|
||||
const found = items.find((libItem) => libItem.id === item.id && libItem.type === item.type);
|
||||
setInLibrary(!!found);
|
||||
});
|
||||
return () => unsubscribe();
|
||||
}, [item.id, item.type]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.horizontalItem,
|
||||
{ width: itemWidth },
|
||||
isGrid && styles.discoverGridItem
|
||||
]}
|
||||
onPress={() => {
|
||||
navigation.navigate('Metadata', {
|
||||
id: item.id,
|
||||
type: item.type,
|
||||
addonId: item.addonId
|
||||
});
|
||||
}}
|
||||
onLongPress={() => {
|
||||
setSelectedItem(item);
|
||||
setMenuVisible(true);
|
||||
}}
|
||||
delayLongPress={300}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.horizontalItemPosterContainer, {
|
||||
width: itemWidth,
|
||||
height: undefined,
|
||||
aspectRatio: aspectRatio,
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
borderRadius: settings.posterBorderRadius ?? 12,
|
||||
}]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: item.poster || PLACEHOLDER_POSTER,
|
||||
priority: FastImage.priority.low,
|
||||
cache: FastImage.cacheControl.immutable,
|
||||
}}
|
||||
style={[styles.horizontalItemPoster, { borderRadius: settings.posterBorderRadius ?? 12 }]}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
/>
|
||||
{/* Bookmark icon */}
|
||||
{inLibrary && (
|
||||
<View style={[styles.libraryBadge, { position: 'absolute', top: 8, right: 36, backgroundColor: 'transparent', zIndex: 2 }]}>
|
||||
<Feather name="bookmark" size={16} color={currentTheme.colors.white} />
|
||||
</View>
|
||||
)}
|
||||
{/* Watched icon */}
|
||||
{watched && (
|
||||
<View style={[styles.watchedIndicator, { position: 'absolute', top: 8, right: 8, backgroundColor: 'transparent', zIndex: 2 }]}>
|
||||
<MaterialIcons name="check-circle" size={20} color={currentTheme.colors.success || '#4CAF50'} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text
|
||||
style={[
|
||||
styles.horizontalItemTitle,
|
||||
{
|
||||
color: currentTheme.colors.white,
|
||||
fontSize: isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 14,
|
||||
lineHeight: isTV ? 18 : isLargeTablet ? 17 : isTablet ? 16 : 18,
|
||||
}
|
||||
]}
|
||||
numberOfLines={2}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
{item.year && (
|
||||
<Text style={[styles.yearText, { color: currentTheme.colors.mediumGray, fontSize: isTV ? 12 : isLargeTablet ? 11 : isTablet ? 10 : 12 }]}>
|
||||
{item.year}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
DiscoverResultItem.displayName = 'DiscoverResultItem';
|
||||
198
src/components/search/DiscoverSection.tsx
Normal file
198
src/components/search/DiscoverSection.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
} from 'react-native';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { StreamingContent } from '../../services/catalogService';
|
||||
import { DiscoverCatalog, isTablet, isLargeTablet, isTV } from './searchUtils';
|
||||
import { DiscoverResultItem } from './DiscoverResultItem';
|
||||
import { searchStyles as styles } from './searchStyles';
|
||||
import { BottomSheetModal } from '@gorhom/bottom-sheet';
|
||||
|
||||
interface DiscoverSectionProps {
|
||||
discoverLoading: boolean;
|
||||
discoverInitialized: boolean;
|
||||
discoverResults: StreamingContent[];
|
||||
pendingDiscoverResults: StreamingContent[];
|
||||
loadingMore: boolean;
|
||||
selectedCatalog: DiscoverCatalog | null;
|
||||
selectedDiscoverType: 'movie' | 'series';
|
||||
selectedDiscoverGenre: string | null;
|
||||
availableGenres: string[];
|
||||
typeSheetRef: React.RefObject<BottomSheetModal>;
|
||||
catalogSheetRef: React.RefObject<BottomSheetModal>;
|
||||
genreSheetRef: React.RefObject<BottomSheetModal>;
|
||||
handleShowMore: () => void;
|
||||
navigation: any;
|
||||
setSelectedItem: (item: StreamingContent) => void;
|
||||
setMenuVisible: (visible: boolean) => void;
|
||||
currentTheme: any;
|
||||
}
|
||||
|
||||
export const DiscoverSection = ({
|
||||
discoverLoading,
|
||||
discoverInitialized,
|
||||
discoverResults,
|
||||
pendingDiscoverResults,
|
||||
loadingMore,
|
||||
selectedCatalog,
|
||||
selectedDiscoverType,
|
||||
selectedDiscoverGenre,
|
||||
availableGenres,
|
||||
typeSheetRef,
|
||||
catalogSheetRef,
|
||||
genreSheetRef,
|
||||
handleShowMore,
|
||||
navigation,
|
||||
setSelectedItem,
|
||||
setMenuVisible,
|
||||
currentTheme,
|
||||
}: DiscoverSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<View style={styles.discoverContainer}>
|
||||
{/* Section Header */}
|
||||
<View style={styles.discoverHeader}>
|
||||
<Text style={[styles.discoverTitle, { color: currentTheme.colors.white }]}>
|
||||
{t('search.discover')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* Filter Chips Row */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.discoverChipsScroll}
|
||||
contentContainerStyle={styles.discoverChipsContent}
|
||||
>
|
||||
{/* Type Selector Chip (Movie/TV Show) */}
|
||||
<TouchableOpacity
|
||||
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={() => typeSheetRef.current?.present()}
|
||||
>
|
||||
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
|
||||
{selectedDiscoverType === 'movie' ? t('search.movies') : t('search.tv_shows')}
|
||||
</Text>
|
||||
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Catalog Selector Chip */}
|
||||
<TouchableOpacity
|
||||
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={() => catalogSheetRef.current?.present()}
|
||||
>
|
||||
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
|
||||
{selectedCatalog ? selectedCatalog.catalogName : t('search.select_catalog')}
|
||||
</Text>
|
||||
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Genre Selector Chip - only show if catalog has genres */}
|
||||
{availableGenres.length > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.discoverSelectorChip, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={() => genreSheetRef.current?.present()}
|
||||
>
|
||||
<Text style={[styles.discoverSelectorText, { color: currentTheme.colors.white }]} numberOfLines={1}>
|
||||
{selectedDiscoverGenre || t('search.all_genres')}
|
||||
</Text>
|
||||
<MaterialIcons name="keyboard-arrow-down" size={20} color={currentTheme.colors.lightGray} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Selected filters summary */}
|
||||
{selectedCatalog && (
|
||||
<View style={styles.discoverFilterSummary}>
|
||||
<Text style={[styles.discoverFilterSummaryText, { color: currentTheme.colors.lightGray }]}>
|
||||
{selectedCatalog.addonName} • {selectedCatalog.type === 'movie' ? t('search.movies') : t('search.tv_shows')}
|
||||
{selectedDiscoverGenre ? ` • ${selectedDiscoverGenre}` : ''}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Discover Results */}
|
||||
{discoverLoading ? (
|
||||
<View style={styles.discoverLoadingContainer}>
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.discoverLoadingText, { color: currentTheme.colors.lightGray }]}>
|
||||
{t('search.discovering')}
|
||||
</Text>
|
||||
</View>
|
||||
) : discoverResults.length > 0 ? (
|
||||
<FlatList
|
||||
data={discoverResults}
|
||||
keyExtractor={(item, index) => `discover-${item.id}-${index}`}
|
||||
numColumns={isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 3}
|
||||
key={isTV ? 'tv-6' : isLargeTablet ? 'ltab-5' : isTablet ? 'tab-4' : 'phone-3'}
|
||||
columnWrapperStyle={styles.discoverGridRow}
|
||||
contentContainerStyle={styles.discoverGridContent}
|
||||
renderItem={({ item, index }) => (
|
||||
<DiscoverResultItem
|
||||
key={`discover-${item.id}-${index}`}
|
||||
item={item}
|
||||
index={index}
|
||||
navigation={navigation}
|
||||
setSelectedItem={setSelectedItem}
|
||||
setMenuVisible={setMenuVisible}
|
||||
currentTheme={currentTheme}
|
||||
isGrid={true}
|
||||
/>
|
||||
)}
|
||||
initialNumToRender={9}
|
||||
maxToRenderPerBatch={6}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={true}
|
||||
scrollEnabled={false}
|
||||
ListFooterComponent={
|
||||
pendingDiscoverResults.length > 0 ? (
|
||||
<TouchableOpacity
|
||||
style={styles.showMoreButton}
|
||||
onPress={handleShowMore}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.showMoreButtonText, { color: currentTheme.colors.white }]}>
|
||||
{t('search.show_more', { count: pendingDiscoverResults.length })}
|
||||
</Text>
|
||||
<MaterialIcons name="expand-more" size={20} color={currentTheme.colors.white} />
|
||||
</TouchableOpacity>
|
||||
) : loadingMore ? (
|
||||
<View style={styles.loadingMoreContainer}>
|
||||
<ActivityIndicator size="small" color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
) : discoverInitialized && !discoverLoading && selectedCatalog ? (
|
||||
<View style={styles.discoverEmptyContainer}>
|
||||
<MaterialIcons name="movie-filter" size={48} color={currentTheme.colors.lightGray} />
|
||||
<Text style={[styles.discoverEmptyText, { color: currentTheme.colors.lightGray }]}>
|
||||
{t('search.no_content_found')}
|
||||
</Text>
|
||||
<Text style={[styles.discoverEmptySubtext, { color: currentTheme.colors.mediumGray }]}>
|
||||
{t('search.try_different')}
|
||||
</Text>
|
||||
</View>
|
||||
) : !selectedCatalog && discoverInitialized ? (
|
||||
<View style={styles.discoverEmptyContainer}>
|
||||
<MaterialIcons name="touch-app" size={48} color={currentTheme.colors.lightGray} />
|
||||
<Text style={[styles.discoverEmptyText, { color: currentTheme.colors.lightGray }]}>
|
||||
{t('search.select_catalog_desc')}
|
||||
</Text>
|
||||
<Text style={[styles.discoverEmptySubtext, { color: currentTheme.colors.mediumGray }]}>
|
||||
{t('search.tap_catalog_desc')}
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
DiscoverSection.displayName = 'DiscoverSection';
|
||||
|
|
@ -190,7 +190,15 @@ const styles = StyleSheet.create({
|
|||
fontSize: 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
libraryBadge: {},
|
||||
libraryBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
zIndex: 2,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
watchedIndicator: {},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
// Search components barrel export
|
||||
export * from './searchUtils';
|
||||
export { searchStyles } from './searchStyles';
|
||||
export { SearchSkeletonLoader } from './SearchSkeletonLoader';
|
||||
export { SearchAnimation } from './SearchAnimation';
|
||||
export { SearchResultItem } from './SearchResultItem';
|
||||
export { RecentSearches } from './RecentSearches';
|
||||
export { DiscoverResultItem } from './DiscoverResultItem';
|
||||
export { AddonSection } from './AddonSection';
|
||||
export { DiscoverSection } from './DiscoverSection';
|
||||
export { DiscoverBottomSheets } from './DiscoverBottomSheets';
|
||||
|
|
|
|||
531
src/components/search/searchStyles.ts
Normal file
531
src/components/search/searchStyles.ts
Normal file
|
|
@ -0,0 +1,531 @@
|
|||
import { StyleSheet, Platform, Dimensions } from 'react-native';
|
||||
import { isTablet, isTV, isLargeTablet, HORIZONTAL_ITEM_WIDTH, HORIZONTAL_POSTER_HEIGHT, POSTER_WIDTH, POSTER_HEIGHT } from './searchUtils';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
export const searchStyles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
paddingTop: 0,
|
||||
},
|
||||
searchBarContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
height: 48,
|
||||
},
|
||||
searchBarWrapper: {
|
||||
flex: 1,
|
||||
height: 48,
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 16,
|
||||
height: '100%',
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
searchIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
height: '100%',
|
||||
},
|
||||
clearButton: {
|
||||
padding: 4,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollViewContent: {
|
||||
paddingBottom: isTablet ? 120 : 100,
|
||||
paddingHorizontal: 0,
|
||||
},
|
||||
carouselContainer: {
|
||||
marginBottom: isTablet ? 32 : 24,
|
||||
},
|
||||
carouselTitle: {
|
||||
fontSize: isTablet ? 20 : 18,
|
||||
fontWeight: '700',
|
||||
marginBottom: isTablet ? 16 : 12,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
carouselSubtitle: {
|
||||
fontSize: isTablet ? 16 : 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: isTablet ? 12 : 8,
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
addonHeaderContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: isTablet ? 16 : 12,
|
||||
marginTop: isTablet ? 24 : 16,
|
||||
marginBottom: isTablet ? 8 : 4,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
addonHeaderIcon: {
|
||||
// removed icon
|
||||
},
|
||||
addonHeaderText: {
|
||||
fontSize: isTablet ? 18 : 16,
|
||||
fontWeight: '700',
|
||||
flex: 1,
|
||||
},
|
||||
addonHeaderBadge: {
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 12,
|
||||
},
|
||||
addonHeaderBadgeText: {
|
||||
fontSize: isTablet ? 12 : 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
horizontalListContent: {
|
||||
paddingHorizontal: 16,
|
||||
},
|
||||
horizontalItem: {
|
||||
width: HORIZONTAL_ITEM_WIDTH,
|
||||
marginRight: 16,
|
||||
},
|
||||
horizontalItemPosterContainer: {
|
||||
width: HORIZONTAL_ITEM_WIDTH,
|
||||
height: HORIZONTAL_POSTER_HEIGHT,
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
borderWidth: 1.5,
|
||||
borderColor: 'rgba(255,255,255,0.15)',
|
||||
elevation: Platform.OS === 'android' ? 1 : 0,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 1,
|
||||
},
|
||||
horizontalItemPoster: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
horizontalItemTitle: {
|
||||
fontSize: isTablet ? 12 : 14,
|
||||
fontWeight: '600',
|
||||
lineHeight: isTablet ? 16 : 18,
|
||||
textAlign: 'left',
|
||||
},
|
||||
yearText: {
|
||||
fontSize: isTablet ? 10 : 12,
|
||||
marginTop: 2,
|
||||
},
|
||||
recentSearchesContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: isTablet ? 24 : 16,
|
||||
paddingTop: isTablet ? 12 : 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.05)',
|
||||
marginBottom: isTablet ? 16 : 8,
|
||||
},
|
||||
recentSearchItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: isTablet ? 12 : 10,
|
||||
paddingHorizontal: 16,
|
||||
marginVertical: 1,
|
||||
},
|
||||
recentSearchIcon: {
|
||||
marginRight: 12,
|
||||
},
|
||||
recentSearchText: {
|
||||
fontSize: 16,
|
||||
flex: 1,
|
||||
},
|
||||
recentSearchDeleteButton: {
|
||||
padding: 4,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
loadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 5,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: isTablet ? 64 : 32,
|
||||
paddingBottom: isTablet ? 120 : 100,
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
marginTop: 16,
|
||||
marginBottom: 8,
|
||||
},
|
||||
emptySubtext: {
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
lineHeight: 20,
|
||||
},
|
||||
skeletonContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 12,
|
||||
paddingTop: 16,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
skeletonVerticalItem: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
},
|
||||
skeletonPoster: {
|
||||
width: POSTER_WIDTH,
|
||||
height: POSTER_HEIGHT,
|
||||
borderRadius: 12,
|
||||
},
|
||||
skeletonItemDetails: {
|
||||
flex: 1,
|
||||
marginLeft: 16,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
skeletonMetaRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
},
|
||||
skeletonTitle: {
|
||||
height: 20,
|
||||
width: '80%',
|
||||
marginBottom: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
skeletonMeta: {
|
||||
height: 14,
|
||||
width: '30%',
|
||||
borderRadius: 4,
|
||||
},
|
||||
skeletonSectionHeader: {
|
||||
height: 24,
|
||||
width: '40%',
|
||||
marginBottom: 16,
|
||||
borderRadius: 4,
|
||||
},
|
||||
ratingContainer: {
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 4,
|
||||
},
|
||||
ratingText: {
|
||||
fontSize: isTablet ? 9 : 10,
|
||||
fontWeight: '700',
|
||||
marginLeft: 2,
|
||||
},
|
||||
simpleAnimationContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
simpleAnimationContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
spinnerContainer: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
shadowColor: "#000",
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
simpleAnimationText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
watchedIndicator: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
borderRadius: 12,
|
||||
padding: 2,
|
||||
zIndex: 2,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
libraryBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
zIndex: 2,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
// Discover section styles
|
||||
discoverContainer: {
|
||||
paddingTop: isTablet ? 16 : 12,
|
||||
paddingBottom: isTablet ? 24 : 16,
|
||||
},
|
||||
discoverHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: isTablet ? 16 : 12,
|
||||
gap: 8,
|
||||
},
|
||||
discoverTitle: {
|
||||
fontSize: isTablet ? 22 : 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
discoverTypeContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: isTablet ? 16 : 12,
|
||||
gap: 12,
|
||||
},
|
||||
discoverTypeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
gap: 6,
|
||||
},
|
||||
discoverTypeText: {
|
||||
fontSize: isTablet ? 15 : 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
discoverGenreScroll: {
|
||||
marginBottom: isTablet ? 20 : 16,
|
||||
},
|
||||
discoverGenreContent: {
|
||||
paddingHorizontal: 16,
|
||||
gap: 8,
|
||||
},
|
||||
discoverGenreChip: {
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 16,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
marginRight: 8,
|
||||
},
|
||||
discoverGenreChipActive: {
|
||||
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
discoverGenreText: {
|
||||
fontSize: isTablet ? 14 : 13,
|
||||
fontWeight: '500',
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
},
|
||||
discoverGenreTextActive: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '600',
|
||||
},
|
||||
discoverLoadingContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
},
|
||||
discoverLoadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
discoverAddonSection: {
|
||||
marginBottom: isTablet ? 28 : 20,
|
||||
},
|
||||
discoverAddonHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: isTablet ? 12 : 8,
|
||||
},
|
||||
discoverAddonName: {
|
||||
fontSize: isTablet ? 16 : 15,
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
discoverAddonBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 10,
|
||||
},
|
||||
discoverAddonBadgeText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
discoverEmptyContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 60,
|
||||
paddingHorizontal: 32,
|
||||
},
|
||||
discoverEmptyText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
marginTop: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
discoverEmptySubtext: {
|
||||
fontSize: 14,
|
||||
marginTop: 4,
|
||||
textAlign: 'center',
|
||||
},
|
||||
discoverGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: 16,
|
||||
gap: 12,
|
||||
},
|
||||
discoverGridRow: {
|
||||
justifyContent: 'flex-start',
|
||||
gap: 12,
|
||||
},
|
||||
discoverGridContent: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
discoverGridItem: {
|
||||
marginRight: 0,
|
||||
marginBottom: 12,
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
width: '100%',
|
||||
paddingVertical: 16,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// New chip-based discover styles
|
||||
discoverChipsScroll: {
|
||||
marginBottom: isTablet ? 12 : 10,
|
||||
flexGrow: 0,
|
||||
},
|
||||
discoverChipsContent: {
|
||||
paddingHorizontal: 16,
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
discoverSelectorChip: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
borderRadius: 20,
|
||||
gap: 6,
|
||||
},
|
||||
discoverSelectorText: {
|
||||
fontSize: isTablet ? 14 : 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
discoverFilterSummary: {
|
||||
paddingHorizontal: 16,
|
||||
marginBottom: isTablet ? 16 : 12,
|
||||
},
|
||||
discoverFilterSummaryText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// Bottom sheet styles
|
||||
bottomSheetHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 16,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
bottomSheetTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
},
|
||||
bottomSheetContent: {
|
||||
paddingHorizontal: 12,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
bottomSheetItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
marginVertical: 2,
|
||||
},
|
||||
bottomSheetItemSelected: {
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
bottomSheetItemIcon: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
bottomSheetItemContent: {
|
||||
flex: 1,
|
||||
},
|
||||
bottomSheetItemTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
bottomSheetItemSubtitle: {
|
||||
fontSize: 13,
|
||||
marginTop: 2,
|
||||
},
|
||||
showMoreButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 24,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
borderRadius: 8,
|
||||
marginVertical: 20,
|
||||
alignSelf: 'center',
|
||||
},
|
||||
showMoreButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginRight: 8,
|
||||
},
|
||||
});
|
||||
9
src/constants/locales.ts
Normal file
9
src/constants/locales.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export const LOCALES = [
|
||||
{ code: 'en', key: 'english' },
|
||||
{ code: 'pt-BR', key: 'portuguese_br' },
|
||||
{ code: 'pt-PT', key: 'portuguese_pt' },
|
||||
{ code: 'de', key: 'german' },
|
||||
{ code: 'ar', key: 'arabic' },
|
||||
{ code: 'fr', key: 'french' },
|
||||
{ code: 'it', key: 'italian' }
|
||||
];
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { useTraktIntegration } from '../hooks/useTraktIntegration';
|
||||
import {
|
||||
TraktUser,
|
||||
TraktWatchedItem,
|
||||
TraktWatchlistItem,
|
||||
TraktCollectionItem,
|
||||
import {
|
||||
TraktUser,
|
||||
TraktWatchedItem,
|
||||
TraktWatchlistItem,
|
||||
TraktCollectionItem,
|
||||
TraktRatingItem,
|
||||
TraktPlaybackItem
|
||||
TraktPlaybackItem,
|
||||
traktService
|
||||
} from '../services/traktService';
|
||||
|
||||
interface TraktContextProps {
|
||||
|
|
@ -37,15 +38,25 @@ interface TraktContextProps {
|
|||
removeFromCollection: (imdbId: string, type: 'movie' | 'show') => Promise<boolean>;
|
||||
isInWatchlist: (imdbId: string, type: 'movie' | 'show') => boolean;
|
||||
isInCollection: (imdbId: string, type: 'movie' | 'show') => boolean;
|
||||
// Maintenance mode
|
||||
isMaintenanceMode: boolean;
|
||||
maintenanceMessage: string;
|
||||
}
|
||||
|
||||
const TraktContext = createContext<TraktContextProps | undefined>(undefined);
|
||||
|
||||
export function TraktProvider({ children }: { children: ReactNode }) {
|
||||
const traktIntegration = useTraktIntegration();
|
||||
|
||||
|
||||
// Add maintenance mode values to the context
|
||||
const contextValue: TraktContextProps = {
|
||||
...traktIntegration,
|
||||
isMaintenanceMode: traktService.isMaintenanceMode(),
|
||||
maintenanceMessage: traktService.getMaintenanceMessage(),
|
||||
};
|
||||
|
||||
return (
|
||||
<TraktContext.Provider value={traktIntegration}>
|
||||
<TraktContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</TraktContext.Provider>
|
||||
);
|
||||
|
|
|
|||
41
src/hooks/useBottomSheetBackHandler.ts
Normal file
41
src/hooks/useBottomSheetBackHandler.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { BackHandler } from 'react-native';
|
||||
import { BottomSheetModal } from '@gorhom/bottom-sheet';
|
||||
|
||||
export function useBottomSheetBackHandler() {
|
||||
const activeSheetRef =
|
||||
useRef<React.RefObject<BottomSheetModal> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = BackHandler.addEventListener(
|
||||
'hardwareBackPress',
|
||||
() => {
|
||||
if (activeSheetRef.current?.current) {
|
||||
activeSheetRef.current.current.dismiss();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
);
|
||||
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
|
||||
const onChange =
|
||||
(ref: React.RefObject<BottomSheetModal>) =>
|
||||
(index: number) => {
|
||||
if (index >= 0) {
|
||||
activeSheetRef.current = ref;
|
||||
}
|
||||
};
|
||||
|
||||
const onDismiss =
|
||||
(ref: React.RefObject<BottomSheetModal>) => () => {
|
||||
if (activeSheetRef.current === ref) {
|
||||
activeSheetRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return { onChange, onDismiss };
|
||||
}
|
||||
|
|
@ -220,7 +220,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
|
|||
vote_average: tmdbEpisode.vote_average || 0,
|
||||
still_path: tmdbEpisode.still_path || null,
|
||||
season_poster_path: tmdbEpisode.season_poster_path || null,
|
||||
addonId: episodeData.addonId || series.addonId,
|
||||
addonId: (episodeData as any).addonId || series.addonId,
|
||||
};
|
||||
|
||||
|
||||
|
|
@ -247,7 +247,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
|
|||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null,
|
||||
addonId: episodeData?.addonId || series.addonId,
|
||||
addonId: (episodeData as any)?.addonId || series.addonId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -268,7 +268,7 @@ export const useCalendarData = (): UseCalendarDataReturn => {
|
|||
vote_average: 0,
|
||||
still_path: null,
|
||||
season_poster_path: null,
|
||||
addonId: episodeData?.addonId || series.addonId,
|
||||
addonId: series.addonId,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -550,7 +550,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
if (__DEV__) logger.log('Fetching movie details from TMDB for:', tmdbId);
|
||||
const movieDetails = await tmdbService.getMovieDetails(
|
||||
tmdbId,
|
||||
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
|
||||
settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'
|
||||
);
|
||||
if (movieDetails) {
|
||||
const imdbId = movieDetails.imdb_id || movieDetails.external_ids?.imdb_id;
|
||||
|
|
@ -634,7 +634,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
try {
|
||||
const showDetails = await tmdbService.getTVShowDetails(
|
||||
parseInt(tmdbId),
|
||||
settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}-US` : 'en-US'
|
||||
settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en'
|
||||
);
|
||||
if (showDetails) {
|
||||
// OPTIMIZATION: Fetch external IDs, credits, and logo in parallel
|
||||
|
|
@ -824,9 +824,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
// Store addon logo before TMDB enrichment overwrites it
|
||||
const addonLogo = (finalMetadata as any).logo;
|
||||
|
||||
// If localization is enabled, merge TMDB localized text (name/overview) before first render
|
||||
|
||||
try {
|
||||
if (settings.enrichMetadataWithTMDB && settings.useTmdbLocalizedMetadata) {
|
||||
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichTitleDescription) {
|
||||
const tmdbSvc = TMDBService.getInstance();
|
||||
let finalTmdbId: number | null = tmdbId;
|
||||
if (!finalTmdbId) {
|
||||
|
|
@ -835,7 +835,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
}
|
||||
|
||||
if (finalTmdbId) {
|
||||
const lang = settings.tmdbLanguagePreference || 'en';
|
||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||
if (type === 'movie') {
|
||||
const localized = await tmdbSvc.getMovieDetails(String(finalTmdbId), lang);
|
||||
if (localized) {
|
||||
|
|
@ -857,8 +857,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
|
||||
finalMetadata = {
|
||||
...finalMetadata,
|
||||
name: finalMetadata.name || localized.title,
|
||||
description: finalMetadata.description || localized.overview,
|
||||
name: localized.title || finalMetadata.name,
|
||||
description: localized.overview || finalMetadata.description,
|
||||
movieDetails: movieDetailsObj,
|
||||
...(productionInfo.length > 0 && { networks: productionInfo }),
|
||||
};
|
||||
|
|
@ -894,8 +894,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
|
||||
finalMetadata = {
|
||||
...finalMetadata,
|
||||
name: finalMetadata.name || localized.name,
|
||||
description: finalMetadata.description || localized.overview,
|
||||
name: localized.name || finalMetadata.name,
|
||||
description: localized.overview || finalMetadata.description,
|
||||
tvDetails,
|
||||
...(productionInfo.length > 0 && { networks: productionInfo }),
|
||||
};
|
||||
|
|
@ -904,19 +904,13 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (__DEV__) console.log('[useMetadata] failed to merge localized TMDB text', e);
|
||||
if (__DEV__) console.log('[useMetadata] failed to merge TMDB title/description', e);
|
||||
}
|
||||
|
||||
// Centralized logo fetching logic
|
||||
try {
|
||||
if (addonLogo) {
|
||||
finalMetadata.logo = addonLogo;
|
||||
if (__DEV__) {
|
||||
console.log('[useMetadata] Using addon-provided logo:', { hasLogo: true });
|
||||
}
|
||||
// Check both master switch AND granular logos setting
|
||||
} else if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
|
||||
// Only use TMDB logos when both enrichment AND logos option are ON
|
||||
// When TMDB enrichment AND logos are enabled, prioritize TMDB logo over addon logo
|
||||
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichLogos) {
|
||||
const tmdbService = TMDBService.getInstance();
|
||||
const preferredLanguage = settings.tmdbLanguagePreference || 'en';
|
||||
const contentType = type === 'series' ? 'tv' : 'movie';
|
||||
|
|
@ -932,23 +926,26 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
|
||||
if (tmdbIdForLogo) {
|
||||
const logoUrl = await tmdbService.getContentLogo(contentType, tmdbIdForLogo, preferredLanguage);
|
||||
finalMetadata.logo = logoUrl || undefined; // TMDB logo or undefined (no addon fallback)
|
||||
// Use TMDB logo if found, otherwise fall back to addon logo
|
||||
finalMetadata.logo = logoUrl || addonLogo || undefined;
|
||||
if (__DEV__) {
|
||||
console.log('[useMetadata] Logo fetch result:', {
|
||||
contentType,
|
||||
tmdbIdForLogo,
|
||||
preferredLanguage,
|
||||
logoUrl: !!logoUrl,
|
||||
tmdbLogoFound: !!logoUrl,
|
||||
usingAddonFallback: !logoUrl && !!addonLogo,
|
||||
enrichmentEnabled: true
|
||||
});
|
||||
}
|
||||
} else {
|
||||
finalMetadata.logo = undefined; // No TMDB ID means no logo
|
||||
if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, will show text title');
|
||||
// No TMDB ID, fall back to addon logo
|
||||
finalMetadata.logo = addonLogo || undefined;
|
||||
if (__DEV__) console.log('[useMetadata] No TMDB ID found for logo, using addon logo');
|
||||
}
|
||||
} else {
|
||||
// When enrichment or logos is OFF, keep addon logo or undefined
|
||||
finalMetadata.logo = finalMetadata.logo || undefined;
|
||||
// When enrichment or logos is OFF, use addon logo
|
||||
finalMetadata.logo = addonLogo || finalMetadata.logo || undefined;
|
||||
if (__DEV__) {
|
||||
console.log('[useMetadata] TMDB logo enrichment disabled, using addon logo:', {
|
||||
hasAddonLogo: !!finalMetadata.logo,
|
||||
|
|
@ -1125,10 +1122,11 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
// Fetch season posters from TMDB only if enrichment AND season posters are enabled
|
||||
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichSeasonPosters) {
|
||||
try {
|
||||
const lang = settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}` : 'en';
|
||||
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
|
||||
if (tmdbIdToUse) {
|
||||
if (!tmdbId) setTmdbId(tmdbIdToUse);
|
||||
const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse);
|
||||
const showDetails = await tmdbService.getTVShowDetails(tmdbIdToUse, lang);
|
||||
if (showDetails?.seasons) {
|
||||
Object.keys(groupedAddonEpisodes).forEach(seasonStr => {
|
||||
const seasonNum = parseInt(seasonStr, 10);
|
||||
|
|
@ -1151,37 +1149,46 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
if (__DEV__) logger.log('[loadSeriesData] TMDB season poster enrichment disabled; skipping season poster fetch');
|
||||
}
|
||||
|
||||
// If localized TMDB text is enabled AND episode enrichment is enabled, merge episode names/overviews per language
|
||||
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichEpisodes && settings.useTmdbLocalizedMetadata) {
|
||||
if (settings.enrichMetadataWithTMDB && settings.tmdbEnrichEpisodes) {
|
||||
try {
|
||||
const tmdbIdToUse = tmdbId || (id.startsWith('tt') ? await tmdbService.findTMDBIdByIMDB(id) : null);
|
||||
if (tmdbIdToUse) {
|
||||
const lang = `${settings.tmdbLanguagePreference || 'en'}-US`;
|
||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||
const seasons = Object.keys(groupedAddonEpisodes).map(Number);
|
||||
for (const seasonNum of seasons) {
|
||||
const seasonEps = groupedAddonEpisodes[seasonNum];
|
||||
// Parallel fetch a reasonable batch (limit concurrency implicitly by season)
|
||||
const localized = await Promise.all(
|
||||
seasonEps.map(async ep => {
|
||||
try {
|
||||
const data = await tmdbService.getEpisodeDetails(Number(tmdbIdToUse), seasonNum, ep.episode_number, lang);
|
||||
if (data) {
|
||||
|
||||
// Fetch all seasons in parallel (much faster than fetching each episode individually)
|
||||
const seasonPromises = seasons.map(async seasonNum => {
|
||||
try {
|
||||
// getSeasonDetails returns all episodes for a season in one call
|
||||
const seasonData = await tmdbService.getSeasonDetails(Number(tmdbIdToUse), seasonNum, undefined, lang);
|
||||
if (seasonData && seasonData.episodes) {
|
||||
// Create a map of episode number -> localized data for fast lookup
|
||||
const localizedMap = new Map<number, { name: string; overview: string }>();
|
||||
for (const ep of seasonData.episodes) {
|
||||
localizedMap.set(ep.episode_number, { name: ep.name, overview: ep.overview });
|
||||
}
|
||||
|
||||
// Merge localized data into addon episodes
|
||||
groupedAddonEpisodes[seasonNum] = groupedAddonEpisodes[seasonNum].map(ep => {
|
||||
const localized = localizedMap.get(ep.episode_number);
|
||||
if (localized) {
|
||||
return {
|
||||
...ep,
|
||||
name: data.name || ep.name,
|
||||
overview: data.overview || ep.overview,
|
||||
name: localized.name || ep.name,
|
||||
overview: localized.overview || ep.overview,
|
||||
};
|
||||
}
|
||||
} catch { }
|
||||
return ep;
|
||||
})
|
||||
);
|
||||
groupedAddonEpisodes[seasonNum] = localized;
|
||||
}
|
||||
if (__DEV__) logger.log('[useMetadata] merged localized episode names/overviews from TMDB');
|
||||
return ep;
|
||||
});
|
||||
}
|
||||
} catch { }
|
||||
});
|
||||
|
||||
await Promise.all(seasonPromises);
|
||||
if (__DEV__) logger.log('[useMetadata] merged episode names/overviews from TMDB (batch)');
|
||||
}
|
||||
} catch (e) {
|
||||
if (__DEV__) console.log('[useMetadata] failed to merge localized episode text', e);
|
||||
if (__DEV__) console.log('[useMetadata] failed to merge episode text from TMDB', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1264,13 +1271,14 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
|
||||
// Fallback to TMDB if no addon episodes
|
||||
logger.log('📺 No addon episodes found, falling back to TMDB');
|
||||
const lang = settings.useTmdbLocalizedMetadata ? `${settings.tmdbLanguagePreference || 'en'}` : 'en';
|
||||
const tmdbIdResult = await tmdbService.findTMDBIdByIMDB(id);
|
||||
if (tmdbIdResult) {
|
||||
setTmdbId(tmdbIdResult);
|
||||
|
||||
const [allEpisodes, showDetails] = await Promise.all([
|
||||
tmdbService.getAllEpisodes(tmdbIdResult),
|
||||
tmdbService.getTVShowDetails(tmdbIdResult)
|
||||
tmdbService.getAllEpisodes(tmdbIdResult, lang),
|
||||
tmdbService.getTVShowDetails(tmdbIdResult, lang)
|
||||
]);
|
||||
|
||||
const transformedEpisodes: GroupedEpisodes = {};
|
||||
|
|
@ -1596,22 +1604,26 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
});
|
||||
|
||||
// Add local scrapers if enabled
|
||||
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
|
||||
initialStatuses.push({
|
||||
id: scraper.id,
|
||||
name: scraper.name,
|
||||
isLoading: true,
|
||||
hasCompleted: false,
|
||||
error: null,
|
||||
startTime: Date.now(),
|
||||
endTime: null
|
||||
const currentSettings = await mmkvStorage.getItem('app_settings');
|
||||
const enableLocalScrapersNow = currentSettings ? JSON.parse(currentSettings).enableLocalScrapers !== false : true;
|
||||
|
||||
if (enableLocalScrapersNow) {
|
||||
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
|
||||
initialStatuses.push({
|
||||
id: scraper.id,
|
||||
name: scraper.name,
|
||||
isLoading: true,
|
||||
hasCompleted: false,
|
||||
error: null,
|
||||
startTime: Date.now(),
|
||||
endTime: null
|
||||
});
|
||||
initialActiveFetching.push(scraper.name);
|
||||
});
|
||||
initialActiveFetching.push(scraper.name);
|
||||
});
|
||||
}
|
||||
|
||||
setScraperStatuses(initialStatuses);
|
||||
setActiveFetchingScrapers(initialActiveFetching);
|
||||
console.log('🔍 [loadStreams] Initialized activeFetchingScrapers:', initialActiveFetching);
|
||||
|
||||
// If no scrapers are available, stop loading immediately
|
||||
if (initialStatuses.length === 0) {
|
||||
|
|
@ -1732,23 +1744,27 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
initialActiveFetching.push(addon.name);
|
||||
});
|
||||
|
||||
// Add local scrapers if enabled
|
||||
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
|
||||
initialStatuses.push({
|
||||
id: scraper.id,
|
||||
name: scraper.name,
|
||||
isLoading: true,
|
||||
hasCompleted: false,
|
||||
error: null,
|
||||
startTime: Date.now(),
|
||||
endTime: null
|
||||
// Add local scrapers if enabled (read from storage to avoid stale closure)
|
||||
const currentSettings = await mmkvStorage.getItem('app_settings');
|
||||
const enableLocalScrapersNow = currentSettings ? JSON.parse(currentSettings).enableLocalScrapers !== false : true;
|
||||
|
||||
if (enableLocalScrapersNow) {
|
||||
localScrapers.filter((scraper: ScraperInfo) => scraper.enabled).forEach((scraper: ScraperInfo) => {
|
||||
initialStatuses.push({
|
||||
id: scraper.id,
|
||||
name: scraper.name,
|
||||
isLoading: true,
|
||||
hasCompleted: false,
|
||||
error: null,
|
||||
startTime: Date.now(),
|
||||
endTime: null
|
||||
});
|
||||
initialActiveFetching.push(scraper.name);
|
||||
});
|
||||
initialActiveFetching.push(scraper.name);
|
||||
});
|
||||
}
|
||||
|
||||
setScraperStatuses(initialStatuses);
|
||||
setActiveFetchingScrapers(initialActiveFetching);
|
||||
console.log('🔍 [loadEpisodeStreams] Initialized activeFetchingScrapers:', initialActiveFetching);
|
||||
|
||||
// If no scrapers are available, stop loading immediately
|
||||
if (initialStatuses.length === 0) {
|
||||
|
|
@ -2038,7 +2054,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
setLoadingRecommendations(true);
|
||||
try {
|
||||
const tmdbService = TMDBService.getInstance();
|
||||
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId));
|
||||
const lang = settings.useTmdbLocalizedMetadata ? (settings.tmdbLanguagePreference || 'en') : 'en';
|
||||
const results = await tmdbService.getRecommendations(type === 'movie' ? 'movie' : 'tv', String(tmdbId), lang);
|
||||
|
||||
// Convert TMDB results to StreamingContent format (simplified)
|
||||
const formattedRecommendations: StreamingContent[] = results.map((item: any) => ({
|
||||
|
|
@ -2056,7 +2073,7 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat
|
|||
} finally {
|
||||
setLoadingRecommendations(false);
|
||||
}
|
||||
}, [tmdbId, type]);
|
||||
}, [tmdbId, type, settings.useTmdbLocalizedMetadata, settings.tmdbLanguagePreference]);
|
||||
|
||||
// Fetch TMDB ID if needed and then recommendations
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export interface AppSettings {
|
|||
useExternalPlayer: boolean;
|
||||
preferredPlayer: 'internal' | 'vlc' | 'infuse' | 'outplayer' | 'vidhub' | 'infuse_livecontainer' | 'external';
|
||||
showHeroSection: boolean;
|
||||
showThisWeekSection: boolean; // Toggle "This Week" section
|
||||
featuredContentSource: 'tmdb' | 'catalogs';
|
||||
heroStyle: 'legacy' | 'carousel' | 'appletv';
|
||||
selectedHeroCatalogs: string[]; // Array of catalog IDs to display in hero section
|
||||
|
|
@ -92,12 +93,14 @@ export interface AppSettings {
|
|||
tmdbEnrichMovieDetails: boolean; // Show movie details (budget, revenue, tagline, etc.)
|
||||
tmdbEnrichTvDetails: boolean; // Show TV details (status, seasons count, networks, etc.)
|
||||
tmdbEnrichCollections: boolean; // Show movie collections/franchises
|
||||
tmdbEnrichTitleDescription: boolean; // Use TMDB title/description (overrides addon when localization enabled)
|
||||
// Trakt integration
|
||||
showTraktComments: boolean; // Show Trakt comments in metadata screens
|
||||
// Continue Watching behavior
|
||||
useCachedStreams: boolean; // Enable/disable direct player navigation from Continue Watching cache
|
||||
openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen
|
||||
streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour)
|
||||
continueWatchingCardStyle: 'wide' | 'poster'; // Card style: 'wide' (horizontal) or 'poster' (vertical)
|
||||
enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile
|
||||
useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content
|
||||
// Android MPV player settings
|
||||
|
|
@ -122,6 +125,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
useExternalPlayer: false,
|
||||
preferredPlayer: 'internal',
|
||||
showHeroSection: true,
|
||||
showThisWeekSection: true, // Enabled by default
|
||||
featuredContentSource: 'catalogs',
|
||||
heroStyle: 'appletv',
|
||||
selectedHeroCatalogs: [], // Empty array means all catalogs are selected
|
||||
|
|
@ -176,12 +180,14 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
tmdbEnrichMovieDetails: true,
|
||||
tmdbEnrichTvDetails: true,
|
||||
tmdbEnrichCollections: true,
|
||||
tmdbEnrichTitleDescription: true, // Enabled by default for backward compatibility
|
||||
// Trakt integration
|
||||
showTraktComments: true, // Show Trakt comments by default when authenticated
|
||||
// Continue Watching behavior
|
||||
useCachedStreams: false, // Enable by default
|
||||
openMetadataScreenWhenCacheDisabled: true, // Default to StreamsScreen when cache disabled
|
||||
streamCacheTTL: 60 * 60 * 1000, // Default: 1 hour in milliseconds
|
||||
continueWatchingCardStyle: 'wide', // Default to wide (horizontal) card style
|
||||
enableStreamsBackdrop: true, // Enable by default (new behavior)
|
||||
// Android MPV player settings
|
||||
videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback)
|
||||
|
|
|
|||
|
|
@ -523,7 +523,7 @@ export function useTraktIntegration() {
|
|||
// Fetch both playback progress and recently watched movies
|
||||
const [traktProgress, watchedMovies, watchedShows] = await Promise.all([
|
||||
getTraktPlaybackProgress(),
|
||||
traktService.getWatchedMovies()
|
||||
traktService.getWatchedMovies(),
|
||||
traktService.getWatchedShows()
|
||||
]);
|
||||
|
||||
|
|
@ -559,15 +559,23 @@ export function useTraktIntegration() {
|
|||
return undefined;
|
||||
})();
|
||||
|
||||
updatePromises.push(
|
||||
storageService.mergeWithTraktProgress(
|
||||
id,
|
||||
type,
|
||||
item.progress,
|
||||
item.paused_at,
|
||||
episodeId,
|
||||
exactTime
|
||||
)
|
||||
// Merge with local progress
|
||||
await storageService.mergeWithTraktProgress(
|
||||
id,
|
||||
type,
|
||||
item.progress,
|
||||
item.paused_at,
|
||||
episodeId,
|
||||
exactTime
|
||||
);
|
||||
|
||||
// FIX: Mark as already synced so it won't be re-uploaded to Trakt
|
||||
await storageService.updateTraktSyncStatus(
|
||||
id,
|
||||
type,
|
||||
true, // synced = true
|
||||
item.progress,
|
||||
episodeId
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[useTraktIntegration] Error preparing Trakt progress update:', error);
|
||||
|
|
@ -581,19 +589,27 @@ export function useTraktIntegration() {
|
|||
const id = movie.movie.ids.imdb;
|
||||
const watchedAt = movie.last_watched_at;
|
||||
|
||||
updatePromises.push(
|
||||
storageService.mergeWithTraktProgress(
|
||||
id,
|
||||
'movie',
|
||||
100, // 100% progress for watched items
|
||||
watchedAt
|
||||
)
|
||||
await storageService.mergeWithTraktProgress(
|
||||
id,
|
||||
'movie',
|
||||
100,
|
||||
watchedAt
|
||||
);
|
||||
|
||||
// FIX: Mark as already synced
|
||||
await storageService.updateTraktSyncStatus(
|
||||
id,
|
||||
'movie',
|
||||
true,
|
||||
100
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[useTraktIntegration] Error preparing watched movie update:', error);
|
||||
logger.error('[useTraktIntegration] Error preparing watched movie update:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Process watched shows (100% completed episodes)
|
||||
for (const show of watchedShows) {
|
||||
try {
|
||||
if (show.show?.ids?.imdb && show.seasons) {
|
||||
|
|
@ -602,21 +618,31 @@ export function useTraktIntegration() {
|
|||
for (const season of show.seasons) {
|
||||
for (const episode of season.episodes) {
|
||||
const episodeId = `${showImdbId}:${season.number}:${episode.number}`;
|
||||
updatePromises.push(
|
||||
storageService.mergeWithTraktProgress(
|
||||
showImdbId,
|
||||
'series',
|
||||
100,
|
||||
episode.last_watched_at,
|
||||
episodeId
|
||||
)
|
||||
|
||||
await storageService.mergeWithTraktProgress(
|
||||
showImdbId,
|
||||
'series',
|
||||
100,
|
||||
episode.last_watched_at,
|
||||
episodeId
|
||||
);
|
||||
|
||||
// FIX: Mark as already synced
|
||||
await storageService.updateTraktSyncStatus(
|
||||
showImdbId,
|
||||
'series',
|
||||
true,
|
||||
100,
|
||||
episodeId
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[useTraktIntegration] Error preparing watched show update:', error);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Execute all updates in parallel
|
||||
await Promise.all(updatePromises);
|
||||
|
||||
|
|
|
|||
|
|
@ -108,30 +108,60 @@ export const useWatchProgress = (
|
|||
setWatchProgress(null);
|
||||
}
|
||||
} else {
|
||||
// FIXED: Find the most recently watched episode instead of first unfinished
|
||||
// Sort by lastUpdated timestamp (most recent first)
|
||||
const sortedProgresses = seriesProgresses.sort((a, b) =>
|
||||
b.progress.lastUpdated - a.progress.lastUpdated
|
||||
);
|
||||
|
||||
if (sortedProgresses.length > 0) {
|
||||
// Use the most recently watched episode
|
||||
const mostRecentProgress = sortedProgresses[0];
|
||||
const progress = mostRecentProgress.progress;
|
||||
|
||||
// Removed excessive logging for most recent progress
|
||||
|
||||
const COMPLETION_THRESHOLD = 85;
|
||||
const incompleteProgresses = seriesProgresses.filter(({ progress }) => {
|
||||
const progressPercent = (progress.currentTime / progress.duration) * 100;
|
||||
return progressPercent < COMPLETION_THRESHOLD;
|
||||
});
|
||||
if (incompleteProgresses.length > 0) {
|
||||
const sortedIncomplete = incompleteProgresses.sort((a, b) =>
|
||||
b.progress.lastUpdated - a.progress.lastUpdated
|
||||
);
|
||||
const mostRecentIncomplete = sortedIncomplete[0];
|
||||
setWatchProgress({
|
||||
...progress,
|
||||
episodeId: mostRecentProgress.episodeId,
|
||||
traktSynced: progress.traktSynced,
|
||||
traktProgress: progress.traktProgress
|
||||
...mostRecentIncomplete.progress,
|
||||
episodeId: mostRecentIncomplete.episodeId,
|
||||
traktSynced: mostRecentIncomplete.progress.traktSynced,
|
||||
traktProgress: mostRecentIncomplete.progress.traktProgress
|
||||
});
|
||||
} else if (seriesProgresses.length > 0) {
|
||||
const watchedEpisodeNumbers = seriesProgresses
|
||||
.map(({ episodeId }) => getEpisodeNumber(episodeId))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
if (a!.season !== b!.season) return a!.season - b!.season;
|
||||
return a!.episode - b!.episode;
|
||||
});
|
||||
if (watchedEpisodeNumbers.length > 0) {
|
||||
const lastWatched = watchedEpisodeNumbers[watchedEpisodeNumbers.length - 1]!;
|
||||
const currentEpisodes = episodesRef.current;
|
||||
|
||||
const nextEpisode = currentEpisodes.find(ep => {
|
||||
if (ep.season_number > lastWatched.season) return true;
|
||||
if (ep.season_number === lastWatched.season && ep.episode_number > lastWatched.episode) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
if (nextEpisode) {
|
||||
setWatchProgress({
|
||||
currentTime: 0,
|
||||
duration: nextEpisode.runtime * 60 || 0,
|
||||
lastUpdated: Date.now(),
|
||||
episodeId: `${id}:${nextEpisode.season_number}:${nextEpisode.episode_number}`,
|
||||
traktSynced: false,
|
||||
traktProgress: 0
|
||||
});
|
||||
} else {
|
||||
setWatchProgress(null);
|
||||
}
|
||||
} else {
|
||||
// No watched episodes found
|
||||
setWatchProgress(null);
|
||||
}
|
||||
} else {
|
||||
setWatchProgress(null);
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
// For movies
|
||||
const progress = await storageService.getWatchProgress(id, type, episodeId);
|
||||
|
|
@ -224,4 +254,4 @@ export const useWatchProgress = (
|
|||
getPlayButtonText,
|
||||
loadWatchProgress
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue