mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-04-12 20:50:22 +00:00
Merge remote-tracking branch 'upstream/main' into Mal
Resolved conflicts in: - src/components/player/AndroidVideoPlayer.tsx (Kept upstream imports) - src/navigation/AppNavigator.tsx (Merged MAL and Simkl screens/imports) - src/screens/CalendarScreen.tsx (Merged AniList source support with upstream filtering) - src/screens/LibraryScreen.tsx (Merged MAL and Simkl rendering/filters) - src/screens/SettingsScreen.tsx (Merged MAL and Simkl settings items) - src/screens/streams/useStreamsScreen.ts (Resolved streamProvider declaration) - src/services/pluginService.ts (Merged testPlugin feature with upstream safety/sandboxing) - src/services/stremioService.ts (Merged imports) - src/services/watchedService.ts (Merged MAL and Simkl sync logic)
This commit is contained in:
commit
540f364c82
134 changed files with 15178 additions and 4740 deletions
10
.gitignore
vendored
10
.gitignore
vendored
|
|
@ -85,6 +85,7 @@ mmkv.md
|
|||
fix-android-scroll-lag-summary.md
|
||||
server/cache-server
|
||||
server/campaign-manager
|
||||
server/sync-service
|
||||
carousal.md
|
||||
node_modules
|
||||
expofs.md
|
||||
|
|
@ -97,4 +98,11 @@ trakt-docss
|
|||
# Removed submodules (kept locally)
|
||||
libmpv-android/
|
||||
mpv-android/
|
||||
mpvKt/
|
||||
mpvKt/
|
||||
|
||||
# Torrent libraries
|
||||
LibTorrent/
|
||||
iTorrent/
|
||||
simkl-docss
|
||||
downloader.md
|
||||
server
|
||||
85
App.tsx
85
App.tsx
|
|
@ -11,13 +11,15 @@ import {
|
|||
StyleSheet,
|
||||
I18nManager,
|
||||
Platform,
|
||||
LogBox
|
||||
LogBox,
|
||||
Linking
|
||||
} 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';
|
||||
import { Provider as PaperProvider } from 'react-native-paper';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { enableScreens, enableFreeze } from 'react-native-screens';
|
||||
import AppNavigator, {
|
||||
CustomNavigationDarkTheme,
|
||||
|
|
@ -28,6 +30,7 @@ import 'react-native-reanimated';
|
|||
import { CatalogProvider } from './src/contexts/CatalogContext';
|
||||
import { GenreProvider } from './src/contexts/GenreContext';
|
||||
import { TraktProvider } from './src/contexts/TraktContext';
|
||||
import { SimklProvider } from './src/contexts/SimklContext';
|
||||
import { ThemeProvider, useTheme } from './src/contexts/ThemeContext';
|
||||
import { TrailerProvider } from './src/contexts/TrailerContext';
|
||||
import { DownloadsProvider } from './src/contexts/DownloadsContext';
|
||||
|
|
@ -103,6 +106,45 @@ const ThemedApp = () => {
|
|||
|
||||
// GitHub major/minor release overlay
|
||||
const githubUpdate = useGithubMajorUpdate();
|
||||
const [isDownloadingGitHub, setIsDownloadingGitHub] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
|
||||
const handleGithubUpdateAction = async () => {
|
||||
console.log('handleGithubUpdateAction triggered. Release data exists:', !!githubUpdate.releaseData);
|
||||
if (Platform.OS === 'android') {
|
||||
setIsDownloadingGitHub(true);
|
||||
setDownloadProgress(0);
|
||||
try {
|
||||
const { default: AndroidUpdateService } = await import('./src/services/androidUpdateService');
|
||||
if (githubUpdate.releaseData) {
|
||||
console.log('Calling AndroidUpdateService with:', githubUpdate.releaseData.tag_name);
|
||||
const success = await AndroidUpdateService.downloadAndInstallUpdate(
|
||||
githubUpdate.releaseData,
|
||||
(progress) => {
|
||||
setDownloadProgress(progress);
|
||||
}
|
||||
);
|
||||
console.log('AndroidUpdateService result:', success);
|
||||
if (!success) {
|
||||
console.log('Update failed, falling back to browser');
|
||||
// If download fails or no APK found, fallback to browser
|
||||
if (githubUpdate.releaseUrl) Linking.openURL(githubUpdate.releaseUrl);
|
||||
}
|
||||
} else if (githubUpdate.releaseUrl) {
|
||||
console.log('No release data, falling back to browser');
|
||||
Linking.openURL(githubUpdate.releaseUrl);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update via Android service', error);
|
||||
if (githubUpdate.releaseUrl) Linking.openURL(githubUpdate.releaseUrl);
|
||||
} finally {
|
||||
setIsDownloadingGitHub(false);
|
||||
setDownloadProgress(0);
|
||||
}
|
||||
} else {
|
||||
if (githubUpdate.releaseUrl) Linking.openURL(githubUpdate.releaseUrl);
|
||||
}
|
||||
};
|
||||
|
||||
// Check onboarding status and initialize services
|
||||
useEffect(() => {
|
||||
|
|
@ -201,6 +243,9 @@ const ThemedApp = () => {
|
|||
releaseUrl={githubUpdate.releaseUrl}
|
||||
onDismiss={githubUpdate.onDismiss}
|
||||
onLater={githubUpdate.onLater}
|
||||
onUpdateAction={handleGithubUpdateAction}
|
||||
isDownloading={isDownloadingGitHub}
|
||||
downloadProgress={downloadProgress}
|
||||
/>
|
||||
<CampaignManager />
|
||||
</View>
|
||||
|
|
@ -213,23 +258,27 @@ const ThemedApp = () => {
|
|||
|
||||
function App(): React.JSX.Element {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<BottomSheetModalProvider>
|
||||
<GenreProvider>
|
||||
<CatalogProvider>
|
||||
<TraktProvider>
|
||||
<ThemeProvider>
|
||||
<TrailerProvider>
|
||||
<ToastProvider>
|
||||
<ThemedApp />
|
||||
</ToastProvider>
|
||||
</TrailerProvider>
|
||||
</ThemeProvider>
|
||||
</TraktProvider>
|
||||
</CatalogProvider>
|
||||
</GenreProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GestureHandlerRootView>
|
||||
<SafeAreaProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<BottomSheetModalProvider>
|
||||
<GenreProvider>
|
||||
<CatalogProvider>
|
||||
<TraktProvider>
|
||||
<SimklProvider>
|
||||
<ThemeProvider>
|
||||
<TrailerProvider>
|
||||
<ToastProvider>
|
||||
<ThemedApp />
|
||||
</ToastProvider>
|
||||
</TrailerProvider>
|
||||
</ThemeProvider>
|
||||
</SimklProvider>
|
||||
</TraktProvider>
|
||||
</CatalogProvider>
|
||||
</GenreProvider>
|
||||
</BottomSheetModalProvider>
|
||||
</GestureHandlerRootView>
|
||||
</SafeAreaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
172
README.md
172
README.md
|
|
@ -1,8 +1,8 @@
|
|||
# 🎬 Nuvio Media Hub
|
||||
|
||||
<!-- PROJECT LOGO -->
|
||||
<div align="center">
|
||||
<a id="readme-top"></a>
|
||||
|
||||
<img src="assets/nuviotext.png" alt="Nuvio" width="300" />
|
||||
<br />
|
||||
<br />
|
||||
|
||||
[![Contributors][contributors-shield]][contributors-url]
|
||||
[![Forks][forks-shield]][forks-url]
|
||||
|
|
@ -10,166 +10,54 @@
|
|||
[![Issues][issues-shield]][issues-url]
|
||||
[![License][license-shield]][license-url]
|
||||
|
||||
<img src="assets/titlelogo.png" alt="Nuvio Logo" width="120" />
|
||||
<p align="center">
|
||||
A modern media hub built with React Native and Expo
|
||||
<p>
|
||||
A modern media hub built with React Native and Expo.
|
||||
<br />
|
||||
Stremio Addon ecosystem • Cross‑platform • Offline metadata & sync
|
||||
<br />
|
||||
<br />
|
||||
<a href="#getting-started"><strong>Get Started »</strong></a>
|
||||
<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>
|
||||
Stremio Addon ecosystem • Cross-platform • Offline metadata & sync
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- TABLE OF CONTENTS -->
|
||||
<details>
|
||||
<summary>Table of Contents</summary>
|
||||
## About
|
||||
|
||||
<ol>
|
||||
<li>
|
||||
<a href="#about-the-project">About The Project</a>
|
||||
</li>
|
||||
<li><a href="#installation">Installation</a></li>
|
||||
Nuvio Media Hub is a cross-platform app for managing, discovering, and streaming your media via a flexible addon ecosystem. Built with React Native and Expo.
|
||||
|
||||
<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="#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>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<!-- ABOUT THE PROJECT -->
|
||||
## About The Project
|
||||
|
||||
Nuvio Media Hub is a cross‑platform app for managing, discovering, and streaming your media via a flexible addon ecosystem. Built with React Native + Expo, it integrates providers and sync services while keeping a simple, fast UI.
|
||||
|
||||
<!-- INSTALLATION -->
|
||||
## Installation
|
||||
|
||||
### Android
|
||||
|
||||
[](https://github.com/tapframe/NuvioStreaming/releases/latest)
|
||||
|
||||
Download the latest APK from [GitHub Releases](https://github.com/tapframe/NuvioStreaming/releases/latest)
|
||||
Download the latest APK from [GitHub Releases](https://github.com/tapframe/NuvioStreaming/releases/latest).
|
||||
|
||||
### iOS
|
||||
|
||||
#### TestFlight (Recommended)
|
||||
* [TestFlight](https://testflight.apple.com/join/QkKMGRqp)
|
||||
* [AltStore](https://tinyurl.com/NuvioAltstore)
|
||||
* [SideStore](https://tinyurl.com/NuvioSidestore)
|
||||
|
||||
<img src="https://upload.wikimedia.org/wikipedia/fr/b/bc/TestFlight-icon.png" width="24" height="24" align="left" alt="TestFlight Icon"> [](https://testflight.apple.com/join/QkKMGRqp)
|
||||
**Manual source:** `https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/nuvio-source.json`
|
||||
|
||||
#### AltStore
|
||||
## Development
|
||||
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/2/20/AltStore_logo.png" width="24" height="24" align="left" alt="AltStore Logo"> [](https://tinyurl.com/NuvioAltstore)
|
||||
```bash
|
||||
git clone https://github.com/tapframe/NuvioStreaming.git
|
||||
cd NuvioStreaming
|
||||
npm install
|
||||
npx expo run:android
|
||||
# or
|
||||
npx expo run:ios
|
||||
```
|
||||
|
||||
#### SideStore
|
||||
## Legal & DMCA
|
||||
|
||||
<img src="https://github.com/SideStore/assets/blob/main/icon.png?raw=true" width="24" height="24" align="left" alt="SideStore Logo"> [](https://tinyurl.com/NuvioSidestore)
|
||||
|
||||
**Manual URL:** `https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/nuvio-source.json`
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
<!-- GETTING STARTED -->
|
||||
## Getting Started
|
||||
|
||||
Follow the steps below to run the app locally for development. For detailed setup and troubleshooting, see [Project Documentation](docs/DOCUMENTATION.md).
|
||||
|
||||
### Development Build
|
||||
|
||||
<details>
|
||||
<summary>Build from Source</summary>
|
||||
|
||||
git clone https://github.com/tapframe/NuvioStreaming.git
|
||||
cd NuvioStreaming
|
||||
npm install
|
||||
# If you hit peer dependency conflicts:
|
||||
# npm install --legacy-peer-deps
|
||||
npx expo start
|
||||
|
||||
npx expo prebuild
|
||||
npx expo run:android # Android
|
||||
npx expo run:ios # iOS
|
||||
|
||||
</details>
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions make the open‑source community amazing! Any contributions are greatly appreciated.
|
||||
|
||||
1. Fork the project
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Support
|
||||
|
||||
If you find Nuvio helpful, consider supporting development:
|
||||
|
||||
* **Ko‑Fi** – `https://ko-fi.com/tapframe`
|
||||
* **GitHub Star** – Star the repo to show support
|
||||
* **Share** – Tell others about the project
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## License
|
||||
|
||||
Distributed under the GNU GPLv3 License. See `LICENSE` for more information.
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Legal
|
||||
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.
|
||||
|
||||
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:**
|
||||
|
||||
* GitHub: `https://github.com/tapframe`
|
||||
* Issues: `https://github.com/tapframe/NuvioStreaming/issues`
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
* [React Native](https://reactnative.dev/)
|
||||
* [Expo](https://expo.dev/)
|
||||
* [TypeScript](https://www.typescriptlang.org/)
|
||||
* Community contributors and testers
|
||||
|
||||
**Disclaimer:** This application functions as a media hub with addon/plugin support. It does not contain any built‑in content or host media content. Content access is only available through user‑installed plugins and addons. Any legal concerns should be directed to the specific websites providing the content.
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
## Built With
|
||||
|
||||
<p align="left">
|
||||
<a href="https://skillicons.dev">
|
||||
<img src="https://skillicons.dev/icons?i=react,typescript,nodejs,expo,github,githubactions&theme=light&perline=6" alt="Skills Icons" />
|
||||
</a>
|
||||
<br/>
|
||||
React Native • Expo • TypeScript
|
||||
</p>
|
||||
* React Native
|
||||
* Expo
|
||||
* TypeScript
|
||||
|
||||
## Star History
|
||||
|
||||
|
|
@ -181,8 +69,6 @@ For comprehensive legal information, including our full disclaimer, third-party
|
|||
</picture>
|
||||
</a>
|
||||
|
||||
<p align="right">(<a href="#readme-top">back to top</a>)</p>
|
||||
|
||||
<!-- MARKDOWN LINKS & IMAGES -->
|
||||
[contributors-shield]: https://img.shields.io/github/contributors/tapframe/NuvioStreaming.svg?style=for-the-badge
|
||||
[contributors-url]: https://github.com/tapframe/NuvioStreaming/graphs/contributors
|
||||
|
|
|
|||
|
|
@ -95,8 +95,8 @@ android {
|
|||
applicationId 'com.nuvio.app'
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 33
|
||||
versionName "1.3.5"
|
||||
versionCode 35
|
||||
versionName "1.3.7"
|
||||
|
||||
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 = 33 // Current versionCode 33 from defaultConfig
|
||||
def baseVersionCode = 35 // Current versionCode 35 from defaultConfig
|
||||
def abiName = output.getFilter(com.android.build.OutputFile.ABI)
|
||||
|
||||
def versionCode = baseVersionCode * 100 // Base multiplier
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS"/>
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
|
|
|||
|
|
@ -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.5</string>
|
||||
<string name="expo_runtime_version">1.3.7</string>
|
||||
</resources>
|
||||
20
app.json
20
app.json
|
|
@ -2,7 +2,7 @@
|
|||
"expo": {
|
||||
"name": "Nuvio",
|
||||
"slug": "nuvio",
|
||||
"version": "1.3.5",
|
||||
"version": "1.3.7",
|
||||
"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": "33",
|
||||
"buildNumber": "35",
|
||||
"infoPlist": {
|
||||
"NSAppTransportSecurity": {
|
||||
"NSAllowsArbitraryLoads": true
|
||||
|
|
@ -37,7 +37,8 @@
|
|||
},
|
||||
"bundleIdentifier": "com.nuvio.app",
|
||||
"associatedDomains": [],
|
||||
"jsEngine": "hermes"
|
||||
"jsEngine": "hermes",
|
||||
"appleTeamId": "8QBDZ766S3"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
|
|
@ -51,7 +52,7 @@
|
|||
"android.permission.WRITE_SETTINGS"
|
||||
],
|
||||
"package": "com.nuvio.app",
|
||||
"versionCode": 33,
|
||||
"versionCode": 35,
|
||||
"architectures": [
|
||||
"arm64-v8a",
|
||||
"armeabi-v7a",
|
||||
|
|
@ -67,6 +68,7 @@
|
|||
},
|
||||
"owner": "nayifleo",
|
||||
"plugins": [
|
||||
"expo-live-activity",
|
||||
[
|
||||
"@sentry/react-native/expo",
|
||||
{
|
||||
|
|
@ -75,6 +77,12 @@
|
|||
"organization": "tapframe"
|
||||
}
|
||||
],
|
||||
[
|
||||
"@kesha-antonov/react-native-background-downloader",
|
||||
{
|
||||
"skipMmkvDependency": true
|
||||
}
|
||||
],
|
||||
"expo-localization",
|
||||
[
|
||||
"expo-updates",
|
||||
|
|
@ -97,6 +105,6 @@
|
|||
"fallbackToCacheTimeout": 30000,
|
||||
"url": "https://ota.nuvioapp.space/api/manifest"
|
||||
},
|
||||
"runtimeVersion": "1.3.5"
|
||||
"runtimeVersion": "1.3.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
assets/lottie/ranking/bronze.json
Normal file
1
assets/lottie/ranking/bronze.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/lottie/ranking/gold.json
Normal file
1
assets/lottie/ranking/gold.json
Normal file
File diff suppressed because one or more lines are too long
1
assets/lottie/ranking/silver.json
Normal file
1
assets/lottie/ranking/silver.json
Normal file
File diff suppressed because one or more lines are too long
BIN
assets/simkl-favicon.png
Normal file
BIN
assets/simkl-favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
assets/simkl-logo.png
Normal file
BIN
assets/simkl-logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
0
assets/trakt-favicon.png
Normal file
0
assets/trakt-favicon.png
Normal file
12
index.html
12
index.html
|
|
@ -995,15 +995,7 @@
|
|||
<div class="credits-logos">
|
||||
<img src="https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg"
|
||||
alt="TMDB" class="credit-logo">
|
||||
<div class="stremio-logos">
|
||||
<img src="https://www.stremio.com/website/stremio-logo-small.png" alt="Stremio"
|
||||
class="credit-logo">
|
||||
<img src="https://www.stremio.com/website/stremio-txt-logo-small.png" alt="Stremio"
|
||||
class="credit-logo">
|
||||
</div>
|
||||
<img src="https://upload.wikimedia.org/wikipedia/commons/6/69/IMDB_Logo_2016.svg" alt="IMDb"
|
||||
class="credit-logo">
|
||||
<img src="https://mdblist.com/static/mdblist_logo.png" alt="MDBList" class="credit-logo">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -1027,7 +1019,7 @@
|
|||
</a>
|
||||
</div>
|
||||
|
||||
<p class="footer-copyright">© 2025 Nuvio. GNU GPLv3. Free and Open Source.</p>
|
||||
<p class="footer-copyright">© 2026 Nuvio. GNU GPLv3. Free and Open Source.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"colors": [
|
||||
{
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "dark"
|
||||
}
|
||||
],
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances": [
|
||||
{
|
||||
"appearance": "luminosity",
|
||||
"value": "tinted"
|
||||
}
|
||||
],
|
||||
"idiom": "universal",
|
||||
"platform": "ios",
|
||||
"size": "1024x1024"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
6
ios/LiveActivity/Assets.xcassets/Contents.json
Normal file
6
ios/LiveActivity/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"colors": [
|
||||
{
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
37
ios/LiveActivity/Color+hex.swift
Normal file
37
ios/LiveActivity/Color+hex.swift
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import SwiftUI
|
||||
|
||||
extension Color {
|
||||
init(hex: String) {
|
||||
var cString: String = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
|
||||
if cString.hasPrefix("#") {
|
||||
cString.remove(at: cString.startIndex)
|
||||
}
|
||||
|
||||
if (cString.count) != 6, (cString.count) != 8 {
|
||||
self.init(.white)
|
||||
return
|
||||
}
|
||||
|
||||
var rgbValue: UInt64 = 0
|
||||
Scanner(string: cString).scanHexInt64(&rgbValue)
|
||||
|
||||
if (cString.count) == 8 {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((rgbValue >> 24) & 0xFF) / 255,
|
||||
green: Double((rgbValue >> 16) & 0xFF) / 255,
|
||||
blue: Double((rgbValue >> 08) & 0xFF) / 255,
|
||||
opacity: Double((rgbValue >> 00) & 0xFF) / 255
|
||||
)
|
||||
} else {
|
||||
self.init(
|
||||
.sRGB,
|
||||
red: Double((rgbValue >> 16) & 0xFF) / 255,
|
||||
green: Double((rgbValue >> 08) & 0xFF) / 255,
|
||||
blue: Double((rgbValue >> 00) & 0xFF) / 255,
|
||||
opacity: 1
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
7
ios/LiveActivity/Date+toTimerInterval.swift
Normal file
7
ios/LiveActivity/Date+toTimerInterval.swift
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
extension Date {
|
||||
static func toTimerInterval(miliseconds: Double) -> ClosedRange<Self> {
|
||||
now ... max(now, Date(timeIntervalSince1970: miliseconds / 1000))
|
||||
}
|
||||
}
|
||||
33
ios/LiveActivity/Image+dynamic.swift
Normal file
33
ios/LiveActivity/Image+dynamic.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
extension Image {
|
||||
static func dynamic(assetNameOrPath: String) -> Self {
|
||||
if let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: "group.expoLiveActivity.sharedData"
|
||||
) {
|
||||
let contentsOfFile = container.appendingPathComponent(assetNameOrPath).path
|
||||
|
||||
if let uiImage = UIImage(contentsOfFile: contentsOfFile) {
|
||||
return Image(uiImage: uiImage)
|
||||
}
|
||||
}
|
||||
|
||||
return Image(assetNameOrPath)
|
||||
}
|
||||
}
|
||||
|
||||
extension UIImage {
|
||||
/// Attempts to load a UIImage either from the shared app group container or the main bundle.
|
||||
static func dynamic(assetNameOrPath: String) -> UIImage? {
|
||||
if let container = FileManager.default.containerURL(
|
||||
forSecurityApplicationGroupIdentifier: "group.expoLiveActivity.sharedData"
|
||||
) {
|
||||
let contentsOfFile = container.appendingPathComponent(assetNameOrPath).path
|
||||
if let uiImage = UIImage(contentsOfFile: contentsOfFile) {
|
||||
return uiImage
|
||||
}
|
||||
}
|
||||
return UIImage(named: assetNameOrPath)
|
||||
}
|
||||
}
|
||||
13
ios/LiveActivity/Info.plist
Normal file
13
ios/LiveActivity/Info.plist
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.widgetkit-extension</string>
|
||||
</dict>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
5
ios/LiveActivity/LiveActivity.entitlements
Normal file
5
ios/LiveActivity/LiveActivity.entitlements
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
247
ios/LiveActivity/LiveActivityView.swift
Normal file
247
ios/LiveActivity/LiveActivityView.swift
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
#if canImport(ActivityKit)
|
||||
|
||||
struct ConditionalForegroundViewModifier: ViewModifier {
|
||||
let color: String?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if let color = color {
|
||||
content.foregroundStyle(Color(hex: color))
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DebugLog: View {
|
||||
#if DEBUG
|
||||
private let message: String
|
||||
init(_ message: String) {
|
||||
self.message = message
|
||||
print(message)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Text(message)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
#else
|
||||
init(_: String) {}
|
||||
var body: some View { EmptyView() }
|
||||
#endif
|
||||
}
|
||||
|
||||
struct LiveActivityView: View {
|
||||
let contentState: LiveActivityAttributes.ContentState
|
||||
let attributes: LiveActivityAttributes
|
||||
@State private var imageContainerSize: CGSize?
|
||||
|
||||
var progressViewTint: Color? {
|
||||
attributes.progressViewTint.map { Color(hex: $0) }
|
||||
}
|
||||
|
||||
private var imageAlignment: Alignment {
|
||||
switch attributes.imageAlign {
|
||||
case "center":
|
||||
return .center
|
||||
case "bottom":
|
||||
return .bottom
|
||||
default:
|
||||
return .top
|
||||
}
|
||||
}
|
||||
|
||||
private func alignedImage(imageName: String) -> some View {
|
||||
let defaultHeight: CGFloat = 64
|
||||
let defaultWidth: CGFloat = 64
|
||||
let containerHeight = imageContainerSize?.height
|
||||
let containerWidth = imageContainerSize?.width
|
||||
let hasWidthConstraint = (attributes.imageWidthPercent != nil) || (attributes.imageWidth != nil)
|
||||
|
||||
let computedHeight: CGFloat? = {
|
||||
if let percent = attributes.imageHeightPercent {
|
||||
let clamped = min(max(percent, 0), 100) / 100.0
|
||||
// Use the row height as a base. Fallback to default when row height is not measured yet.
|
||||
let base = (containerHeight ?? defaultHeight)
|
||||
return base * clamped
|
||||
} else if let size = attributes.imageHeight {
|
||||
return CGFloat(size)
|
||||
} else if hasWidthConstraint {
|
||||
// Mimic CSS: when only width is set, keep height automatic to preserve aspect ratio
|
||||
return nil
|
||||
} else {
|
||||
// Mimic CSS: this works against CSS but provides a better default behavior.
|
||||
// When no width/height is set, use a default size (64pt)
|
||||
// Width will adjust automatically base on aspect ratio
|
||||
return defaultHeight
|
||||
}
|
||||
}()
|
||||
|
||||
let computedWidth: CGFloat? = {
|
||||
if let percent = attributes.imageWidthPercent {
|
||||
let clamped = min(max(percent, 0), 100) / 100.0
|
||||
let base = (containerWidth ?? defaultWidth)
|
||||
return base * clamped
|
||||
} else if let size = attributes.imageWidth {
|
||||
return CGFloat(size)
|
||||
} else {
|
||||
return nil // Keep aspect fit based on height
|
||||
}
|
||||
}()
|
||||
|
||||
return ZStack(alignment: .center) {
|
||||
Group {
|
||||
let fit = attributes.contentFit ?? "cover"
|
||||
switch fit {
|
||||
case "contain":
|
||||
Image.dynamic(assetNameOrPath: imageName).resizable().scaledToFit().frame(width: computedWidth, height: computedHeight)
|
||||
case "fill":
|
||||
Image.dynamic(assetNameOrPath: imageName).resizable().frame(
|
||||
width: computedWidth,
|
||||
height: computedHeight
|
||||
)
|
||||
case "none":
|
||||
Image.dynamic(assetNameOrPath: imageName).renderingMode(.original).frame(width: computedWidth, height: computedHeight)
|
||||
case "scale-down":
|
||||
if let uiImage = UIImage.dynamic(assetNameOrPath: imageName) {
|
||||
// Determine the target box. When width/height are nil, we use image's intrinsic dimension for comparison.
|
||||
let targetHeight = computedHeight ?? uiImage.size.height
|
||||
let targetWidth = computedWidth ?? uiImage.size.width
|
||||
let shouldScaleDown = uiImage.size.height > targetHeight || uiImage.size.width > targetWidth
|
||||
|
||||
if shouldScaleDown {
|
||||
Image(uiImage: uiImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: computedWidth, height: computedHeight)
|
||||
} else {
|
||||
Image(uiImage: uiImage)
|
||||
.renderingMode(.original)
|
||||
.frame(width: min(uiImage.size.width, targetWidth), height: min(uiImage.size.height, targetHeight))
|
||||
}
|
||||
} else {
|
||||
DebugLog("⚠️[ExpoLiveActivity] assetNameOrPath couldn't resolve to UIImage")
|
||||
}
|
||||
case "cover":
|
||||
Image.dynamic(assetNameOrPath: imageName).resizable().scaledToFill().frame(
|
||||
width: computedWidth,
|
||||
height: computedHeight
|
||||
).clipped()
|
||||
default:
|
||||
DebugLog("⚠️[ExpoLiveActivity] Unknown contentFit '\(fit)'")
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: imageAlignment)
|
||||
.background(
|
||||
GeometryReader { proxy in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
let s = proxy.size
|
||||
if s.width > 0, s.height > 0 { imageContainerSize = s }
|
||||
}
|
||||
.onChange(of: proxy.size) { s in
|
||||
if s.width > 0, s.height > 0 { imageContainerSize = s }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let defaultPadding = 24
|
||||
|
||||
let top = CGFloat(
|
||||
attributes.paddingDetails?.top
|
||||
?? attributes.paddingDetails?.vertical
|
||||
?? attributes.padding
|
||||
?? defaultPadding
|
||||
)
|
||||
|
||||
let bottom = CGFloat(
|
||||
attributes.paddingDetails?.bottom
|
||||
?? attributes.paddingDetails?.vertical
|
||||
?? attributes.padding
|
||||
?? defaultPadding
|
||||
)
|
||||
|
||||
let leading = CGFloat(
|
||||
attributes.paddingDetails?.left
|
||||
?? attributes.paddingDetails?.horizontal
|
||||
?? attributes.padding
|
||||
?? defaultPadding
|
||||
)
|
||||
|
||||
let trailing = CGFloat(
|
||||
attributes.paddingDetails?.right
|
||||
?? attributes.paddingDetails?.horizontal
|
||||
?? attributes.padding
|
||||
?? defaultPadding
|
||||
)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
let position = attributes.imagePosition ?? "right"
|
||||
let isStretch = position.contains("Stretch")
|
||||
let isLeftImage = position.hasPrefix("left")
|
||||
let hasImage = contentState.imageName != nil
|
||||
let effectiveStretch = isStretch && hasImage
|
||||
|
||||
HStack(alignment: .center) {
|
||||
if hasImage, isLeftImage {
|
||||
if let imageName = contentState.imageName {
|
||||
alignedImage(imageName: imageName)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(contentState.title)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.modifier(ConditionalForegroundViewModifier(color: attributes.titleColor))
|
||||
|
||||
if let subtitle = contentState.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.title3)
|
||||
.modifier(ConditionalForegroundViewModifier(color: attributes.subtitleColor))
|
||||
}
|
||||
|
||||
if effectiveStretch {
|
||||
if let date = contentState.timerEndDateInMilliseconds {
|
||||
ProgressView(timerInterval: Date.toTimerInterval(miliseconds: date))
|
||||
.tint(progressViewTint)
|
||||
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
||||
} else if let progress = contentState.progress {
|
||||
ProgressView(value: progress)
|
||||
.tint(progressViewTint)
|
||||
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
||||
}
|
||||
}
|
||||
}.layoutPriority(1)
|
||||
|
||||
if hasImage, !isLeftImage { // right side (default)
|
||||
Spacer()
|
||||
if let imageName = contentState.imageName {
|
||||
alignedImage(imageName: imageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !effectiveStretch {
|
||||
if let date = contentState.timerEndDateInMilliseconds {
|
||||
ProgressView(timerInterval: Date.toTimerInterval(miliseconds: date))
|
||||
.tint(progressViewTint)
|
||||
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
||||
} else if let progress = contentState.progress {
|
||||
ProgressView(value: progress)
|
||||
.tint(progressViewTint)
|
||||
.modifier(ConditionalForegroundViewModifier(color: attributes.progressViewLabelColor))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(EdgeInsets(top: top, leading: leading, bottom: bottom, trailing: trailing))
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
169
ios/LiveActivity/LiveActivityWidget.swift
Normal file
169
ios/LiveActivity/LiveActivityWidget.swift
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import ActivityKit
|
||||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
struct LiveActivityAttributes: ActivityAttributes {
|
||||
struct ContentState: Codable, Hashable {
|
||||
var title: String
|
||||
var subtitle: String?
|
||||
var timerEndDateInMilliseconds: Double?
|
||||
var progress: Double?
|
||||
var imageName: String?
|
||||
var dynamicIslandImageName: String?
|
||||
}
|
||||
|
||||
var name: String
|
||||
var backgroundColor: String?
|
||||
var titleColor: String?
|
||||
var subtitleColor: String?
|
||||
var progressViewTint: String?
|
||||
var progressViewLabelColor: String?
|
||||
var deepLinkUrl: String?
|
||||
var timerType: DynamicIslandTimerType?
|
||||
var padding: Int?
|
||||
var paddingDetails: PaddingDetails?
|
||||
var imagePosition: String?
|
||||
var imageWidth: Int?
|
||||
var imageHeight: Int?
|
||||
var imageWidthPercent: Double?
|
||||
var imageHeightPercent: Double?
|
||||
var imageAlign: String?
|
||||
var contentFit: String?
|
||||
|
||||
enum DynamicIslandTimerType: String, Codable {
|
||||
case circular
|
||||
case digital
|
||||
}
|
||||
|
||||
struct PaddingDetails: Codable, Hashable {
|
||||
var top: Int?
|
||||
var bottom: Int?
|
||||
var left: Int?
|
||||
var right: Int?
|
||||
var vertical: Int?
|
||||
var horizontal: Int?
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveActivityWidget: Widget {
|
||||
var body: some WidgetConfiguration {
|
||||
ActivityConfiguration(for: LiveActivityAttributes.self) { context in
|
||||
LiveActivityView(contentState: context.state, attributes: context.attributes)
|
||||
.activityBackgroundTint(
|
||||
context.attributes.backgroundColor.map { Color(hex: $0) }
|
||||
)
|
||||
.activitySystemActionForegroundColor(Color.black)
|
||||
.applyWidgetURL(from: context.attributes.deepLinkUrl)
|
||||
} dynamicIsland: { context in
|
||||
DynamicIsland {
|
||||
DynamicIslandExpandedRegion(.leading, priority: 1) {
|
||||
dynamicIslandExpandedLeading(title: context.state.title, subtitle: context.state.subtitle)
|
||||
.dynamicIsland(verticalPlacement: .belowIfTooWide)
|
||||
.padding(.leading, 5)
|
||||
.applyWidgetURL(from: context.attributes.deepLinkUrl)
|
||||
}
|
||||
DynamicIslandExpandedRegion(.trailing) {
|
||||
if let imageName = context.state.imageName {
|
||||
dynamicIslandExpandedTrailing(imageName: imageName)
|
||||
.padding(.trailing, 5)
|
||||
.applyWidgetURL(from: context.attributes.deepLinkUrl)
|
||||
}
|
||||
}
|
||||
DynamicIslandExpandedRegion(.bottom) {
|
||||
if let date = context.state.timerEndDateInMilliseconds {
|
||||
dynamicIslandExpandedBottom(
|
||||
endDate: date, progressViewTint: context.attributes.progressViewTint
|
||||
)
|
||||
.padding(.horizontal, 5)
|
||||
.applyWidgetURL(from: context.attributes.deepLinkUrl)
|
||||
}
|
||||
}
|
||||
} compactLeading: {
|
||||
if let dynamicIslandImageName = context.state.dynamicIslandImageName {
|
||||
resizableImage(imageName: dynamicIslandImageName)
|
||||
.frame(maxWidth: 23, maxHeight: 23)
|
||||
.applyWidgetURL(from: context.attributes.deepLinkUrl)
|
||||
}
|
||||
} compactTrailing: {
|
||||
if let date = context.state.timerEndDateInMilliseconds {
|
||||
compactTimer(
|
||||
endDate: date,
|
||||
timerType: context.attributes.timerType ?? .circular,
|
||||
progressViewTint: context.attributes.progressViewTint
|
||||
).applyWidgetURL(from: context.attributes.deepLinkUrl)
|
||||
}
|
||||
} minimal: {
|
||||
if let date = context.state.timerEndDateInMilliseconds {
|
||||
compactTimer(
|
||||
endDate: date,
|
||||
timerType: context.attributes.timerType ?? .circular,
|
||||
progressViewTint: context.attributes.progressViewTint
|
||||
).applyWidgetURL(from: context.attributes.deepLinkUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func compactTimer(
|
||||
endDate: Double,
|
||||
timerType: LiveActivityAttributes.DynamicIslandTimerType,
|
||||
progressViewTint: String?
|
||||
) -> some View {
|
||||
if timerType == .digital {
|
||||
Text(timerInterval: Date.toTimerInterval(miliseconds: endDate))
|
||||
.font(.system(size: 15))
|
||||
.minimumScaleFactor(0.8)
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: 60)
|
||||
.multilineTextAlignment(.trailing)
|
||||
} else {
|
||||
circularTimer(endDate: endDate)
|
||||
.tint(progressViewTint.map { Color(hex: $0) })
|
||||
}
|
||||
}
|
||||
|
||||
private func dynamicIslandExpandedLeading(title: String, subtitle: String?) -> some View {
|
||||
VStack(alignment: .leading) {
|
||||
Spacer()
|
||||
Text(title)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.white)
|
||||
.fontWeight(.semibold)
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.font(.title3)
|
||||
.minimumScaleFactor(0.8)
|
||||
.foregroundStyle(.white.opacity(0.75))
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func dynamicIslandExpandedTrailing(imageName: String) -> some View {
|
||||
VStack {
|
||||
Spacer()
|
||||
resizableImage(imageName: imageName)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
private func dynamicIslandExpandedBottom(endDate: Double, progressViewTint: String?) -> some View {
|
||||
ProgressView(timerInterval: Date.toTimerInterval(miliseconds: endDate))
|
||||
.foregroundStyle(.white)
|
||||
.tint(progressViewTint.map { Color(hex: $0) })
|
||||
.padding(.top, 5)
|
||||
}
|
||||
|
||||
private func circularTimer(endDate: Double) -> some View {
|
||||
ProgressView(
|
||||
timerInterval: Date.toTimerInterval(miliseconds: endDate),
|
||||
countsDown: false,
|
||||
label: { EmptyView() },
|
||||
currentValueLabel: {
|
||||
EmptyView()
|
||||
}
|
||||
)
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
9
ios/LiveActivity/LiveActivityWidgetBundle.swift
Normal file
9
ios/LiveActivity/LiveActivityWidgetBundle.swift
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import SwiftUI
|
||||
import WidgetKit
|
||||
|
||||
@main
|
||||
struct LiveActivityWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
LiveActivityWidget()
|
||||
}
|
||||
}
|
||||
12
ios/LiveActivity/View+applyIfPresent.swift
Normal file
12
ios/LiveActivity/View+applyIfPresent.swift
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func applyIfPresent<T>(_ value: T?, transform: (Self, T) -> some View) -> some View {
|
||||
if let value {
|
||||
transform(self, value)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
24
ios/LiveActivity/View+applyWidgetURL.swift
Normal file
24
ios/LiveActivity/View+applyWidgetURL.swift
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import SwiftUI
|
||||
|
||||
private let cachedScheme: String? = {
|
||||
guard
|
||||
let urlTypes = Bundle.main.infoDictionary?["CFBundleURLTypes"] as? [[String: Any]],
|
||||
let schemes = urlTypes.first?["CFBundleURLSchemes"] as? [String],
|
||||
let firstScheme = schemes.first
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return firstScheme
|
||||
}()
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func applyWidgetURL(from urlString: String?) -> some View {
|
||||
applyIfPresent(urlString) { view, string in
|
||||
applyIfPresent(cachedScheme) { view, scheme in
|
||||
view.widgetURL(URL(string: scheme + "://" + string))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
ios/LiveActivity/ViewHelpers.swift
Normal file
33
ios/LiveActivity/ViewHelpers.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import SwiftUI
|
||||
|
||||
func resizableImage(imageName: String) -> some View {
|
||||
Image.dynamic(assetNameOrPath: imageName)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
|
||||
func resizableImage(imageName: String, height: CGFloat?, width: CGFloat?) -> some View {
|
||||
resizableImage(imageName: imageName)
|
||||
.frame(width: width, height: height)
|
||||
}
|
||||
|
||||
private struct ContainerSizeKey: PreferenceKey {
|
||||
static var defaultValue: CGSize?
|
||||
static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) {
|
||||
value = nextValue() ?? value
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func captureContainerSize() -> some View {
|
||||
background(
|
||||
GeometryReader { proxy in
|
||||
Color.clear.preference(key: ContainerSizeKey.self, value: proxy.size)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
func onContainerSize(_ perform: @escaping (CGSize?) -> Void) -> some View {
|
||||
onPreferenceChange(ContainerSizeKey.self, perform: perform)
|
||||
}
|
||||
}
|
||||
|
|
@ -11,32 +11,121 @@
|
|||
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 */; };
|
||||
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 */; };
|
||||
730F1CDE2F24B27100EF7E51 /* Color+hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8034143A77A946B5A793F967 /* Color+hex.swift */; };
|
||||
730F1CDF2F24B27100EF7E51 /* Date+toTimerInterval.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A48D8A298DD48928E8D0A02 /* Date+toTimerInterval.swift */; };
|
||||
730F1CE02F24B27100EF7E51 /* Image+dynamic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26957CDD392E4E9390811D0D /* Image+dynamic.swift */; };
|
||||
730F1CE12F24B27100EF7E51 /* LiveActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F448294A36E433E924078C1 /* LiveActivityView.swift */; };
|
||||
730F1CE22F24B27100EF7E51 /* LiveActivityWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD48662BB71E4C9C9E340289 /* LiveActivityWidget.swift */; };
|
||||
730F1CE32F24B27100EF7E51 /* LiveActivityWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A83D742B36224176A0AB3B25 /* LiveActivityWidgetBundle.swift */; };
|
||||
730F1CE42F24B27100EF7E51 /* View+applyIfPresent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 324373F393774A9CA40DE22E /* View+applyIfPresent.swift */; };
|
||||
730F1CE52F24B27100EF7E51 /* View+applyWidgetURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 373D1473F5A74CBC9DBD108B /* View+applyWidgetURL.swift */; };
|
||||
730F1CE62F24B27100EF7E51 /* ViewHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3396D68881EF486E99FD480A /* ViewHelpers.swift */; };
|
||||
730F1CE72F24B27100EF7E51 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0F1D0037D1F24E60BDB57628 /* Assets.xcassets */; };
|
||||
9FBA88F42E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F32E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift */; };
|
||||
9FBA88F52E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F12E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift */; };
|
||||
9FBA88F62E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F02E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m */; };
|
||||
9FBA88F72E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FBA88F22E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift */; };
|
||||
A0892AA96024D9EF7CA87A8A /* libPods-Nuvio.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 349BFD3B214640DED8541999 /* libPods-Nuvio.a */; };
|
||||
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 */; };
|
||||
F285A1620F5847BA863124AF /* LiveActivity.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EF8716173E0148BD82B233B7 /* LiveActivity.appex */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
55A0DD628D7F4F4F88B4A001 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 0EA489F2BF6143F1BA7B8485;
|
||||
remoteInfo = LiveActivity;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
13CD9594FB5C4FE4A6794089 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
3447F08B99D9427E99FEE18E /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
F285A1620F5847BA863124AF /* LiveActivity.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
571AD3FB23F14FC7BE6A1E44 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
BDCAC5D772944755921F3BCF /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
F1058FE7710A45FABC0689A7 /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0DFF64A670930CED5EA4DF3A /* 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>"; };
|
||||
0E13CE4BDE2F4555806AE753 /* Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
0F1D0037D1F24E60BDB57628 /* Assets.xcassets */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
26957CDD392E4E9390811D0D /* Image+dynamic.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = "Image+dynamic.swift"; sourceTree = "<group>"; };
|
||||
2DE29A8A87D24662BEFFF849 /* LiveActivity.entitlements */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 9; includeInIndex = 0; lastKnownFileType = unknown; path = LiveActivity.entitlements; sourceTree = "<group>"; };
|
||||
2F448294A36E433E924078C1 /* LiveActivityView.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = LiveActivityView.swift; sourceTree = "<group>"; };
|
||||
324373F393774A9CA40DE22E /* View+applyIfPresent.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = "View+applyIfPresent.swift"; sourceTree = "<group>"; };
|
||||
3396D68881EF486E99FD480A /* ViewHelpers.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = ViewHelpers.swift; sourceTree = "<group>"; };
|
||||
349BFD3B214640DED8541999 /* libPods-Nuvio.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Nuvio.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
373D1473F5A74CBC9DBD108B /* View+applyWidgetURL.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = "View+applyWidgetURL.swift"; sourceTree = "<group>"; };
|
||||
3A48D8A298DD48928E8D0A02 /* Date+toTimerInterval.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = "Date+toTimerInterval.swift"; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
730F1CE82F24B29C00EF7E51 /* LiveActivityDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = LiveActivityDebug.entitlements; sourceTree = "<group>"; };
|
||||
73BB213C2E9EEAC700EC03F8 /* NuvioRelease.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = NuvioRelease.entitlements; path = Nuvio/NuvioRelease.entitlements; 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>"; };
|
||||
8034143A77A946B5A793F967 /* Color+hex.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = "Color+hex.swift"; sourceTree = "<group>"; };
|
||||
9FBA88F02E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ../KSPlayer/RNBridge/KSPlayerManager.m; sourceTree = "<group>"; };
|
||||
9FBA88F12E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerModule.swift; sourceTree = "<group>"; };
|
||||
9FBA88F22E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerView.swift; sourceTree = "<group>"; };
|
||||
9FBA88F32E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ../KSPlayer/RNBridge/KSPlayerViewManager.swift; sourceTree = "<group>"; };
|
||||
A83D742B36224176A0AB3B25 /* LiveActivityWidgetBundle.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = LiveActivityWidgetBundle.swift; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Nuvio/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
AD48662BB71E4C9C9E340289 /* LiveActivityWidget.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; path = LiveActivityWidget.swift; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
DAD634845937EAF8D64F20FC /* 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>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
EF8716173E0148BD82B233B7 /* LiveActivity.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; fileEncoding = 9; includeInIndex = 0; path = LiveActivity.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = Nuvio/AppDelegate.swift; sourceTree = "<group>"; };
|
||||
F11748442D0722820044C1D9 /* Nuvio-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = "Nuvio-Bridging-Header.h"; path = "Nuvio/Nuvio-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
|
@ -46,7 +135,14 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
D66ACCC72CB69F1FF14A2585 /* libPods-Nuvio.a in Frameworks */,
|
||||
A0892AA96024D9EF7CA87A8A /* libPods-Nuvio.a in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
C105694FF46449959CE16947 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
|
|
@ -59,10 +155,10 @@
|
|||
73BB213C2E9EEAC700EC03F8 /* NuvioRelease.entitlements */,
|
||||
F11748412D0307B40044C1D9 /* AppDelegate.swift */,
|
||||
F11748442D0722820044C1D9 /* Nuvio-Bridging-Header.h */,
|
||||
9FBA88F02E86ECD700892850 /* KSPlayerManager.m */,
|
||||
9FBA88F12E86ECD700892850 /* KSPlayerModule.swift */,
|
||||
9FBA88F22E86ECD700892850 /* KSPlayerView.swift */,
|
||||
9FBA88F32E86ECD700892850 /* KSPlayerViewManager.swift */,
|
||||
9FBA88F02E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m */,
|
||||
9FBA88F12E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift */,
|
||||
9FBA88F22E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift */,
|
||||
9FBA88F32E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift */,
|
||||
BB2F792B24A3F905000567C9 /* Supporting */,
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */,
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
|
|
@ -76,7 +172,7 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||
436A6FCA2C83F29076E121BA /* libPods-Nuvio.a */,
|
||||
349BFD3B214640DED8541999 /* libPods-Nuvio.a */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -89,6 +185,13 @@
|
|||
name = ExpoModulesProviders;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
62B088ADB2A740DAB9E343F9 /* LiveActivity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = LiveActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
832341AE1AAA6A7D00B99B32 /* Libraries */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -105,6 +208,11 @@
|
|||
2D16E6871FA4F8E400B85C8A /* Frameworks */,
|
||||
D90A3959C97EE9926C513293 /* Pods */,
|
||||
358C5C99C443A921C8EEDDC8 /* ExpoModulesProviders */,
|
||||
E8C72B3DF7DB40A8896F56C9 /* LiveActivity */,
|
||||
62B088ADB2A740DAB9E343F9 /* LiveActivity */,
|
||||
B9F3EB198DED443D980ADFB3 /* LiveActivity */,
|
||||
C05E525650E143FB85ED7622 /* LiveActivity */,
|
||||
D05210A39FF14E649D77F8A8 /* LiveActivity */,
|
||||
);
|
||||
indentWidth = 2;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -115,10 +223,18 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
13B07F961A680F5B00A75B9A /* Nuvio.app */,
|
||||
EF8716173E0148BD82B233B7 /* LiveActivity.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
B9F3EB198DED443D980ADFB3 /* LiveActivity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = LiveActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
BB2F792B24A3F905000567C9 /* Supporting */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -128,15 +244,49 @@
|
|||
path = Nuvio/Supporting;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C05E525650E143FB85ED7622 /* LiveActivity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
);
|
||||
path = LiveActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D05210A39FF14E649D77F8A8 /* LiveActivity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8034143A77A946B5A793F967 /* Color+hex.swift */,
|
||||
3A48D8A298DD48928E8D0A02 /* Date+toTimerInterval.swift */,
|
||||
26957CDD392E4E9390811D0D /* Image+dynamic.swift */,
|
||||
2F448294A36E433E924078C1 /* LiveActivityView.swift */,
|
||||
AD48662BB71E4C9C9E340289 /* LiveActivityWidget.swift */,
|
||||
A83D742B36224176A0AB3B25 /* LiveActivityWidgetBundle.swift */,
|
||||
324373F393774A9CA40DE22E /* View+applyIfPresent.swift */,
|
||||
373D1473F5A74CBC9DBD108B /* View+applyWidgetURL.swift */,
|
||||
3396D68881EF486E99FD480A /* ViewHelpers.swift */,
|
||||
0E13CE4BDE2F4555806AE753 /* Info.plist */,
|
||||
0F1D0037D1F24E60BDB57628 /* Assets.xcassets */,
|
||||
2DE29A8A87D24662BEFFF849 /* LiveActivity.entitlements */,
|
||||
);
|
||||
path = LiveActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
D90A3959C97EE9926C513293 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */,
|
||||
5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */,
|
||||
DAD634845937EAF8D64F20FC /* Pods-Nuvio.debug.xcconfig */,
|
||||
0DFF64A670930CED5EA4DF3A /* Pods-Nuvio.release.xcconfig */,
|
||||
);
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E8C72B3DF7DB40A8896F56C9 /* LiveActivity */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
730F1CE82F24B29C00EF7E51 /* LiveActivityDebug.entitlements */,
|
||||
);
|
||||
path = LiveActivity;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
ECB31D9B6FF08C7E8E875650 /* Nuvio */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -148,23 +298,46 @@
|
|||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
0EA489F2BF6143F1BA7B8485 /* LiveActivity */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = C95083D445BA485B82D2FFBC /* Build configuration list for PBXNativeTarget "LiveActivity" */;
|
||||
buildPhases = (
|
||||
6E9A0429F8E74948A82DEFF5 /* Sources */,
|
||||
C105694FF46449959CE16947 /* Frameworks */,
|
||||
1E668E0B92C34E73AECDBE1A /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = LiveActivity;
|
||||
productName = LiveActivity;
|
||||
productReference = EF8716173E0148BD82B233B7 /* LiveActivity.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
13B07F861A680F5B00A75B9A /* Nuvio */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Nuvio" */;
|
||||
buildPhases = (
|
||||
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */,
|
||||
13C7A3175A582B3D4E9F198E /* [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 */,
|
||||
9F740EE07B5F97C85979C145 /* [CP] Embed Pods Frameworks */,
|
||||
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */,
|
||||
E043D7E00F2210228303FC0B /* [CP] Embed Pods Frameworks */,
|
||||
7F1DFB9D902E2DBC35F3FB84 /* [CP] Copy Pods Resources */,
|
||||
3447F08B99D9427E99FEE18E /* Embed Foundation Extensions */,
|
||||
BDCAC5D772944755921F3BCF /* Embed Foundation Extensions */,
|
||||
571AD3FB23F14FC7BE6A1E44 /* Embed Foundation Extensions */,
|
||||
13CD9594FB5C4FE4A6794089 /* Embed Foundation Extensions */,
|
||||
F1058FE7710A45FABC0689A7 /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
8410CAE82E604DD1A187EDA2 /* PBXTargetDependency */,
|
||||
);
|
||||
name = Nuvio;
|
||||
productName = Nuvio;
|
||||
|
|
@ -179,8 +352,15 @@
|
|||
attributes = {
|
||||
LastUpgradeCheck = 1130;
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
0EA489F2BF6143F1BA7B8485 = {
|
||||
DevelopmentTeam = 8QBDZ766S3;
|
||||
LastSwiftMigration = 1250;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
DevelopmentTeam = 8QBDZ766S3;
|
||||
LastSwiftMigration = 1250;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
@ -198,6 +378,7 @@
|
|||
projectRoot = "";
|
||||
targets = (
|
||||
13B07F861A680F5B00A75B9A /* Nuvio */,
|
||||
0EA489F2BF6143F1BA7B8485 /* LiveActivity */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
|
@ -214,6 +395,14 @@
|
|||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
1E668E0B92C34E73AECDBE1A /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
730F1CE72F24B27100EF7E51 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
|
|
@ -234,7 +423,7 @@
|
|||
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";
|
||||
};
|
||||
3B2D9C1D63379C2F30AC0F2B /* [CP] Check Pods Manifest.lock */ = {
|
||||
13C7A3175A582B3D4E9F198E /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -256,7 +445,7 @@
|
|||
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;
|
||||
};
|
||||
550CD54859274FE505BA4957 /* [CP] Copy Pods Resources */ = {
|
||||
7F1DFB9D902E2DBC35F3FB84 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -406,7 +595,7 @@
|
|||
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 */ = {
|
||||
E043D7E00F2210228303FC0B /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
|
|
@ -436,24 +625,50 @@
|
|||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F11748422D0307B40044C1D9 /* AppDelegate.swift in Sources */,
|
||||
9FBA88F42E86ECD700892850 /* KSPlayerViewManager.swift in Sources */,
|
||||
9FBA88F52E86ECD700892850 /* KSPlayerModule.swift in Sources */,
|
||||
9FBA88F62E86ECD700892850 /* KSPlayerManager.m in Sources */,
|
||||
9FBA88F72E86ECD700892850 /* KSPlayerView.swift in Sources */,
|
||||
9FBA88F42E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerViewManager.swift in Sources */,
|
||||
9FBA88F52E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerModule.swift in Sources */,
|
||||
9FBA88F62E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerManager.m in Sources */,
|
||||
9FBA88F72E86ECD700892850 /* ../KSPlayer/RNBridge/KSPlayerView.swift in Sources */,
|
||||
2AA769395C1242F225F875AF /* ExpoModulesProvider.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
6E9A0429F8E74948A82DEFF5 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
730F1CDE2F24B27100EF7E51 /* Color+hex.swift in Sources */,
|
||||
730F1CDF2F24B27100EF7E51 /* Date+toTimerInterval.swift in Sources */,
|
||||
730F1CE02F24B27100EF7E51 /* Image+dynamic.swift in Sources */,
|
||||
730F1CE12F24B27100EF7E51 /* LiveActivityView.swift in Sources */,
|
||||
730F1CE22F24B27100EF7E51 /* LiveActivityWidget.swift in Sources */,
|
||||
730F1CE32F24B27100EF7E51 /* LiveActivityWidgetBundle.swift in Sources */,
|
||||
730F1CE42F24B27100EF7E51 /* View+applyIfPresent.swift in Sources */,
|
||||
730F1CE52F24B27100EF7E51 /* View+applyWidgetURL.swift in Sources */,
|
||||
730F1CE62F24B27100EF7E51 /* ViewHelpers.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
8410CAE82E604DD1A187EDA2 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 0EA489F2BF6143F1BA7B8485 /* LiveActivity */;
|
||||
targetProxy = 55A0DD628D7F4F4F88B4A001 /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
13B07F941A680F5B00A75B9A /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 904B4A0A0308D3727268BA5E /* Pods-Nuvio.debug.xcconfig */;
|
||||
baseConfigurationReference = DAD634845937EAF8D64F20FC /* Pods-Nuvio.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Nuvio/Nuvio.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
ENABLE_BITCODE = NO;
|
||||
|
|
@ -487,11 +702,13 @@
|
|||
};
|
||||
13B07F951A680F5B00A75B9A /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 5346BAA9EF8C9C8182D4485C /* Pods-Nuvio.release.xcconfig */;
|
||||
baseConfigurationReference = 0DFF64A670930CED5EA4DF3A /* Pods-Nuvio.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Nuvio/NuvioRelease.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
INFOPLIST_FILE = Nuvio/Info.plist;
|
||||
|
|
@ -517,6 +734,30 @@
|
|||
};
|
||||
name = Release;
|
||||
};
|
||||
3DCEA1FBF99E46F58A7150CC /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = LiveActivity/LiveActivity.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = LiveActivity/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
|
||||
MARKETING_VERSION = 1.3.6;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub.LiveActivity;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
83CBBA201A601CBA00E9B192 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
|
|
@ -635,6 +876,30 @@
|
|||
};
|
||||
name = Release;
|
||||
};
|
||||
E4108F64486C48E192EAA45D /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
APPLICATION_EXTENSION_API_ONLY = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = LiveActivity/LiveActivity.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 34;
|
||||
DEVELOPMENT_TEAM = 8QBDZ766S3;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = LiveActivity/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = LiveActivity;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
|
||||
MARKETING_VERSION = 1.3.6;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub.LiveActivity;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
|
|
@ -656,6 +921,15 @@
|
|||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
C95083D445BA485B82D2FFBC /* Build configuration list for PBXNativeTarget "LiveActivity" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
3DCEA1FBF99E46F58A7150CC /* Debug */,
|
||||
E4108F64486C48E192EAA45D /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
|
||||
|
|
|
|||
|
|
@ -84,4 +84,13 @@ class ReactNativeDelegate: ExpoReactNativeFactoryDelegate {
|
|||
return Bundle.main.url(forResource: "main", withExtension: "jsbundle")
|
||||
#endif
|
||||
}
|
||||
|
||||
func application(
|
||||
_ application: UIApplication,
|
||||
handleEventsForBackgroundURLSession identifier: String,
|
||||
completionHandler: @escaping () -> Void
|
||||
) {
|
||||
RNBackgroundDownloader.setCompletionHandlerWithIdentifier(identifier, completionHandler: completionHandler)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@
|
|||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>29</string>
|
||||
<string>35</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>12.0</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
|
|
@ -58,9 +58,13 @@
|
|||
<string>_CC1AD845._googlecast._tcp</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Allow $(PRODUCT_NAME) to access your local network</string>
|
||||
<string>Nuvio uses the local network to discover Cast-enabled devices on your WiFi network and to connect to local media servers.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>This app does not require microphone access.</string>
|
||||
<key>NSSupportsLiveActivities</key>
|
||||
<true/>
|
||||
<key>NSSupportsLiveActivitiesFrequentUpdates</key>
|
||||
<false/>
|
||||
<key>RCTNewArchEnabled</key>
|
||||
<true/>
|
||||
<key>RCTRootViewBackgroundColor</key>
|
||||
|
|
@ -73,13 +77,6 @@
|
|||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>SplashScreen</string>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>SplashScreenBackground</string>
|
||||
<key>UIImageName</key>
|
||||
<string>SplashScreenLegacy</string>
|
||||
</dict>
|
||||
<key>UIRequiredDeviceCapabilities</key>
|
||||
<array>
|
||||
<string>arm64</string>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
//
|
||||
// Use this file to import your target's public headers that you would like to expose to Swift.
|
||||
//
|
||||
#import <RNBackgroundDownloader.h>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
<dict>
|
||||
<key>aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.developer.associated-domains</key>
|
||||
<array/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
|
@ -15,7 +15,7 @@
|
|||
<viewController storyboardIdentifier="SplashScreenViewController" id="EXPO-VIEWCONTROLLER-1" sceneMemberID="viewController">
|
||||
<view key="view" userInteractionEnabled="NO" contentMode="scaleToFill" insetsLayoutMarginsFromSafeArea="NO" id="EXPO-ContainerView" userLabel="ContainerView">
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<autoresizingMask key="autoresizingMask" flexibleMaxX="YES" flexibleMaxY="YES"/>
|
||||
<subviews>
|
||||
<imageView id="EXPO-SplashScreen" userLabel="SplashScreenLegacy" image="SplashScreenLegacy" contentMode="scaleAspectFit" clipsSubviews="true" userInteractionEnabled="false" translatesAutoresizingMaskIntoConstraints="false">
|
||||
<rect key="frame" x="0" y="0" width="414" height="736"/>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<key>EXUpdatesLaunchWaitMs</key>
|
||||
<integer>30000</integer>
|
||||
<key>EXUpdatesRuntimeVersion</key>
|
||||
<string>1.2.11</string>
|
||||
<string>1.3.6</string>
|
||||
<key>EXUpdatesURL</key>
|
||||
<string>https://ota.nuvioapp.space/api/manifest</string>
|
||||
</dict>
|
||||
|
|
|
|||
|
|
@ -235,6 +235,8 @@ PODS:
|
|||
- ExpoModulesCore
|
||||
- ExpoLinking (8.0.10):
|
||||
- ExpoModulesCore
|
||||
- ExpoLiveActivity (0.4.2):
|
||||
- ExpoModulesCore
|
||||
- ExpoLocalization (17.0.8):
|
||||
- ExpoModulesCore
|
||||
- ExpoModulesCore (3.0.29):
|
||||
|
|
@ -404,6 +406,8 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- MMKV (2.2.4):
|
||||
- MMKVCore (~> 2.2.4)
|
||||
- MMKVCore (2.2.4)
|
||||
- NitroMmkv (4.1.0):
|
||||
- hermes-engine
|
||||
|
|
@ -1734,6 +1738,29 @@ PODS:
|
|||
- React-RCTFBReactNativeSpec
|
||||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- react-native-background-downloader (4.4.5):
|
||||
- hermes-engine
|
||||
- MMKV
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- 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-blur (4.4.1):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
|
|
@ -1971,7 +1998,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-video (6.18.0):
|
||||
- react-native-video (6.19.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -1983,7 +2010,7 @@ PODS:
|
|||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-jsi
|
||||
- react-native-video/Video (= 6.18.0)
|
||||
- react-native-video/Video (= 6.19.0)
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-renderercss
|
||||
|
|
@ -1994,7 +2021,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-video/Fabric (6.18.0):
|
||||
- react-native-video/Fabric (6.19.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2016,7 +2043,7 @@ PODS:
|
|||
- ReactCommon/turbomodule/core
|
||||
- ReactNativeDependencies
|
||||
- Yoga
|
||||
- react-native-video/Video (6.18.0):
|
||||
- react-native-video/Video (6.19.0):
|
||||
- hermes-engine
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
|
|
@ -2786,6 +2813,7 @@ DEPENDENCIES:
|
|||
- ExpoKeepAwake (from `../node_modules/expo-keep-awake/ios`)
|
||||
- ExpoLinearGradient (from `../node_modules/expo-linear-gradient/ios`)
|
||||
- ExpoLinking (from `../node_modules/expo-linking/ios`)
|
||||
- ExpoLiveActivity (from `../node_modules/expo-live-activity/ios`)
|
||||
- ExpoLocalization (from `../node_modules/expo-localization/ios`)
|
||||
- ExpoModulesCore (from `../node_modules/expo-modules-core`)
|
||||
- ExpoRandom (from `../node_modules/expo-random/ios`)
|
||||
|
|
@ -2839,6 +2867,7 @@ DEPENDENCIES:
|
|||
- React-logger (from `../node_modules/react-native/ReactCommon/logger`)
|
||||
- React-Mapbuffer (from `../node_modules/react-native/ReactCommon`)
|
||||
- React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
||||
- "react-native-background-downloader (from `../node_modules/@kesha-antonov/react-native-background-downloader`)"
|
||||
- "react-native-blur (from `../node_modules/@react-native-community/blur`)"
|
||||
- react-native-bottom-tabs (from `../node_modules/react-native-bottom-tabs`)
|
||||
- "react-native-device-brightness (from `../node_modules/@adrianso/react-native-device-brightness`)"
|
||||
|
|
@ -2898,6 +2927,7 @@ SPEC REPOS:
|
|||
- libdav1d
|
||||
- libwebp
|
||||
- lottie-ios
|
||||
- MMKV
|
||||
- MMKVCore
|
||||
- PromisesObjC
|
||||
- ReachabilitySwift
|
||||
|
|
@ -2961,6 +2991,8 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/expo-linear-gradient/ios"
|
||||
ExpoLinking:
|
||||
:path: "../node_modules/expo-linking/ios"
|
||||
ExpoLiveActivity:
|
||||
:path: "../node_modules/expo-live-activity/ios"
|
||||
ExpoLocalization:
|
||||
:path: "../node_modules/expo-localization/ios"
|
||||
ExpoModulesCore:
|
||||
|
|
@ -3068,6 +3100,8 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/react-native/ReactCommon"
|
||||
React-microtasksnativemodule:
|
||||
:path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||
react-native-background-downloader:
|
||||
:path: "../node_modules/@kesha-antonov/react-native-background-downloader"
|
||||
react-native-blur:
|
||||
:path: "../node_modules/@react-native-community/blur"
|
||||
react-native-bottom-tabs:
|
||||
|
|
@ -3206,6 +3240,7 @@ SPEC CHECKSUMS:
|
|||
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
|
||||
ExpoLinearGradient: 809102bdb979f590083af49f7fa4805cd931bd58
|
||||
ExpoLinking: f4c4a351523da72a6bfa7e1f4ca92aee1043a3ca
|
||||
ExpoLiveActivity: d0dd0e8e1460b6b26555b611c4826cdb1036eea2
|
||||
ExpoLocalization: d9168d5300a5b03e5e78b986124d11fb6ec3ebbd
|
||||
ExpoModulesCore: f3da4f1ab5a8375d0beafab763739dbee8446583
|
||||
ExpoRandom: d1444df65007bdd4070009efd5dab18e20bf0f00
|
||||
|
|
@ -3228,6 +3263,7 @@ SPEC CHECKSUMS:
|
|||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||
lottie-ios: a881093fab623c467d3bce374367755c272bdd59
|
||||
lottie-react-native: cbe3d931a7c24f7891a8e8032c2bb9b2373c4b9c
|
||||
MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf
|
||||
MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df
|
||||
NitroMmkv: 4af10c70043b4c3cded3f16547627c7d9d8e3b8b
|
||||
NitroModules: a71a5ab2911caf79e45170e6e12475b5260a12d0
|
||||
|
|
@ -3266,6 +3302,7 @@ SPEC CHECKSUMS:
|
|||
React-logger: 7b234de35acb469ce76d6bbb0457f664d6f32f62
|
||||
React-Mapbuffer: fbe1da882a187e5898bdf125e1cc6e603d27ecae
|
||||
React-microtasksnativemodule: 76905804171d8ccbe69329fc84c57eb7934add7f
|
||||
react-native-background-downloader: 384c954ba4510de725697f7df4fd75f7c25579a2
|
||||
react-native-blur: 1b00ef07fe0efdc0c40b37139a5268ccad73c72d
|
||||
react-native-bottom-tabs: bcb70e4fae95fc9da0da875f7414acda26dfc551
|
||||
react-native-device-brightness: 1a997350d060c3df9f303b1df84a4f7c5cbeb924
|
||||
|
|
@ -3275,7 +3312,7 @@ SPEC CHECKSUMS:
|
|||
react-native-safe-area-context: 37e680fc4cace3c0030ee46e8987d24f5d3bdab2
|
||||
react-native-skia: 268f183f849742e9da216743ee234bd7ad81c69b
|
||||
react-native-slider: f954578344106f0a732a4358ce3a3e11015eb6e1
|
||||
react-native-video: f5982e21efab0dc356d92541a8a9e19581307f58
|
||||
react-native-video: bca076cfff2a3e749fc63b3ac88118e1d8ee2689
|
||||
React-NativeModulesApple: a9464983ccc0f66f45e93558671f60fc7536e438
|
||||
React-oscompat: 73db7dbc80edef36a9d6ed3c6c4e1724ead4236d
|
||||
React-perflogger: 123272debf907cc423962adafcf4513320e43757
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
{
|
||||
"expo.jsEngine": "hermes",
|
||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||
"newArchEnabled": "true",
|
||||
"ios.deploymentTarget": "16.0"
|
||||
}
|
||||
"newArchEnabled": "true"
|
||||
}
|
||||
|
|
|
|||
379
live.md
Normal file
379
live.md
Normal file
|
|
@ -0,0 +1,379 @@
|
|||

|
||||
|
||||
> [!WARNING]
|
||||
> This library is in early development stage; breaking changes can be introduced in minor version upgrades.
|
||||
|
||||
# expo-live-activity
|
||||
|
||||
`expo-live-activity` is a React Native module designed for use with Expo to manage and display Live Activities on iOS devices exclusively. This module leverages the Live Activities feature introduced in iOS 16, allowing developers to deliver timely updates right on the lock screen.
|
||||
|
||||
## Features
|
||||
|
||||
- Start, update, and stop Live Activities directly from your React Native application.
|
||||
- Easy integration with a comprehensive API.
|
||||
- Custom image support within Live Activities with a pre-configured path.
|
||||
- Listen and handle changes in push notification tokens associated with a Live Activity.
|
||||
|
||||
## Platform compatibility
|
||||
|
||||
**Note:** This module is intended for use on **iOS devices only**. The minimal iOS version that supports Live Activities is 16.2. When methods are invoked on platforms other than iOS or on older iOS versions, they will log an error, ensuring that they are used in the correct context.
|
||||
|
||||
## Installation
|
||||
|
||||
> [!NOTE]
|
||||
> The library isn't supported in Expo Go; to set it up correctly you need to use [Expo DevClient](https://docs.expo.dev/versions/latest/sdk/dev-client/) .
|
||||
> To begin using `expo-live-activity`, follow the installation and configuration steps outlined below:
|
||||
|
||||
### Step 1: Installation
|
||||
|
||||
Run the following command to add the expo-live-activity module to your project:
|
||||
|
||||
```sh
|
||||
npm install expo-live-activity
|
||||
```
|
||||
|
||||
### Step 2: Config Plugin Setup
|
||||
|
||||
The module comes with a built-in config plugin that creates a target in iOS with all the necessary files. The images used in Live Activities should be added to a pre-defined folder in your assets directory:
|
||||
|
||||
1. **Add the config plugin to your app.json or app.config.js:**
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"plugins": ["expo-live-activity"]
|
||||
}
|
||||
}
|
||||
```
|
||||
If you want to update Live Activity with push notifications you can add option `"enablePushNotifications": true`:
|
||||
```json
|
||||
{
|
||||
"expo": {
|
||||
"plugins": [
|
||||
[
|
||||
"expo-live-activity",
|
||||
{
|
||||
"enablePushNotifications": true
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
2. **Assets configuration:**
|
||||
Place images intended for Live Activities in the `assets/liveActivity` folder. The plugin manages these assets automatically.
|
||||
|
||||
Then prebuild your app with:
|
||||
|
||||
```sh
|
||||
npx expo prebuild --clean
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Because of iOS limitations, the assets can't be bigger than 4KB ([native Live Activity documentation](https://developer.apple.com/documentation/activitykit/displaying-live-data-with-live-activities#Understand-constraints))
|
||||
|
||||
### Step 3: Usage in Your React Native App
|
||||
|
||||
Import the functionalities provided by the `expo-live-activity` module in your JavaScript or TypeScript files:
|
||||
|
||||
```javascript
|
||||
import * as LiveActivity from 'expo-live-activity'
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
`expo-live-activity` module exports three primary functions to manage Live Activities:
|
||||
|
||||
### Managing Live Activities
|
||||
|
||||
- **`startActivity(state: LiveActivityState, config?: LiveActivityConfig): string | undefined`**:
|
||||
Start a new Live Activity. Takes a `state` configuration object for initial activity state and an optional `config` object to customize appearance or behavior. It returns the `ID` of the created Live Activity, which should be stored for future reference. If the Live Activity can't be created (eg. on android or iOS lower than 16.2), it will return `undefined`.
|
||||
|
||||
- **`updateActivity(id: string, state: LiveActivityState)`**:
|
||||
Update an existing Live Activity. The `state` object should contain updated information. The `activityId` indicates which activity should be updated.
|
||||
|
||||
- **`stopActivity(id: string, state: LiveActivityState)`**:
|
||||
Terminate an ongoing Live Activity. The `state` object should contain the final state of the activity. The `activityId` indicates which activity should be stopped.
|
||||
|
||||
### Handling Push Notification Tokens
|
||||
|
||||
- **`addActivityPushToStartTokenListener(listener: (event: ActivityPushToStartTokenReceivedEvent) => void): EventSubscription | undefined`**:
|
||||
Subscribe to changes in the push to start token for starting live acitivities with push notifications.
|
||||
- **`addActivityTokenListener(listener: (event: ActivityTokenReceivedEvent) => void): EventSubscription | undefined`**:
|
||||
Subscribe to changes in the push notification token associated with Live Activities.
|
||||
|
||||
### Deep linking
|
||||
|
||||
When starting a new Live Activity, it's possible to pass `deepLinkUrl` field in `config` object. This usually should be a path to one of your screens. If you are using @react-navigation in your project, it's easiest to enable auto linking:
|
||||
|
||||
```typescript
|
||||
const prefix = Linking.createURL('')
|
||||
|
||||
export default function App() {
|
||||
const url = Linking.useLinkingURL()
|
||||
const linking = {
|
||||
enabled: 'auto' as const,
|
||||
prefixes: [prefix],
|
||||
}
|
||||
}
|
||||
|
||||
// Then start the activity with:
|
||||
LiveActivity.startActivity(state, {
|
||||
deepLinkUrl: '/order',
|
||||
})
|
||||
```
|
||||
|
||||
URL scheme will be taken automatically from `scheme` field in `app.json` or fall back to `ios.bundleIdentifier`.
|
||||
|
||||
### State Object Structure
|
||||
|
||||
The `state` object should include:
|
||||
|
||||
```typescript
|
||||
{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
progressBar: { // Only one property (date, progress, or elapsedTimer) is available at a time
|
||||
date?: number; // Set as epoch time in milliseconds. This is used as an end date in a countdown timer.
|
||||
progress?: number; // Set amount of progress in the progress bar (0-1)
|
||||
elapsedTimer?: { // Count up timer (elapsed time from start)
|
||||
startDate: number; // Epoch time in milliseconds when the timer started
|
||||
};
|
||||
};
|
||||
imageName?: string; // Matches the name of the image in 'assets/liveActivity'
|
||||
dynamicIslandImageName?: string; // Matches the name of the image in 'assets/liveActivity'
|
||||
};
|
||||
```
|
||||
|
||||
### Config Object Structure
|
||||
|
||||
The `config` object should include:
|
||||
|
||||
```typescript
|
||||
{
|
||||
backgroundColor?: string;
|
||||
titleColor?: string;
|
||||
subtitleColor?: string;
|
||||
progressViewTint?: string;
|
||||
progressViewLabelColor?: string;
|
||||
deepLinkUrl?: string;
|
||||
timerType?: DynamicIslandTimerType; // "circular" | "digital" - defines timer appearance on the dynamic island
|
||||
padding?: Padding // number | {top?: number bottom?: number ...}
|
||||
imagePosition?: ImagePosition; // 'left' | 'right';
|
||||
imageAlign?: ImageAlign; // 'top' | 'center' | 'bottom'
|
||||
imageSize?: ImageSize // { width?: number|`${number}%`, height?: number|`${number}%` } | undefined (defaults to 64pt)
|
||||
contentFit?: ImageContentFit; // 'cover' | 'contain' | 'fill' | 'none' | 'scale-down'
|
||||
};
|
||||
```
|
||||
|
||||
### Activity updates
|
||||
|
||||
`LiveActivity.addActivityUpdatesListener` API allows to subscribe to changes in Live Activity state. This is useful for example when you want to update the Live Activity with new information. Handler will receive an `ActivityUpdateEvent` object which contains information about new state under `activityState` property which is of `ActivityState` type, so the possible values are: `'active'`, `'dismissed'`, `'pending'`, `'stale'` or `'ended'`. Apart from this property, the event also contains `activityId` and `activityName` which can be used to identify the Live Activity.
|
||||
|
||||
## Example Usage
|
||||
|
||||
Managing a Live Activity:
|
||||
|
||||
```typescript
|
||||
const state: LiveActivity.LiveActivityState = {
|
||||
title: 'Title',
|
||||
subtitle: 'This is a subtitle',
|
||||
progressBar: {
|
||||
date: new Date(Date.now() + 60 * 1000 * 5).getTime(),
|
||||
},
|
||||
imageName: 'live_activity_image',
|
||||
dynamicIslandImageName: 'dynamic_island_image',
|
||||
}
|
||||
|
||||
const config: LiveActivity.LiveActivityConfig = {
|
||||
backgroundColor: '#FFFFFF',
|
||||
titleColor: '#000000',
|
||||
subtitleColor: '#333333',
|
||||
progressViewTint: '#4CAF50',
|
||||
progressViewLabelColor: '#FFFFFF',
|
||||
deepLinkUrl: '/dashboard',
|
||||
timerType: 'circular',
|
||||
padding: { horizontal: 20, top: 16, bottom: 16 },
|
||||
imagePosition: 'right',
|
||||
imageAlign: 'center',
|
||||
imageSize: { height: '50%', width: '50%' }, // number (pt) or percentage of the image container, if empty by default is 64pt.
|
||||
contentFit: 'cover',
|
||||
}
|
||||
|
||||
const activityId = LiveActivity.startActivity(state, config)
|
||||
// Store activityId for future reference
|
||||
```
|
||||
|
||||
This will initiate a Live Activity with the specified title, subtitle, image from your configured assets folder and a time to which there will be a countdown in a progress view.
|
||||
|
||||
Using an elapsed timer:
|
||||
|
||||
```typescript
|
||||
const elapsedTimerState: LiveActivity.LiveActivityState = {
|
||||
title: 'Walk in Progress',
|
||||
subtitle: 'With Max the Dog',
|
||||
progressBar: {
|
||||
elapsedTimer: {
|
||||
startDate: Date.now() - 5 * 60 * 1000, // Started 5 minutes ago
|
||||
},
|
||||
},
|
||||
imageName: 'dog_walking',
|
||||
dynamicIslandImageName: 'dog_icon',
|
||||
}
|
||||
|
||||
const activityId = LiveActivity.startActivity(elapsedTimerState, config)
|
||||
```
|
||||
|
||||
The elapsed timer will automatically update every second based on the `startDate` you provide.
|
||||
|
||||
Subscribing to push token changes:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const updateTokenSubscription = LiveActivity.addActivityTokenListener(
|
||||
({ activityID: newActivityID, activityName: newName, activityPushToken: newToken }) => {
|
||||
// Send token to a remote server to update Live Activity with push notifications
|
||||
}
|
||||
)
|
||||
const startTokenSubscription = LiveActivity.addActivityPushToStartTokenListener(
|
||||
({ activityPushToStartToken: newActivityPushToStartToken }) => {
|
||||
// Send token to a remote server to start Live Activity with push notifications
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
updateTokenSubscription?.remove()
|
||||
startTokenSubscription?.remove()
|
||||
}
|
||||
}, [])
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Receiving push token may not work on simulators. Make sure to use physical device when testing this functionality.
|
||||
|
||||
## Push notifications
|
||||
|
||||
By default, starting and updating Live Activity is possible only via API. If you want to have possibility to start or update Live Activity using push notifications, you can enable that feature by adding `"enablePushNotifications": true` in the plugin config in your `app.json` or `app.config.ts` file.
|
||||
|
||||
> [!NOTE]
|
||||
> PushToStart works only for iOS 17.2 and higher.
|
||||
|
||||
Example payload for starting Live Activity:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"event": "start",
|
||||
"content-state": {
|
||||
"title": "Live Activity title!",
|
||||
"subtitle": "Live Activity subtitle.",
|
||||
"timerEndDateInMilliseconds": 1754410997000,
|
||||
"progress": 0.5,
|
||||
"imageName": "live_activity_image",
|
||||
"dynamicIslandImageName": "dynamic_island_image",
|
||||
"elapsedTimerStartDateInMilliseconds": null
|
||||
},
|
||||
"timestamp": 1754491435000, // timestamp of when the push notification was sent
|
||||
"attributes-type": "LiveActivityAttributes",
|
||||
"attributes": {
|
||||
"name": "Test",
|
||||
"backgroundColor": "001A72",
|
||||
"titleColor": "EBEBF0",
|
||||
"subtitleColor": "FFFFFF75",
|
||||
"progressViewTint": "38ACDD",
|
||||
"progressViewLabelColor": "FFFFFF",
|
||||
"deepLinkUrl": "/dashboard",
|
||||
"timerType": "digital",
|
||||
"padding": 24, // or use object to control each side: { "horizontal": 20, "top": 16, "bottom": 16 }
|
||||
"imagePosition": "right",
|
||||
"imageSize": "default"
|
||||
},
|
||||
"alert": {
|
||||
"title": "",
|
||||
"body": "",
|
||||
"sound": "default"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Example payload for updating Live Activity:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"event": "update",
|
||||
"content-state": {
|
||||
"title": "Hello",
|
||||
"subtitle": "World",
|
||||
"timerEndDateInMilliseconds": 1754064245000,
|
||||
"imageName": "live_activity_image",
|
||||
"dynamicIslandImageName": "dynamic_island_image"
|
||||
},
|
||||
"timestamp": 1754063621319 // timestamp of when the push notification was sent
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where `timerEndDateInMilliseconds` value is a timestamp in milliseconds corresponding to the target point of the countdown displayed in Live Activity view.
|
||||
|
||||
Example payload for starting Live Activity with elapsed timer:
|
||||
|
||||
```json
|
||||
{
|
||||
"aps": {
|
||||
"event": "start",
|
||||
"content-state": {
|
||||
"title": "Walk in Progress",
|
||||
"subtitle": "With Max",
|
||||
"timerEndDateInMilliseconds": null,
|
||||
"progress": null,
|
||||
"imageName": "dog_walking",
|
||||
"dynamicIslandImageName": "dog_icon",
|
||||
"elapsedTimerStartDateInMilliseconds": 1754410997000
|
||||
},
|
||||
"timestamp": 1754491435000,
|
||||
"attributes-type": "LiveActivityAttributes",
|
||||
"attributes": {
|
||||
"name": "WalkActivity",
|
||||
"backgroundColor": "001A72",
|
||||
"titleColor": "EBEBF0",
|
||||
"progressViewLabelColor": "FFFFFF"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Where `elapsedTimerStartDateInMilliseconds` is the timestamp (in milliseconds) when the elapsed timer started counting up.
|
||||
|
||||
## Image support
|
||||
|
||||
Live Activity view also supports image display. There are two dedicated fields in the `state` object for that:
|
||||
|
||||
- `imageName`
|
||||
- `dynamicIslandImageName`
|
||||
|
||||
The value of each field can be:
|
||||
|
||||
- a string which maps to an asset name
|
||||
- a URL to remote image - currently, it's possible to use this option only via API, but we plan on to add that feature to push notifications as well. It also requires adding "App Groups" capability to both "main app" and "Live Activity" targets.
|
||||
|
||||
## expo-live-activity is created by Software Mansion
|
||||
|
||||
[](https://swmansion.com)
|
||||
|
||||
Since 2012 [Software Mansion](https://swmansion.com) is a software agency with
|
||||
experience in building web and mobile apps. We are Core React Native
|
||||
Contributors and experts in dealing with all kinds of React Native issues. We
|
||||
can help you build your next dream product –
|
||||
[Hire us](https://swmansion.com/contact/projects?utm_source=typegpu&utm_medium=readme).
|
||||
|
||||
<!-- automd:contributors author="software-mansion" -->
|
||||
|
||||
Made by [@software-mansion](https://github.com/software-mansion) and
|
||||
[community](https://github.com/software-mansion-labs/expo-live-activity/graphs/contributors) 💛
|
||||
<br><br>
|
||||
<a href="https://github.com/software-mansion-labs/expo-live-activity/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=software-mansion-labs/expo-live-activity" />
|
||||
</a>
|
||||
|
||||
<!-- /automd -->
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -30,12 +30,20 @@
|
|||
"https://github.com/tapframe/NuvioStreaming/blob/main/screenshots/search-portrait.png?raw=true"
|
||||
],
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.3.6",
|
||||
"buildVersion": "34",
|
||||
"date": "2026-01-21",
|
||||
"localizedDescription": "# Nuvio Media Hub – v1.3.6 \n\n## Update Notes\n\n### Player & Playback\n- Updated **React Native Video** to the latest version \n- Fixed some **TXT-based streams** failing to play in ExoPlayer \n- Fixed **M3U8 streams without file extension** failing to play in ExoPlayer \n- Added more **aspect ratio options** to ExoPlayer for better viewing control \n\n### Improvements & Fixes\n- Updated several **dependencies** \n- Added an **in-built major app update downloader** (Android) \n- Various **internal fixes and stability improvements** \n\nThis update focuses on improving playback compatibility, update handling, and overall stability.",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.6/Stable_1-3-6.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.5/Stable_1-3-5.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
|
|
@ -43,7 +51,7 @@
|
|||
"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",
|
||||
"downloadURL": "https://github.com/tapframe/NuvioStreaming/releases/download/v1.3.4/Stable_1-3-4.ipa",
|
||||
"size": 25700000
|
||||
},
|
||||
{
|
||||
|
|
|
|||
30
package-lock.json
generated
30
package-lock.json
generated
|
|
@ -17,6 +17,7 @@
|
|||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
|
||||
"@legendapp/list": "^2.0.13",
|
||||
"@lottiefiles/dotlottie-react": "^0.17.7",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
|
|
@ -54,6 +55,7 @@
|
|||
"expo-intent-launcher": "~13.0.7",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-live-activity": "^0.4.2",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-navigation-bar": "~5.0.10",
|
||||
"expo-notifications": "~0.32.12",
|
||||
|
|
@ -91,7 +93,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.18.0",
|
||||
"react-native-video": "^6.19.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-wheel-color-picker": "^1.3.1",
|
||||
"react-native-worklets": "^0.7.1"
|
||||
|
|
@ -2732,6 +2734,15 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kesha-antonov/react-native-background-downloader": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@kesha-antonov/react-native-background-downloader/-/react-native-background-downloader-4.4.5.tgz",
|
||||
"integrity": "sha512-OrQdhDhroRFiUKfoX6AoPV7qgA/UzAJljI/980NvPK4okux36qGKzN2BX/sRL6emv3MNQSKyKifjxYq/TpCq0Q==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react-native": ">=0.57.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@legendapp/list": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@legendapp/list/-/list-2.0.18.tgz",
|
||||
|
|
@ -6584,6 +6595,17 @@
|
|||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-live-activity": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmjs.org/expo-live-activity/-/expo-live-activity-0.4.2.tgz",
|
||||
"integrity": "sha512-b3QdsXAg8dPr6p8w4U4eBYdndArSprCPOJC9U8wovAsOOrCA3eSv4vwfn41XNDmaPTc6gweCABaIIxPaTg2oZQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*",
|
||||
"react": "*",
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-localization": {
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz",
|
||||
|
|
@ -11195,9 +11217,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/react-native-video": {
|
||||
"version": "6.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.18.0.tgz",
|
||||
"integrity": "sha512-9BjAtAh1uGq6h/GNCCh5yzb/iI9qJHuflwNGExyhoUxbhPD1s+15h+CdpJ2MKKJTXw6J7w+nQOp1Ywa54R8w7Q==",
|
||||
"version": "6.19.0",
|
||||
"resolved": "https://registry.npmjs.org/react-native-video/-/react-native-video-6.19.0.tgz",
|
||||
"integrity": "sha512-JVojWIxwuH5C3RjVrF4UyuweuOH/Guq/W2xeN9zugePXZI8Xn/j6/oU94gCWHaFzkR/HGeJpqMq+l9aEHSnpIQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "*",
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"@expo/metro-runtime": "~6.1.2",
|
||||
"@expo/vector-icons": "^15.0.2",
|
||||
"@gorhom/bottom-sheet": "^5.2.6",
|
||||
"@kesha-antonov/react-native-background-downloader": "^4.4.5",
|
||||
"@legendapp/list": "^2.0.13",
|
||||
"@lottiefiles/dotlottie-react": "^0.17.7",
|
||||
"@react-native-community/blur": "^4.4.1",
|
||||
|
|
@ -54,6 +55,7 @@
|
|||
"expo-intent-launcher": "~13.0.7",
|
||||
"expo-keep-awake": "~15.0.8",
|
||||
"expo-linear-gradient": "~15.0.7",
|
||||
"expo-live-activity": "^0.4.2",
|
||||
"expo-localization": "~17.0.7",
|
||||
"expo-navigation-bar": "~5.0.10",
|
||||
"expo-notifications": "~0.32.12",
|
||||
|
|
@ -91,7 +93,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.18.0",
|
||||
"react-native-video": "^6.19.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-wheel-color-picker": "^1.3.1",
|
||||
"react-native-worklets": "^0.7.1"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
1639
patches/react-native-video+6.19.0.patch
Normal file
1639
patches/react-native-video+6.19.0.patch
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -10,13 +10,18 @@ interface Props {
|
|||
releaseUrl?: string;
|
||||
onDismiss: () => void;
|
||||
onLater: () => void;
|
||||
onUpdateAction?: () => void;
|
||||
isDownloading?: boolean;
|
||||
downloadProgress?: number;
|
||||
}
|
||||
|
||||
const MajorUpdateOverlay: React.FC<Props> = ({ visible, latestTag, releaseNotes, releaseUrl, onDismiss, onLater }) => {
|
||||
const MajorUpdateOverlay: React.FC<Props> = ({ visible, latestTag, releaseNotes, releaseUrl, onDismiss, onLater, onUpdateAction, isDownloading, downloadProgress }) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const progressPercent = downloadProgress ? Math.round(downloadProgress * 100) : 0;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" statusBarTranslucent presentationStyle="overFullScreen" supportedOrientations={['portrait', 'landscape', 'landscape-left', 'landscape-right']}>
|
||||
<View style={styles.backdrop}>
|
||||
|
|
@ -40,10 +45,16 @@ const MajorUpdateOverlay: React.FC<Props> = ({ visible, latestTag, releaseNotes,
|
|||
)}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{releaseUrl ? (
|
||||
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: currentTheme.colors.primary }]} onPress={() => Linking.openURL(releaseUrl)}>
|
||||
<MaterialIcons name="open-in-new" size={18} color="#fff" />
|
||||
<Text style={styles.primaryText}>View release</Text>
|
||||
{releaseUrl || onUpdateAction ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryBtn, { backgroundColor: currentTheme.colors.primary, opacity: isDownloading ? 0.7 : 1 }]}
|
||||
onPress={onUpdateAction || (() => releaseUrl && Linking.openURL(releaseUrl))}
|
||||
disabled={isDownloading}
|
||||
>
|
||||
<MaterialIcons name={isDownloading ? "downloading" : "system-update"} size={18} color="#fff" />
|
||||
<Text style={styles.primaryText}>
|
||||
{isDownloading ? `Downloading... ${progressPercent}%` : (onUpdateAction ? 'Update Now' : 'View release')}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ interface StreamCardProps {
|
|||
showAlert: (title: string, message: string) => void;
|
||||
parentTitle?: string;
|
||||
parentType?: 'movie' | 'series';
|
||||
parentYear?: number;
|
||||
parentSeason?: number;
|
||||
parentEpisode?: number;
|
||||
parentEpisodeTitle?: string;
|
||||
|
|
@ -38,36 +39,37 @@ interface StreamCardProps {
|
|||
parentImdbId?: string;
|
||||
}
|
||||
|
||||
const StreamCard = memo(({
|
||||
stream,
|
||||
onPress,
|
||||
index,
|
||||
isLoading,
|
||||
statusMessage,
|
||||
theme,
|
||||
showLogos,
|
||||
scraperLogo,
|
||||
showAlert,
|
||||
parentTitle,
|
||||
parentType,
|
||||
parentSeason,
|
||||
parentEpisode,
|
||||
parentEpisodeTitle,
|
||||
parentPosterUrl,
|
||||
providerName,
|
||||
parentId,
|
||||
parentImdbId
|
||||
const StreamCard = memo(({
|
||||
stream,
|
||||
onPress,
|
||||
index,
|
||||
isLoading,
|
||||
statusMessage,
|
||||
theme,
|
||||
showLogos,
|
||||
scraperLogo,
|
||||
showAlert,
|
||||
parentTitle,
|
||||
parentType,
|
||||
parentYear,
|
||||
parentSeason,
|
||||
parentEpisode,
|
||||
parentEpisodeTitle,
|
||||
parentPosterUrl,
|
||||
providerName,
|
||||
parentId,
|
||||
parentImdbId
|
||||
}: StreamCardProps) => {
|
||||
const { settings } = useSettings();
|
||||
const { startDownload } = useDownloads();
|
||||
const { showSuccess, showInfo } = useToast();
|
||||
|
||||
|
||||
// Handle long press to copy stream URL to clipboard
|
||||
const handleLongPress = useCallback(async () => {
|
||||
if (stream.url) {
|
||||
try {
|
||||
await Clipboard.setString(stream.url);
|
||||
|
||||
|
||||
// Use toast for Android, custom alert for iOS
|
||||
if (Platform.OS === 'android') {
|
||||
showSuccess('URL Copied', 'Stream URL copied to clipboard!');
|
||||
|
|
@ -85,13 +87,13 @@ const StreamCard = memo(({
|
|||
}
|
||||
}
|
||||
}, [stream.url, showAlert, showSuccess, showInfo]);
|
||||
|
||||
|
||||
const styles = React.useMemo(() => createStyles(theme.colors), [theme.colors]);
|
||||
|
||||
|
||||
const streamInfo = useMemo(() => {
|
||||
const title = stream.title || '';
|
||||
const name = stream.name || '';
|
||||
|
||||
|
||||
// Helper function to format size from bytes
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
|
@ -100,16 +102,16 @@ const StreamCard = memo(({
|
|||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
|
||||
// Get size from title (legacy format) or from stream.size field
|
||||
let sizeDisplay = title.match(/💾\s*([\d.]+\s*[GM]B)/)?.[1];
|
||||
if (!sizeDisplay && stream.size && typeof stream.size === 'number' && stream.size > 0) {
|
||||
sizeDisplay = formatSize(stream.size);
|
||||
}
|
||||
|
||||
|
||||
// Extract quality for badge display
|
||||
const basicQuality = title.match(/(\d+)p/)?.[1] || null;
|
||||
|
||||
|
||||
return {
|
||||
quality: basicQuality,
|
||||
isHDR: title.toLowerCase().includes('hdr'),
|
||||
|
|
@ -120,7 +122,7 @@ const StreamCard = memo(({
|
|||
subTitle: title && title !== name ? title : null
|
||||
};
|
||||
}, [stream.name, stream.title, stream.behaviorHints, stream.size]);
|
||||
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
try {
|
||||
const url = stream.url;
|
||||
|
|
@ -132,21 +134,25 @@ const StreamCard = memo(({
|
|||
showAlert('Already Downloading', 'This download has already started for this exact link.');
|
||||
return;
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
// Show immediate feedback on both platforms
|
||||
showAlert('Starting Download', 'Download will be started.');
|
||||
// Show immediate feedback on both platforms
|
||||
// showAlert('Starting Download', 'Download will be started.');
|
||||
const parent: any = stream as any;
|
||||
const inferredTitle = parentTitle || stream.name || stream.title || parent.metaName || 'Content';
|
||||
const inferredType: 'movie' | 'series' = parentType || (parent.kind === 'series' || parent.type === 'series' ? 'series' : 'movie');
|
||||
const year = typeof parentYear === 'number'
|
||||
? parentYear
|
||||
: (typeof parent.year === 'number' ? parent.year : undefined);
|
||||
const season = typeof parentSeason === 'number' ? parentSeason : (parent.season || parent.season_number);
|
||||
const episode = typeof parentEpisode === 'number' ? parentEpisode : (parent.episode || parent.episode_number);
|
||||
const episodeTitle = parentEpisodeTitle || parent.episodeTitle || parent.episode_name;
|
||||
// Prefer the stream's display name (often includes provider + resolution)
|
||||
const provider = (stream.name as any) || (stream.title as any) || providerName || parent.addonName || parent.addonId || (stream.addonName as any) || (stream.addonId as any) || 'Provider';
|
||||
|
||||
|
||||
// Use parentId first (from route params), fallback to stream metadata
|
||||
const idForContent = parentId || parent.imdbId || parent.tmdbId || parent.addonId || inferredTitle;
|
||||
|
||||
|
||||
// Extract tmdbId if available (from parentId or parent metadata)
|
||||
let tmdbId: number | undefined = undefined;
|
||||
if (parentId && parentId.startsWith('tmdb:')) {
|
||||
|
|
@ -159,6 +165,7 @@ const StreamCard = memo(({
|
|||
id: String(idForContent),
|
||||
type: inferredType,
|
||||
title: String(inferredTitle),
|
||||
year: inferredType === 'movie' ? year : undefined,
|
||||
providerName: String(provider),
|
||||
season: inferredType === 'series' ? (season ? Number(season) : undefined) : undefined,
|
||||
episode: inferredType === 'series' ? (episode ? Number(episode) : undefined) : undefined,
|
||||
|
|
@ -172,99 +179,101 @@ const StreamCard = memo(({
|
|||
tmdbId: tmdbId,
|
||||
});
|
||||
showAlert('Download Started', 'Your download has been added to the queue.');
|
||||
} catch {}
|
||||
} catch (e: any) {
|
||||
showAlert('Download Failed', e.message || 'Could not start download.');
|
||||
}
|
||||
}, [startDownload, stream.url, stream.headers, streamInfo.quality, showAlert, stream.name, stream.title, parentId, parentImdbId, parentTitle, parentType, parentSeason, parentEpisode, parentEpisodeTitle, parentPosterUrl, providerName]);
|
||||
|
||||
const isDebrid = streamInfo.isDebrid;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.streamCard,
|
||||
isLoading && styles.streamCardLoading,
|
||||
isDebrid && styles.streamCardHighlighted
|
||||
]}
|
||||
onPress={onPress}
|
||||
onLongPress={handleLongPress}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Scraper Logo */}
|
||||
{showLogos && scraperLogo && (
|
||||
<View style={styles.scraperLogoContainer}>
|
||||
{scraperLogo.toLowerCase().endsWith('.svg') || scraperLogo.toLowerCase().includes('.svg?') ? (
|
||||
<Image
|
||||
source={{ uri: scraperLogo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : (
|
||||
<FastImage
|
||||
source={{ uri: scraperLogo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.streamDetails}>
|
||||
<View style={styles.streamNameRow}>
|
||||
<View style={styles.streamTitleContainer}>
|
||||
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
|
||||
{streamInfo.displayName}
|
||||
</Text>
|
||||
{streamInfo.subTitle && (
|
||||
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
|
||||
{streamInfo.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Show loading indicator if stream is loading */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingIndicator}>
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
|
||||
{statusMessage || "Loading..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.streamMetaRow}>
|
||||
{streamInfo.isDolby && (
|
||||
<QualityBadge type="VISION" />
|
||||
)}
|
||||
|
||||
{streamInfo.size && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{streamInfo.isDebrid && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{settings?.enableDownloads !== false && (
|
||||
<TouchableOpacity
|
||||
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
|
||||
onPress={handleDownload}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="download"
|
||||
size={20}
|
||||
color={theme.colors.highEmphasis}
|
||||
style={[
|
||||
styles.streamCard,
|
||||
isLoading && styles.streamCardLoading,
|
||||
isDebrid && styles.streamCardHighlighted
|
||||
]}
|
||||
onPress={onPress}
|
||||
onLongPress={handleLongPress}
|
||||
disabled={isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* Scraper Logo */}
|
||||
{showLogos && scraperLogo && (
|
||||
<View style={styles.scraperLogoContainer}>
|
||||
{scraperLogo.toLowerCase().endsWith('.svg') || scraperLogo.toLowerCase().includes('.svg?') ? (
|
||||
<Image
|
||||
source={{ uri: scraperLogo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<FastImage
|
||||
source={{ uri: scraperLogo }}
|
||||
style={styles.scraperLogo}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={styles.streamDetails}>
|
||||
<View style={styles.streamNameRow}>
|
||||
<View style={styles.streamTitleContainer}>
|
||||
<Text style={[styles.streamName, { color: theme.colors.highEmphasis }]}>
|
||||
{streamInfo.displayName}
|
||||
</Text>
|
||||
{streamInfo.subTitle && (
|
||||
<Text style={[styles.streamAddonName, { color: theme.colors.mediumEmphasis }]}>
|
||||
{streamInfo.subTitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Show loading indicator if stream is loading */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingIndicator}>
|
||||
<ActivityIndicator size="small" color={theme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: theme.colors.primary }]}>
|
||||
{statusMessage || "Loading..."}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.streamMetaRow}>
|
||||
{streamInfo.isDolby && (
|
||||
<QualityBadge type="VISION" />
|
||||
)}
|
||||
|
||||
{streamInfo.size && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.darkGray }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>💾 {streamInfo.size}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{streamInfo.isDebrid && (
|
||||
<View style={[styles.chip, { backgroundColor: theme.colors.success }]}>
|
||||
<Text style={[styles.chipText, { color: theme.colors.white }]}>DEBRID</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
|
||||
{settings?.enableDownloads !== false && (
|
||||
<TouchableOpacity
|
||||
style={[styles.streamAction, { marginLeft: 8, backgroundColor: theme.colors.elevation2 }]}
|
||||
onPress={handleDownload}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="download"
|
||||
size={20}
|
||||
color={theme.colors.highEmphasis}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -371,6 +371,7 @@ const TabletStreamsLayout: React.FC<TabletStreamsLayoutProps> = ({
|
|||
showAlert={(t: string, m: string) => openAlert(t, m)}
|
||||
parentTitle={metadata?.name}
|
||||
parentType={type as 'movie' | 'series'}
|
||||
parentYear={metadata?.year}
|
||||
parentSeason={(type === 'series' || type === 'other') ? currentEpisode?.season_number : undefined}
|
||||
parentEpisode={(type === 'series' || type === 'other') ? currentEpisode?.episode_number : undefined}
|
||||
parentEpisodeTitle={(type === 'series' || type === 'other') ? currentEpisode?.name : undefined}
|
||||
|
|
|
|||
|
|
@ -224,7 +224,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
const thumbnailOpacity = useSharedValue(1);
|
||||
const trailerOpacity = useSharedValue(0);
|
||||
const trailerMuted = settings?.trailerMuted ?? true;
|
||||
const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
|
||||
// Initialize to 0 for smooth fade-in
|
||||
const heroOpacity = useSharedValue(0);
|
||||
|
||||
// Handler for trailer end
|
||||
const handleTrailerEnd = useCallback(() => {
|
||||
|
|
@ -270,15 +271,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
'worklet';
|
||||
const scrollYValue = scrollY.value;
|
||||
|
||||
// Disable parallax during drag to avoid transform conflicts
|
||||
if (isDragging.value > 0) {
|
||||
return {
|
||||
transform: [
|
||||
{ scale: 1.0 },
|
||||
{ translateY: 0 }
|
||||
],
|
||||
};
|
||||
}
|
||||
// Keep parallax active during drag to prevent jumps
|
||||
// if (isDragging.value > 0) { ... }
|
||||
|
||||
// Pre-calculated constants - start at 1.0 for normal size
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
|
|
@ -308,15 +302,8 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
'worklet';
|
||||
const scrollYValue = scrollY.value;
|
||||
|
||||
// Disable parallax during drag to avoid transform conflicts
|
||||
if (isDragging.value > 0) {
|
||||
return {
|
||||
transform: [
|
||||
{ scale: 1.0 },
|
||||
{ translateY: 0 }
|
||||
],
|
||||
};
|
||||
}
|
||||
// Keep parallax active during drag to prevent jumps
|
||||
// if (isDragging.value > 0) { ... }
|
||||
|
||||
// Pre-calculated constants - start at 1.0 for normal size
|
||||
const DEFAULT_ZOOM = 1.0;
|
||||
|
|
@ -360,13 +347,10 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
// Smooth fade-in when content loads
|
||||
useEffect(() => {
|
||||
if (currentItem && !loading) {
|
||||
heroOpacity.value = withDelay(
|
||||
100,
|
||||
withTiming(1, {
|
||||
duration: 500,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
);
|
||||
heroOpacity.value = withTiming(1, {
|
||||
duration: 800,
|
||||
easing: Easing.out(Easing.cubic),
|
||||
});
|
||||
}
|
||||
}, [currentItem, loading, heroOpacity]);
|
||||
|
||||
|
|
@ -462,7 +446,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
if (url) {
|
||||
const bestUrl = TrailerService.getBestFormatUrl(url);
|
||||
setTrailerUrl(bestUrl);
|
||||
logger.info('[AppleTVHero] Trailer URL loaded:', bestUrl);
|
||||
// logger.info('[AppleTVHero] Trailer URL loaded:', bestUrl);
|
||||
} else {
|
||||
logger.info('[AppleTVHero] No trailer found for:', currentItem.name);
|
||||
setTrailerUrl(null);
|
||||
|
|
@ -970,7 +954,7 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
setCurrentIndex(index);
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
if (loading && !currentItem) {
|
||||
return (
|
||||
<View style={[styles.container, { height: HERO_HEIGHT, marginTop: -insets.top }]}>
|
||||
<View style={styles.skeletonContainer}>
|
||||
|
|
@ -1008,13 +992,12 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
return (
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View
|
||||
entering={initialLoadComplete ? undefined : FadeIn.duration(600).delay(150)}
|
||||
style={[styles.container, heroContainerStyle, { height: HERO_HEIGHT, marginTop: -insets.top }]}
|
||||
style={[styles.container, heroContainerStyle, { height: HERO_HEIGHT, marginTop: -insets.top, backgroundColor: currentTheme.colors.darkBackground }]}
|
||||
>
|
||||
{/* Background Images with Crossfade */}
|
||||
<View style={styles.backgroundContainer}>
|
||||
{/* Current Image - Always visible as base */}
|
||||
<Animated.View style={[styles.imageWrapper, backgroundParallaxStyle]}>
|
||||
<Animated.View style={[styles.imageWrapper, backgroundParallaxStyle, { opacity: thumbnailOpacity }]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: bannerUrl,
|
||||
|
|
@ -1029,17 +1012,19 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
|
|||
|
||||
{/* Next/Preview Image - Animated overlay during drag */}
|
||||
{nextIndex !== currentIndex && (
|
||||
<Animated.View style={[styles.imageWrapperAbsolute, nextImageStyle, backgroundParallaxStyle]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: nextBannerUrl,
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable,
|
||||
}}
|
||||
style={styles.backgroundImage}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={() => setBannerLoaded((prev) => ({ ...prev, [nextIndex]: true }))}
|
||||
/>
|
||||
<Animated.View style={[styles.imageWrapperAbsolute, backgroundParallaxStyle]}>
|
||||
<Animated.View style={[StyleSheet.absoluteFill, nextImageStyle]}>
|
||||
<FastImage
|
||||
source={{
|
||||
uri: nextBannerUrl,
|
||||
priority: FastImage.priority.high,
|
||||
cache: FastImage.cacheControl.immutable,
|
||||
}}
|
||||
style={styles.backgroundImage}
|
||||
resizeMode={FastImage.resizeMode.cover}
|
||||
onLoad={() => setBannerLoaded((prev) => ({ ...prev, [nextIndex]: true }))}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
|
|
@ -1456,7 +1441,7 @@ const styles = StyleSheet.create({
|
|||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
height: 400, // Increased to cover action buttons with dark background
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
// Loading & Empty States
|
||||
|
|
|
|||
|
|
@ -207,7 +207,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
|
|||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
scrollEventThrottle={16}
|
||||
decelerationRate="fast"
|
||||
decelerationRate="normal"
|
||||
scrollEnabled={true}
|
||||
nestedScrollEnabled={true}
|
||||
contentContainerStyle={StyleSheet.flatten([
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { storageService } from '../../services/storageService';
|
|||
import { logger } from '../../utils/logger';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { TraktService } from '../../services/traktService';
|
||||
import { SimklService } from '../../services/simklService';
|
||||
import { stremioService } from '../../services/stremioService';
|
||||
import { streamCacheService } from '../../services/streamCacheService';
|
||||
import { useSettings } from '../../hooks/useSettings';
|
||||
|
|
@ -221,6 +222,10 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const lastTraktSyncRef = useRef<number>(0);
|
||||
const TRAKT_SYNC_COOLDOWN = 0; // disabled (always fetch Trakt playback)
|
||||
|
||||
// Track last Simkl sync to prevent excessive API calls
|
||||
const lastSimklSyncRef = useRef<number>(0);
|
||||
const SIMKL_SYNC_COOLDOWN = 0; // disabled (always fetch Simkl playback)
|
||||
|
||||
// Track last Trakt reconcile per item (local -> Trakt catch-up)
|
||||
const lastTraktReconcileRef = useRef<Map<string, number>>(new Map());
|
||||
const TRAKT_RECONCILE_COOLDOWN = 0; // 2 minutes between reconcile attempts per item
|
||||
|
|
@ -471,13 +476,19 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const traktService = TraktService.getInstance();
|
||||
const isTraktAuthed = await traktService.isAuthenticated();
|
||||
|
||||
const simklService = SimklService.getInstance();
|
||||
// Prefer Trakt if both are authenticated
|
||||
const isSimklAuthed = !isTraktAuthed ? await simklService.isAuthenticated() : false;
|
||||
|
||||
logger.log(`[CW] Providers authed: trakt=${isTraktAuthed} simkl=${isSimklAuthed}`);
|
||||
|
||||
// Declare groupPromises outside the if block
|
||||
let groupPromises: Promise<void>[] = [];
|
||||
|
||||
// In Trakt mode, CW is sourced from Trakt only, but we still want to overlay local progress
|
||||
// when local is ahead (scrobble lag/offline playback).
|
||||
let localProgressIndex: Map<string, LocalProgressEntry[]> | null = null;
|
||||
if (isTraktAuthed) {
|
||||
if (isTraktAuthed || isSimklAuthed) {
|
||||
try {
|
||||
const allProgress = await storageService.getAllWatchProgress();
|
||||
const index = new Map<string, LocalProgressEntry[]>();
|
||||
|
|
@ -519,8 +530,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
}
|
||||
|
||||
// Non-Trakt: use local storage
|
||||
if (!isTraktAuthed) {
|
||||
// Local-only mode (no Trakt, no Simkl): use local storage
|
||||
if (!isTraktAuthed && !isSimklAuthed) {
|
||||
const allProgress = await storageService.getAllWatchProgress();
|
||||
if (Object.keys(allProgress).length === 0) {
|
||||
setContinueWatchingItems([]);
|
||||
|
|
@ -1300,8 +1311,219 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
})();
|
||||
|
||||
// Wait for all groups and trakt merge to settle, then finalize loading state
|
||||
await Promise.allSettled([...groupPromises, traktMergePromise]);
|
||||
// SIMKL: fetch playback progress (in-progress, paused) and merge similarly to Trakt
|
||||
const simklMergePromise = (async () => {
|
||||
try {
|
||||
if (!isSimklAuthed || isTraktAuthed) return;
|
||||
|
||||
const now = Date.now();
|
||||
if (SIMKL_SYNC_COOLDOWN > 0 && (now - lastSimklSyncRef.current) < SIMKL_SYNC_COOLDOWN) {
|
||||
return;
|
||||
}
|
||||
lastSimklSyncRef.current = now;
|
||||
|
||||
const playbackItems = await simklService.getPlaybackStatus();
|
||||
logger.log(`[CW][Simkl] playback items: ${playbackItems.length}`);
|
||||
|
||||
const simklBatch: ContinueWatchingItem[] = [];
|
||||
const thirtyDaysAgo = Date.now() - (30 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const sortedPlaybackItems = [...playbackItems]
|
||||
.sort((a, b) => new Date(b.paused_at).getTime() - new Date(a.paused_at).getTime())
|
||||
.slice(0, 30);
|
||||
|
||||
for (const item of sortedPlaybackItems) {
|
||||
try {
|
||||
// Skip accidental clicks
|
||||
if ((item.progress ?? 0) < 2) continue;
|
||||
|
||||
const pausedAt = new Date(item.paused_at).getTime();
|
||||
if (pausedAt < thirtyDaysAgo) continue;
|
||||
|
||||
if (item.type === 'movie' && item.movie?.ids?.imdb) {
|
||||
// Skip completed movies
|
||||
if (item.progress >= 85) continue;
|
||||
|
||||
const imdbId = item.movie.ids.imdb.startsWith('tt')
|
||||
? item.movie.ids.imdb
|
||||
: `tt${item.movie.ids.imdb}`;
|
||||
|
||||
const movieKey = `movie:${imdbId}`;
|
||||
if (recentlyRemovedRef.current.has(movieKey)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('movie', imdbId);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
simklBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: imdbId,
|
||||
type: 'movie',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
addonId: undefined,
|
||||
} as ContinueWatchingItem);
|
||||
} else if (item.type === 'episode' && item.show?.ids?.imdb && item.episode) {
|
||||
const showImdb = item.show.ids.imdb.startsWith('tt')
|
||||
? item.show.ids.imdb
|
||||
: `tt${item.show.ids.imdb}`;
|
||||
|
||||
const episodeNum = (item.episode as any).episode ?? (item.episode as any).number;
|
||||
if (episodeNum === undefined || episodeNum === null) {
|
||||
logger.warn('[CW][Simkl] Missing episode number in playback item, skipping', item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const showKey = `series:${showImdb}`;
|
||||
if (recentlyRemovedRef.current.has(showKey)) continue;
|
||||
|
||||
const cachedData = await getCachedMetadata('series', showImdb);
|
||||
if (!cachedData?.basicContent) continue;
|
||||
|
||||
// If episode is completed (>= 85%), find next episode
|
||||
if (item.progress >= 85) {
|
||||
const metadata = cachedData.metadata;
|
||||
if (metadata?.videos) {
|
||||
const nextEpisode = findNextEpisode(
|
||||
item.episode.season,
|
||||
episodeNum,
|
||||
metadata.videos,
|
||||
undefined,
|
||||
showImdb
|
||||
);
|
||||
|
||||
if (nextEpisode) {
|
||||
simklBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: 0,
|
||||
lastUpdated: pausedAt,
|
||||
season: nextEpisode.season,
|
||||
episode: nextEpisode.episode,
|
||||
episodeTitle: nextEpisode.title || `Episode ${nextEpisode.episode}`,
|
||||
addonId: undefined,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
simklBatch.push({
|
||||
...cachedData.basicContent,
|
||||
id: showImdb,
|
||||
type: 'series',
|
||||
progress: item.progress,
|
||||
lastUpdated: pausedAt,
|
||||
season: item.episode.season,
|
||||
episode: episodeNum,
|
||||
episodeTitle: item.episode.title || `Episode ${episodeNum}`,
|
||||
addonId: undefined,
|
||||
} as ContinueWatchingItem);
|
||||
}
|
||||
} catch {
|
||||
// Continue with other items
|
||||
}
|
||||
}
|
||||
|
||||
if (simklBatch.length === 0) {
|
||||
setContinueWatchingItems([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Dedupe (keep most recent per show/movie)
|
||||
const deduped = new Map<string, ContinueWatchingItem>();
|
||||
for (const item of simklBatch) {
|
||||
const key = `${item.type}:${item.id}`;
|
||||
const existing = deduped.get(key);
|
||||
if (!existing || (item.lastUpdated ?? 0) > (existing.lastUpdated ?? 0)) {
|
||||
deduped.set(key, item);
|
||||
}
|
||||
}
|
||||
|
||||
// Filter removed items
|
||||
const filteredItems: ContinueWatchingItem[] = [];
|
||||
for (const item of deduped.values()) {
|
||||
const key = item.type === 'series' && item.season && item.episode
|
||||
? `${item.type}:${item.id}:${item.season}:${item.episode}`
|
||||
: `${item.type}:${item.id}`;
|
||||
if (recentlyRemovedRef.current.has(key)) continue;
|
||||
|
||||
const removeId = item.type === 'series' && item.season && item.episode
|
||||
? `${item.id}:${item.season}:${item.episode}`
|
||||
: item.id;
|
||||
const isRemoved = await storageService.isContinueWatchingRemoved(removeId, item.type);
|
||||
if (!isRemoved) filteredItems.push(item);
|
||||
}
|
||||
|
||||
// Overlay local progress when local is ahead or newer
|
||||
const adjustedItems = filteredItems.map((it) => {
|
||||
if (!localProgressIndex) return it;
|
||||
|
||||
const matches: LocalProgressEntry[] = [];
|
||||
for (const idVariant of getIdVariants(it.id)) {
|
||||
const list = localProgressIndex.get(`${it.type}:${idVariant}`);
|
||||
if (!list) continue;
|
||||
for (const entry of list) {
|
||||
if (it.type === 'series' && it.season !== undefined && it.episode !== undefined) {
|
||||
if (entry.season === it.season && entry.episode === it.episode) {
|
||||
matches.push(entry);
|
||||
}
|
||||
} else {
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return it;
|
||||
|
||||
const mostRecentLocal = matches.reduce<LocalProgressEntry | null>((acc, cur) => {
|
||||
if (!acc) return cur;
|
||||
return (cur.lastUpdated ?? 0) > (acc.lastUpdated ?? 0) ? cur : acc;
|
||||
}, null);
|
||||
|
||||
const highestLocal = matches.reduce<LocalProgressEntry | null>((acc, cur) => {
|
||||
if (!acc) return cur;
|
||||
return (cur.progressPercent ?? 0) > (acc.progressPercent ?? 0) ? cur : acc;
|
||||
}, null);
|
||||
|
||||
if (!mostRecentLocal || !highestLocal) return it;
|
||||
|
||||
const localProgress = mostRecentLocal.progressPercent;
|
||||
const simklProgress = it.progress ?? 0;
|
||||
const localTs = mostRecentLocal.lastUpdated ?? 0;
|
||||
const simklTs = it.lastUpdated ?? 0;
|
||||
|
||||
const isAhead = isFinite(localProgress) && localProgress > simklProgress + 0.5;
|
||||
const isLocalNewer = localTs > simklTs + 5000;
|
||||
|
||||
if (isAhead || isLocalNewer) {
|
||||
return {
|
||||
...it,
|
||||
progress: localProgress,
|
||||
lastUpdated: localTs > 0 ? localTs : it.lastUpdated,
|
||||
} as ContinueWatchingItem;
|
||||
}
|
||||
|
||||
// Otherwise keep Simkl, but if local has a newer timestamp, use it for ordering
|
||||
if (localTs > 0 && localTs > simklTs) {
|
||||
return {
|
||||
...it,
|
||||
lastUpdated: localTs,
|
||||
} as ContinueWatchingItem;
|
||||
}
|
||||
|
||||
return it;
|
||||
});
|
||||
|
||||
adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0));
|
||||
setContinueWatchingItems(adjustedItems);
|
||||
} catch (err) {
|
||||
logger.error('[SimklSync] Error in Simkl merge:', err);
|
||||
}
|
||||
})();
|
||||
|
||||
// Wait for all groups and provider merges to settle, then finalize loading state
|
||||
await Promise.allSettled([...groupPromises, traktMergePromise, simklMergePromise]);
|
||||
} catch (error) {
|
||||
// Continue even if loading fails
|
||||
} finally {
|
||||
|
|
@ -1943,7 +2165,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
style={styles.container}
|
||||
>
|
||||
<View style={[styles.header, { paddingHorizontal: horizontalPadding }]}>
|
||||
|
|
@ -2082,7 +2305,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
)}
|
||||
</BottomSheetView>
|
||||
</BottomSheetModal>
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -143,7 +143,8 @@ export const ThisWeekSection = React.memo(() => {
|
|||
if (!thisWeekSection) return [];
|
||||
|
||||
// Get raw episodes (limit to 60 to be safe for performance but allow grouping)
|
||||
const rawEpisodes = memoryManager.limitArraySize(thisWeekSection.data, 60);
|
||||
|
||||
const rawEpisodes = memoryManager.limitArraySize(thisWeekSection.data.filter(ep => ep.season !== 0), 60);
|
||||
|
||||
// Group by series and date
|
||||
const groups: Record<string, typeof rawEpisodes> = {};
|
||||
|
|
|
|||
23
src/components/icons/SimklIcon.tsx
Normal file
23
src/components/icons/SimklIcon.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from 'react';
|
||||
import { Image, StyleSheet } from 'react-native';
|
||||
|
||||
interface SimklIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
const SimklIcon: React.FC<SimklIconProps> = ({ size = 24, color = '#000000', style }) => {
|
||||
return (
|
||||
<Image
|
||||
source={require('../../../assets/simkl-favicon.png')}
|
||||
style={[
|
||||
{ width: size, height: size, flex: 1 },
|
||||
style
|
||||
]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimklIcon;
|
||||
|
|
@ -5,14 +5,15 @@ import Svg, { Path } from 'react-native-svg';
|
|||
interface TraktIconProps {
|
||||
size?: number;
|
||||
color?: string;
|
||||
style?: any;
|
||||
}
|
||||
|
||||
const TraktIcon: React.FC<TraktIconProps> = ({ size = 24, color = '#ed2224' }) => {
|
||||
const TraktIcon: React.FC<TraktIconProps> = ({ size = 24, color = '#ed2224', style }) => {
|
||||
return (
|
||||
<View style={{ width: size, height: size }}>
|
||||
<View style={[{ width: size, height: size, flex: 1 }, style]}>
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
width="100%"
|
||||
height="100%"
|
||||
viewBox="0 0 144.8 144.8"
|
||||
>
|
||||
<Path
|
||||
|
|
|
|||
|
|
@ -33,7 +33,6 @@ import Animated, {
|
|||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withTiming,
|
||||
withSpring,
|
||||
runOnJS,
|
||||
} from 'react-native-reanimated';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -83,7 +82,7 @@ export const CastDetailsModal: React.FC<CastDetailsModalProps> = ({
|
|||
useEffect(() => {
|
||||
if (visible && castMember) {
|
||||
modalOpacity.value = withTiming(1, { duration: 250 });
|
||||
modalScale.value = withSpring(1, { damping: 20, stiffness: 200 });
|
||||
modalScale.value = withTiming(1, { duration: 250 });
|
||||
|
||||
if (!hasFetched || personDetails?.id !== castMember.id) {
|
||||
fetchPersonDetails();
|
||||
|
|
|
|||
|
|
@ -351,7 +351,6 @@ const CompactCommentCard: React.FC<{
|
|||
onPressIn={() => setIsPressed(true)}
|
||||
onPressOut={() => setIsPressed(false)}
|
||||
onPress={() => {
|
||||
console.log('CompactCommentCard: TouchableOpacity pressed for comment:', comment.id);
|
||||
onPress();
|
||||
}}
|
||||
activeOpacity={1}
|
||||
|
|
@ -789,26 +788,21 @@ export const CommentsSection: React.FC<CommentsSectionProps> = ({
|
|||
}, [loading]);
|
||||
|
||||
// Debug logging
|
||||
console.log('CommentsSection: Comments data:', comments);
|
||||
console.log('CommentsSection: Comments length:', comments?.length);
|
||||
console.log('CommentsSection: Loading:', loading);
|
||||
console.log('CommentsSection: Error:', error);
|
||||
// Debug logging removed per user request
|
||||
|
||||
const renderComment = useCallback(({ item }: { item: TraktContentComment }) => {
|
||||
// Safety check for null/undefined items
|
||||
if (!item || !item.id) {
|
||||
console.log('CommentsSection: Invalid comment item:', item);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('CommentsSection: Rendering comment:', item.id);
|
||||
|
||||
|
||||
return (
|
||||
<CompactCommentCard
|
||||
comment={item}
|
||||
theme={currentTheme}
|
||||
onPress={() => {
|
||||
console.log('CommentsSection: Comment pressed:', item.id);
|
||||
onCommentPress?.(item);
|
||||
}}
|
||||
isSpoilerRevealed={true}
|
||||
|
|
|
|||
|
|
@ -925,7 +925,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
// Handle trailer preload completion
|
||||
const handleTrailerPreloaded = useCallback(() => {
|
||||
setTrailerPreloaded(true);
|
||||
logger.info('HeroSection', 'Trailer preloaded successfully');
|
||||
// logger.info('HeroSection', 'Trailer preloaded successfully');
|
||||
}, []);
|
||||
|
||||
// Handle smooth transition when trailer is ready to play
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ import { isMDBListEnabled } from '../../screens/MDBListSettingsScreen';
|
|||
import { getAgeRatingColor } from '../../utils/ageRatingColors';
|
||||
import AgeRatingBadge from '../common/AgeRatingBadge';
|
||||
|
||||
const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png';
|
||||
|
||||
// Enhanced responsive breakpoints for Metadata Details
|
||||
const BREAKPOINTS = {
|
||||
phone: 0,
|
||||
|
|
@ -108,26 +110,32 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
checkMDBListEnabled();
|
||||
}, []);
|
||||
|
||||
const handleTextLayout = (event: any) => {
|
||||
const { lines } = event.nativeEvent;
|
||||
// If we have 3 or more lines, it means the text was truncated
|
||||
setIsTextTruncated(lines.length >= 3);
|
||||
};
|
||||
|
||||
|
||||
const handleCollapsedTextLayout = (event: any) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setMeasuredHeights(prev => ({ ...prev, collapsed: height }));
|
||||
// Only set initial measurement flag once we have a valid height
|
||||
setMeasuredHeights(prev => {
|
||||
const newHeights = { ...prev, collapsed: height };
|
||||
if (newHeights.expanded > 0 && height > 0) {
|
||||
setIsTextTruncated(newHeights.expanded > height);
|
||||
}
|
||||
return newHeights;
|
||||
});
|
||||
if (height > 0 && !hasInitialMeasurement) {
|
||||
setHasInitialMeasurement(true);
|
||||
// Update animated height immediately without animation for first measurement
|
||||
animatedHeight.value = height;
|
||||
}
|
||||
};
|
||||
|
||||
const handleExpandedTextLayout = (event: any) => {
|
||||
const { height } = event.nativeEvent.layout;
|
||||
setMeasuredHeights(prev => ({ ...prev, expanded: height }));
|
||||
setMeasuredHeights(prev => {
|
||||
const newHeights = { ...prev, expanded: height };
|
||||
if (newHeights.collapsed > 0 && height > 0) {
|
||||
setIsTextTruncated(height > newHeights.collapsed);
|
||||
}
|
||||
return newHeights;
|
||||
});
|
||||
};
|
||||
|
||||
// Animate height changes
|
||||
|
|
@ -233,6 +241,17 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
)}
|
||||
{metadata.imdbRating && !isMDBEnabled && (
|
||||
<View style={styles.ratingContainer}>
|
||||
<FastImage
|
||||
source={{ uri: IMDb_LOGO }}
|
||||
style={[
|
||||
styles.imdbLogo,
|
||||
{
|
||||
width: isTV ? 35 : isLargeTablet ? 32 : isTablet ? 30 : 30,
|
||||
height: isTV ? 18 : isLargeTablet ? 16 : isTablet ? 15 : 15
|
||||
}
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.ratingText,
|
||||
{
|
||||
|
|
@ -369,7 +388,6 @@ const MetadataDetails: React.FC<MetadataDetailsProps> = ({
|
|||
}
|
||||
]}
|
||||
numberOfLines={isFullDescriptionOpen ? undefined : 3}
|
||||
onTextLayout={handleTextLayout}
|
||||
>
|
||||
{metadata.description}
|
||||
</Text>
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ const BREAKPOINTS = {
|
|||
tv: 1440,
|
||||
};
|
||||
|
||||
const IMDb_LOGO = 'https://upload.wikimedia.org/wikipedia/commons/thumb/6/69/IMDB_Logo_2016.svg/575px-IMDB_Logo_2016.svg.png';
|
||||
|
||||
export const RATING_PROVIDERS = {
|
||||
imdb: {
|
||||
name: 'IMDb',
|
||||
|
|
@ -160,8 +162,8 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
const ratingConfig = {
|
||||
imdb: {
|
||||
name: 'IMDb',
|
||||
icon: null, // No icon for IMDb
|
||||
isImage: false,
|
||||
icon: { uri: IMDb_LOGO },
|
||||
isImage: true,
|
||||
color: '#F5C518',
|
||||
transform: (value: number) => value.toFixed(1)
|
||||
},
|
||||
|
|
@ -245,7 +247,7 @@ export const RatingsSection: React.FC<RatingsSectionProps> = ({ imdbId, type })
|
|||
{config.isImage ? (
|
||||
<Image
|
||||
source={config.icon as any}
|
||||
style={[styles.compactRatingIcon, { width: iconSize, height: iconSize, marginRight: iconTextGap }]}
|
||||
style={[styles.compactRatingIcon, { width: source === 'imdb' ? iconSize * 2 : iconSize, height: iconSize, marginRight: iconTextGap }]}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
) : config.icon ? (
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ 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,
|
||||
|
|
@ -1200,7 +1201,18 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
<View style={styles.ratingContainer}>
|
||||
{isImdbRating ? (
|
||||
<>
|
||||
|
||||
<FastImage
|
||||
source={{ uri: IMDb_LOGO }}
|
||||
style={[
|
||||
styles.tmdbLogo, // Reuse same style for dimensions
|
||||
{
|
||||
width: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24,
|
||||
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
marginRight: 4
|
||||
}
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.ratingText,
|
||||
{
|
||||
|
|
@ -1456,7 +1468,18 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
|
|||
<View style={styles.ratingContainerHorizontal}>
|
||||
{isImdbRating ? (
|
||||
<>
|
||||
|
||||
<FastImage
|
||||
source={{ uri: IMDb_LOGO }}
|
||||
style={[
|
||||
styles.tmdbLogo, // Reuse same style
|
||||
{
|
||||
width: isTV ? 28 : isLargeTablet ? 26 : isTablet ? 24 : 24,
|
||||
height: isTV ? 16 : isLargeTablet ? 15 : isTablet ? 14 : 14,
|
||||
marginRight: 4
|
||||
}
|
||||
]}
|
||||
resizeMode={FastImage.resizeMode.contain}
|
||||
/>
|
||||
<Text style={[
|
||||
styles.ratingTextHorizontal,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useRef, useEffect, useMemo, useCallback, useState } from 'react';
|
||||
import { View, StyleSheet, Platform, Animated, ToastAndroid, ActivityIndicator, Text } from 'react-native';
|
||||
import { View, StyleSheet, Platform, Animated, ToastAndroid, ActivityIndicator } from 'react-native';
|
||||
import { toast } from '@backpackapp-io/react-native-toast';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
|
|
@ -44,6 +44,7 @@ import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
|||
import SkipIntroButton from './overlays/SkipIntroButton';
|
||||
import UpNextButton from './common/UpNextButton';
|
||||
import { CustomAlert } from '../CustomAlert';
|
||||
import { CreditsInfo } from '../../services/introService';
|
||||
|
||||
|
||||
// Android-specific components
|
||||
|
|
@ -146,36 +147,38 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
// Subtitle sync modal state
|
||||
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||
|
||||
// Credits timing state from API
|
||||
const [creditsInfo, setCreditsInfo] = useState<CreditsInfo | null>(null);
|
||||
|
||||
// Track auto-selection ref to prevent duplicate selections
|
||||
const hasAutoSelectedTracks = useRef(false);
|
||||
|
||||
// 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)) {
|
||||
setSubtitleOffsetSec(0);
|
||||
}
|
||||
|
||||
|
||||
// Update the ref for next comparison
|
||||
previousVideoRef.current = currentVideo;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [uri, episodeId]);
|
||||
|
||||
const metadataResult = useMetadata({ id: id || 'placeholder', type: (type as any) });
|
||||
const { metadata, cast } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [] };
|
||||
const { metadata, cast, tmdbId } = Boolean(id && type) ? (metadataResult as any) : { metadata: null, cast: [], tmdbId: null };
|
||||
const hasLogo = metadata && metadata.logo;
|
||||
const openingAnimation = useOpeningAnimation(backdrop, metadata);
|
||||
|
||||
const [volume, setVolume] = useState(1.0);
|
||||
const [brightness, setBrightness] = useState(1.0);
|
||||
const setupHook = usePlayerSetup(playerState.setScreenDimensions, setVolume, setBrightness, playerState.paused);
|
||||
const setupHook = usePlayerSetup(playerState.setScreenDimensions, setVolume, playerState.paused);
|
||||
|
||||
const controlsHook = usePlayerControls(
|
||||
mpvPlayerRef,
|
||||
|
|
@ -220,8 +223,6 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
const gestureControls = usePlayerGestureControls({
|
||||
volume,
|
||||
setVolume,
|
||||
brightness,
|
||||
setBrightness,
|
||||
volumeRange: { min: 0, max: 1 },
|
||||
volumeSensitivity: 0.006,
|
||||
brightnessSensitivity: 0.004,
|
||||
|
|
@ -754,9 +755,21 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
}, []);
|
||||
|
||||
const cycleResizeMode = useCallback(() => {
|
||||
if (playerState.resizeMode === 'contain') playerState.setResizeMode('cover');
|
||||
else playerState.setResizeMode('contain');
|
||||
}, [playerState.resizeMode]);
|
||||
gestureControls.showResizeModeOverlayFn(() => {
|
||||
switch (playerState.resizeMode) {
|
||||
case 'contain':
|
||||
playerState.setResizeMode('cover');
|
||||
break;
|
||||
case 'cover':
|
||||
playerState.setResizeMode('stretch');
|
||||
break;
|
||||
case 'stretch':
|
||||
default:
|
||||
playerState.setResizeMode('contain');
|
||||
break;
|
||||
}
|
||||
});
|
||||
}, [playerState.resizeMode, gestureControls.showResizeModeOverlayFn]);
|
||||
|
||||
// Memoize selectedTextTrack to prevent unnecessary re-renders
|
||||
const memoizedSelectedTextTrack = useMemo(() => {
|
||||
|
|
@ -767,8 +780,6 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
|
||||
return (
|
||||
<View style={[styles.container, {
|
||||
width: playerState.screenDimensions.width,
|
||||
height: playerState.screenDimensions.height,
|
||||
position: 'absolute', top: 0, left: 0
|
||||
}]}>
|
||||
<LoadingOverlay
|
||||
|
|
@ -787,6 +798,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
{!isTransitioningStream && (
|
||||
<VideoSurface
|
||||
processedStreamUrl={currentStreamUrl}
|
||||
videoType={currentVideoType}
|
||||
headers={headers}
|
||||
volume={volume}
|
||||
playbackSpeed={speedControl.playbackSpeed}
|
||||
|
|
@ -825,7 +837,9 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
modals.setErrorDetails(displayError);
|
||||
modals.setShowErrorModal(true);
|
||||
}}
|
||||
onBuffer={(buf) => playerState.setIsBuffering(buf.isBuffering)}
|
||||
onBuffer={(buf) => {
|
||||
playerState.setIsBuffering(buf.isBuffering);
|
||||
}}
|
||||
onTracksChanged={(data) => {
|
||||
console.log('[AndroidVideoPlayer] onTracksChanged:', data);
|
||||
if (data?.audioTracks) {
|
||||
|
|
@ -908,10 +922,22 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
showControls={playerState.showControls}
|
||||
hideControls={hideControls}
|
||||
volume={volume}
|
||||
brightness={brightness}
|
||||
controlsTimeout={controlsTimeout}
|
||||
resizeMode={playerState.resizeMode}
|
||||
skip={controlsHook.skip}
|
||||
currentTime={playerState.currentTime}
|
||||
duration={playerState.duration}
|
||||
seekToTime={controlsHook.seekToTime}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
|
||||
{/* Buffering Indicator (Visible when controls are hidden) */}
|
||||
{playerState.isBuffering && !playerState.showControls && (
|
||||
<View style={[StyleSheet.absoluteFill, { justifyContent: 'center', alignItems: 'center', zIndex: 15 }]}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayerControls
|
||||
showControls={playerState.showControls}
|
||||
fadeAnim={fadeAnim}
|
||||
|
|
@ -959,6 +985,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
playerBackend={useExoPlayer ? 'ExoPlayer' : 'MPV'}
|
||||
onSwitchToMPV={handleManualSwitchToMPV}
|
||||
useExoPlayer={useExoPlayer}
|
||||
isBuffering={playerState.isBuffering}
|
||||
/>
|
||||
|
||||
<SpeedActivatedOverlay
|
||||
|
|
@ -999,8 +1026,10 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
episode={episode}
|
||||
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
||||
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
||||
tmdbId={tmdbId || undefined}
|
||||
currentTime={playerState.currentTime}
|
||||
onSkip={(endTime) => controlsHook.seekToTime(endTime)}
|
||||
onCreditsInfo={setCreditsInfo}
|
||||
controlsVisible={playerState.showControls}
|
||||
controlsFixedOffset={100}
|
||||
/>
|
||||
|
|
@ -1026,6 +1055,7 @@ const AndroidVideoPlayer: React.FC = () => {
|
|||
metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined}
|
||||
controlsVisible={playerState.showControls}
|
||||
controlsFixedOffset={100}
|
||||
creditsInfo={creditsInfo}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { View, StatusBar, StyleSheet, Animated, Dimensions } from 'react-native';
|
||||
import { View, StatusBar, StyleSheet, Animated, Dimensions, ActivityIndicator } from 'react-native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import axios from 'axios';
|
||||
|
|
@ -21,6 +21,7 @@ import ResumeOverlay from './modals/ResumeOverlay';
|
|||
import ParentalGuideOverlay from './overlays/ParentalGuideOverlay';
|
||||
import SkipIntroButton from './overlays/SkipIntroButton';
|
||||
import { SpeedActivatedOverlay, PauseOverlay, GestureControls } from './components';
|
||||
import { CreditsInfo } from '../../services/introService';
|
||||
|
||||
// Platform-specific components
|
||||
import { KSPlayerSurface } from './ios/components/KSPlayerSurface';
|
||||
|
|
@ -69,6 +70,7 @@ interface PlayerRouteParams {
|
|||
year?: number;
|
||||
streamProvider?: string;
|
||||
streamName?: string;
|
||||
videoType?: string;
|
||||
id: string;
|
||||
type: string;
|
||||
episodeId?: string;
|
||||
|
|
@ -94,6 +96,42 @@ const KSPlayerCore: React.FC = () => {
|
|||
initialPosition: routeInitialPosition
|
||||
} = params;
|
||||
|
||||
const videoType = (params as any)?.videoType as string | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!__DEV__) return;
|
||||
const headerKeys = Object.keys(headers || {});
|
||||
logger.log('[KSPlayerCore] route params', {
|
||||
uri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
|
||||
id,
|
||||
type,
|
||||
episodeId,
|
||||
imdbId,
|
||||
title,
|
||||
episodeTitle,
|
||||
season,
|
||||
episode,
|
||||
quality,
|
||||
year,
|
||||
streamProvider,
|
||||
streamName,
|
||||
videoType,
|
||||
headersKeys: headerKeys,
|
||||
headersCount: headerKeys.length,
|
||||
});
|
||||
}, [uri, episodeId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!__DEV__) return;
|
||||
const headerKeys = Object.keys(headers || {});
|
||||
logger.log('[KSPlayerCore] source update', {
|
||||
uri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
|
||||
videoType,
|
||||
headersCount: headerKeys.length,
|
||||
headersKeys: headerKeys,
|
||||
});
|
||||
}, [uri, headers, videoType]);
|
||||
|
||||
// --- Hooks ---
|
||||
const playerState = usePlayerState();
|
||||
const {
|
||||
|
|
@ -119,7 +157,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
const speedControl = useSpeedControl(1.0);
|
||||
|
||||
// Metadata Hook
|
||||
const { metadata, groupedEpisodes, cast } = useMetadata({ id, type: type as 'movie' | 'series' });
|
||||
const { metadata, groupedEpisodes, cast, tmdbId } = useMetadata({ id, type: type as 'movie' | 'series' });
|
||||
|
||||
// Trakt Autosync
|
||||
const traktAutosync = useTraktAutosync({
|
||||
|
|
@ -142,6 +180,9 @@ const KSPlayerCore: React.FC = () => {
|
|||
// Subtitle sync modal state
|
||||
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||
|
||||
// Credits timing state from API
|
||||
const [creditsInfo, setCreditsInfo] = useState<CreditsInfo | null>(null);
|
||||
|
||||
// Track auto-selection refs to prevent duplicate selections
|
||||
const hasAutoSelectedTracks = useRef(false);
|
||||
|
||||
|
|
@ -455,6 +496,17 @@ const KSPlayerCore: React.FC = () => {
|
|||
|
||||
// Handlers
|
||||
const onLoad = (data: any) => {
|
||||
if (__DEV__) {
|
||||
logger.log('[KSPlayerCore] onLoad', {
|
||||
uri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
|
||||
duration: data?.duration,
|
||||
audioTracksCount: Array.isArray(data?.audioTracks) ? data.audioTracks.length : 0,
|
||||
textTracksCount: Array.isArray(data?.textTracks) ? data.textTracks.length : 0,
|
||||
videoType,
|
||||
headersKeys: Object.keys(headers || {}),
|
||||
});
|
||||
}
|
||||
|
||||
setDuration(data.duration);
|
||||
if (data.audioTracks) tracks.setKsAudioTracks(data.audioTracks);
|
||||
if (data.textTracks) tracks.setKsTextTracks(data.textTracks);
|
||||
|
|
@ -538,6 +590,18 @@ const KSPlayerCore: React.FC = () => {
|
|||
} catch (e) {
|
||||
msg = 'Error parsing error details';
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
logger.error('[KSPlayerCore] onError', {
|
||||
msg,
|
||||
uri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
|
||||
videoType,
|
||||
streamProvider,
|
||||
streamName,
|
||||
headersKeys: Object.keys(headers || {}),
|
||||
rawError: error,
|
||||
});
|
||||
}
|
||||
modals.setErrorDetails(msg);
|
||||
modals.setShowErrorModal(true);
|
||||
};
|
||||
|
|
@ -581,6 +645,17 @@ const KSPlayerCore: React.FC = () => {
|
|||
modals.setShowSourcesModal(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (__DEV__) {
|
||||
logger.log('[KSPlayerCore] switching stream', {
|
||||
fromUri: typeof uri === 'string' ? uri.slice(0, 240) : uri,
|
||||
toUri: typeof newStream?.url === 'string' ? newStream.url.slice(0, 240) : newStream?.url,
|
||||
newStreamHeadersKeys: Object.keys(newStream?.headers || {}),
|
||||
newProvider: newStream?.addonName || newStream?.name || newStream?.addon || 'Unknown',
|
||||
newName: newStream?.name || newStream?.title || 'Unknown',
|
||||
});
|
||||
}
|
||||
|
||||
modals.setShowSourcesModal(false);
|
||||
setPaused(true);
|
||||
|
||||
|
|
@ -615,6 +690,19 @@ const KSPlayerCore: React.FC = () => {
|
|||
setPaused(true);
|
||||
const ep = modals.selectedEpisodeForStreams;
|
||||
|
||||
if (__DEV__) {
|
||||
logger.log('[KSPlayerCore] switching episode stream', {
|
||||
toUri: typeof stream?.url === 'string' ? stream.url.slice(0, 240) : stream?.url,
|
||||
streamHeadersKeys: Object.keys(stream?.headers || {}),
|
||||
ep: {
|
||||
season: ep?.season_number,
|
||||
episode: ep?.episode_number,
|
||||
name: ep?.name,
|
||||
stremioId: ep?.stremioId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const newQuality = stream.quality || (stream.title?.match(/(\d+)p/)?.[0]);
|
||||
const newProvider = stream.addonName || stream.name || stream.addon || 'Unknown';
|
||||
const newStreamName = stream.name || stream.title || 'Unknown Stream';
|
||||
|
|
@ -654,6 +742,20 @@ const KSPlayerCore: React.FC = () => {
|
|||
controls.seekToTime(value);
|
||||
};
|
||||
|
||||
const handleProgress = useCallback((d: any) => {
|
||||
if (!isSliderDragging) {
|
||||
setCurrentTime(d.currentTime);
|
||||
}
|
||||
// Only update buffered if it changed by more than 0.5s to reduce re-renders
|
||||
const newBuffered = d.buffered || 0;
|
||||
setBuffered(prevBuffered => {
|
||||
if (Math.abs(newBuffered - prevBuffered) > 0.5) {
|
||||
return newBuffered;
|
||||
}
|
||||
return prevBuffered;
|
||||
});
|
||||
}, [isSliderDragging, setCurrentTime, setBuffered]);
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: '#000000' }}>
|
||||
<StatusBar hidden={true} />
|
||||
|
|
@ -693,25 +795,22 @@ const KSPlayerCore: React.FC = () => {
|
|||
onAudioTracks={(d) => tracks.setKsAudioTracks(d.audioTracks || [])}
|
||||
onTextTracks={(d) => tracks.setKsTextTracks(d.textTracks || [])}
|
||||
onLoad={onLoad}
|
||||
onProgress={(d) => {
|
||||
if (!isSliderDragging) {
|
||||
setCurrentTime(d.currentTime);
|
||||
}
|
||||
// Only update buffered if it changed by more than 0.5s to reduce re-renders
|
||||
const newBuffered = d.buffered || 0;
|
||||
if (Math.abs(newBuffered - buffered) > 0.5) {
|
||||
setBuffered(newBuffered);
|
||||
}
|
||||
}}
|
||||
onProgress={handleProgress}
|
||||
onEnd={async () => {
|
||||
setCurrentTime(duration);
|
||||
await traktAutosync.handlePlaybackEnd(duration, duration, 'ended');
|
||||
}}
|
||||
onError={handleError}
|
||||
onBuffer={setIsBuffering}
|
||||
onBuffer={(b) => {
|
||||
setIsBuffering(b);
|
||||
}}
|
||||
onReadyForDisplay={() => setIsPlayerReady(true)}
|
||||
onPlaybackStalled={() => setIsBuffering(true)}
|
||||
onPlaybackResume={() => setIsBuffering(false)}
|
||||
onPlaybackStalled={() => {
|
||||
setIsBuffering(true);
|
||||
}}
|
||||
onPlaybackResume={() => {
|
||||
setIsBuffering(false);
|
||||
}}
|
||||
screenWidth={screenDimensions.width}
|
||||
screenHeight={screenDimensions.height}
|
||||
customVideoStyles={{ width: '100%', height: '100%' }}
|
||||
|
|
@ -769,11 +868,24 @@ const KSPlayerCore: React.FC = () => {
|
|||
volume={volume}
|
||||
brightness={brightness}
|
||||
controlsTimeout={controlsTimeout}
|
||||
resizeMode={resizeMode}
|
||||
skip={controls.skip}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
seekToTime={controls.seekToTime}
|
||||
formatTime={formatTime}
|
||||
/>
|
||||
|
||||
{/* UI Controls */}
|
||||
{isVideoLoaded && (
|
||||
<View pointerEvents="box-none" style={StyleSheet.absoluteFill}>
|
||||
{/* Buffering Indicator (Visible when controls are hidden) */}
|
||||
{isBuffering && !showControls && (
|
||||
<View style={[StyleSheet.absoluteFill, { justifyContent: 'center', alignItems: 'center', zIndex: 15 }]}>
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<PlayerControls
|
||||
showControls={showControls}
|
||||
fadeAnim={fadeAnim}
|
||||
|
|
@ -796,7 +908,21 @@ const KSPlayerCore: React.FC = () => {
|
|||
togglePlayback={controls.togglePlayback}
|
||||
skip={controls.skip}
|
||||
handleClose={handleClose}
|
||||
cycleAspectRatio={() => setResizeMode(prev => prev === 'cover' ? 'contain' : 'cover')}
|
||||
cycleAspectRatio={() => {
|
||||
gestureControls.showResizeModeOverlayFn(() => {
|
||||
setResizeMode(prev => {
|
||||
switch (prev) {
|
||||
case 'contain':
|
||||
return 'cover';
|
||||
case 'cover':
|
||||
return 'stretch';
|
||||
case 'stretch':
|
||||
default:
|
||||
return 'contain';
|
||||
}
|
||||
});
|
||||
});
|
||||
}}
|
||||
cyclePlaybackSpeed={() => speedControl.setPlaybackSpeed(speedControl.playbackSpeed >= 2 ? 1 : speedControl.playbackSpeed + 0.25)}
|
||||
currentPlaybackSpeed={speedControl.playbackSpeed}
|
||||
setShowAudioModal={modals.setShowAudioModal}
|
||||
|
|
@ -814,6 +940,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
isAirPlayActive={isAirPlayActive}
|
||||
allowsAirPlay={allowsAirPlay}
|
||||
onAirPlayPress={() => ksPlayerRef.current?.showAirPlayPicker()}
|
||||
isBuffering={isBuffering}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
|
@ -875,8 +1002,10 @@ const KSPlayerCore: React.FC = () => {
|
|||
episode={episode}
|
||||
malId={(metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id}
|
||||
kitsuId={id?.startsWith('kitsu:') ? id.split(':')[1] : undefined}
|
||||
tmdbId={tmdbId || undefined}
|
||||
currentTime={currentTime}
|
||||
onSkip={(endTime) => controls.seekToTime(endTime)}
|
||||
onCreditsInfo={setCreditsInfo}
|
||||
controlsVisible={showControls}
|
||||
controlsFixedOffset={126}
|
||||
/>
|
||||
|
|
@ -902,6 +1031,7 @@ const KSPlayerCore: React.FC = () => {
|
|||
metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined}
|
||||
controlsVisible={showControls}
|
||||
controlsFixedOffset={126}
|
||||
creditsInfo={creditsInfo}
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export interface MpvPlayerRef {
|
|||
seek: (positionSeconds: number) => void;
|
||||
setAudioTrack: (trackId: number) => void;
|
||||
setSubtitleTrack: (trackId: number) => void;
|
||||
setResizeMode: (mode: 'contain' | 'cover' | 'stretch') => void;
|
||||
}
|
||||
|
||||
export interface MpvPlayerProps {
|
||||
|
|
@ -65,6 +66,9 @@ const MpvPlayer = forwardRef<MpvPlayerRef, MpvPlayerProps>((props, ref) => {
|
|||
setSubtitleTrack: (trackId: number) => {
|
||||
dispatchCommand('setSubtitleTrack', [trackId]);
|
||||
},
|
||||
setResizeMode: (mode: 'contain' | 'cover' | 'stretch') => {
|
||||
dispatchCommand('setResizeMode', [mode]);
|
||||
},
|
||||
}), [dispatchCommand]);
|
||||
|
||||
if (Platform.OS !== 'android' || !MpvPlayerNative) {
|
||||
|
|
|
|||
|
|
@ -1,194 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import {
|
||||
TapGestureHandler,
|
||||
PanGestureHandler,
|
||||
LongPressGestureHandler,
|
||||
State
|
||||
} from 'react-native-gesture-handler';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { styles as localStyles } from '../../utils/playerStyles';
|
||||
|
||||
interface GestureControlsProps {
|
||||
screenDimensions: { width: number, height: number };
|
||||
gestureControls: any;
|
||||
onLongPressActivated: () => void;
|
||||
onLongPressEnd: () => void;
|
||||
onLongPressStateChange: (event: any) => void;
|
||||
toggleControls: () => void;
|
||||
showControls: boolean;
|
||||
hideControls: () => void;
|
||||
volume: number;
|
||||
brightness: number;
|
||||
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
}
|
||||
|
||||
export const GestureControls: React.FC<GestureControlsProps> = ({
|
||||
screenDimensions,
|
||||
gestureControls,
|
||||
onLongPressActivated,
|
||||
onLongPressEnd,
|
||||
onLongPressStateChange,
|
||||
toggleControls,
|
||||
showControls,
|
||||
hideControls,
|
||||
volume,
|
||||
brightness,
|
||||
controlsTimeout
|
||||
}) => {
|
||||
|
||||
const getVolumeIcon = (value: number) => {
|
||||
if (value === 0) return 'volume-off';
|
||||
if (value < 0.3) return 'volume-mute';
|
||||
if (value < 0.6) return 'volume-down';
|
||||
return 'volume-up';
|
||||
};
|
||||
|
||||
const getBrightnessIcon = (value: number) => {
|
||||
if (value < 0.3) return 'brightness-low';
|
||||
if (value < 0.7) return 'brightness-medium';
|
||||
return 'brightness-high';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left side gesture handler - brightness + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Right side gesture handler - volume + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 5,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Volume/Brightness Pill Overlay */}
|
||||
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View
|
||||
style={[
|
||||
localStyles.iconWrapper,
|
||||
{
|
||||
backgroundColor: gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(242, 184, 181)'
|
||||
: 'rgba(59, 59, 59)'
|
||||
}
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={
|
||||
gestureControls.showVolumeOverlay
|
||||
? getVolumeIcon(volume)
|
||||
: getBrightnessIcon(brightness)
|
||||
}
|
||||
size={24}
|
||||
color={
|
||||
gestureControls.showVolumeOverlay && volume === 0
|
||||
? 'rgba(96, 20, 16)'
|
||||
: 'rgba(255, 255, 255)'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text
|
||||
style={[
|
||||
localStyles.gestureText,
|
||||
gestureControls.showVolumeOverlay && volume === 0 && { color: 'rgba(242, 184, 181)' }
|
||||
]}
|
||||
>
|
||||
{gestureControls.showVolumeOverlay && volume === 0
|
||||
? "Muted"
|
||||
: `${Math.round((gestureControls.showVolumeOverlay ? volume : brightness) * 100)}%`
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useRef, forwardRef, useImperativeHandle } from 'react';
|
||||
import React, { useCallback, useRef, forwardRef, useImperativeHandle, useEffect } from 'react';
|
||||
import { View, TouchableWithoutFeedback, StyleSheet } from 'react-native';
|
||||
import { PinchGestureHandler } from 'react-native-gesture-handler';
|
||||
import Video, { VideoRef, SelectedTrack, SelectedVideoTrack, ResizeMode } from 'react-native-video';
|
||||
|
|
@ -7,32 +7,19 @@ import { styles } from '../../utils/playerStyles';
|
|||
import { ResizeModeType } from '../../utils/playerTypes';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
|
||||
// Codec error patterns that indicate we should fallback to MPV
|
||||
|
||||
const CODEC_ERROR_PATTERNS = [
|
||||
'exceeds_capabilities',
|
||||
'no_exceeds_capabilities',
|
||||
'decoder_exception',
|
||||
'decoder.*error',
|
||||
'codec.*error',
|
||||
'unsupported.*codec',
|
||||
'mediacodec.*exception',
|
||||
'omx.*error',
|
||||
'dolby.*vision',
|
||||
'hevc.*error',
|
||||
'no suitable decoder',
|
||||
'decoder initialization failed',
|
||||
'format.no_decoder',
|
||||
'no_decoder',
|
||||
'decoding_failed',
|
||||
'error_code_decoding',
|
||||
'exoplaybackexception',
|
||||
'mediacodecvideodecoder',
|
||||
'mediacodecvideodecoderexception',
|
||||
'decoder failed',
|
||||
'exceeds_capabilities', 'no_exceeds_capabilities', 'decoder_exception',
|
||||
'decoder.*error', 'codec.*error', 'unsupported.*codec',
|
||||
'mediacodec.*exception', 'omx.*error', 'dolby.*vision', 'hevc.*error',
|
||||
'no suitable decoder', 'decoder initialization failed',
|
||||
'format.no_decoder', 'no_decoder', 'decoding_failed', 'error_code_decoding',
|
||||
'mediacodecvideodecoder', 'mediacodecvideodecoderexception', 'decoder failed',
|
||||
];
|
||||
|
||||
interface VideoSurfaceProps {
|
||||
processedStreamUrl: string;
|
||||
videoType?: string;
|
||||
headers?: { [key: string]: string };
|
||||
volume: number;
|
||||
playbackSpeed: number;
|
||||
|
|
@ -93,6 +80,7 @@ const isCodecError = (errorString: string): boolean => {
|
|||
|
||||
export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
||||
processedStreamUrl,
|
||||
videoType,
|
||||
headers,
|
||||
volume,
|
||||
playbackSpeed,
|
||||
|
|
@ -133,10 +121,74 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
subtitleDelay,
|
||||
subtitleAlignment,
|
||||
}) => {
|
||||
// Use the actual stream URL
|
||||
const streamUrl = currentStreamUrl || processedStreamUrl;
|
||||
|
||||
// ========== MPV Handlers ==========
|
||||
const normalizeRnVideoType = (t?: string): 'm3u8' | 'mpd' | undefined => {
|
||||
if (!t) return undefined;
|
||||
const lower = String(t).toLowerCase();
|
||||
if (lower === 'm3u8' || lower === 'hls') return 'm3u8';
|
||||
if (lower === 'mpd' || lower === 'dash') return 'mpd';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const inferRnVideoTypeFromUrl = (url?: string): 'm3u8' | 'mpd' | undefined => {
|
||||
if (!url) return undefined;
|
||||
const lower = url.toLowerCase();
|
||||
if (/\.m3u8(\b|$)/i.test(lower) || /(^|[?&])type=(m3u8|hls)(\b|$)/i.test(lower)) return 'm3u8';
|
||||
if (/\.mpd(\b|$)/i.test(lower) || /(^|[?&])type=(mpd|dash)(\b|$)/i.test(lower)) return 'mpd';
|
||||
|
||||
if (/\b(hls|m3u8|m3u)\b/i.test(lower)) return 'm3u8';
|
||||
if (/\/playlist\//i.test(lower) && (/(^|[?&])token=/.test(lower) || /(^|[?&])expires=/.test(lower))) return 'm3u8';
|
||||
|
||||
if (/\bdash\b/i.test(lower) || /manifest/.test(lower)) return 'mpd';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const resolvedRnVideoType = normalizeRnVideoType(videoType) ?? inferRnVideoTypeFromUrl(streamUrl);
|
||||
|
||||
const probeHlsResponse = useCallback(async (url: string) => {
|
||||
try {
|
||||
const res = await fetch(url, { method: 'GET', headers: { Range: 'bytes=0-2047' } });
|
||||
const text = await res.text();
|
||||
const prefix = text.slice(0, 200).replace(/\s+/g, ' ').trim();
|
||||
console.log('[VideoSurface] Manifest probe:', {
|
||||
status: res.status,
|
||||
contentType: res.headers.get('content-type'),
|
||||
contentEncoding: res.headers.get('content-encoding'),
|
||||
prefix,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.log('[VideoSurface] Manifest probe failed:', e?.message);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const exoRequestHeaders = (() => {
|
||||
const merged = { ...(headers ?? {}) } as Record<string, string>;
|
||||
const hasUA = Object.keys(merged).some(k => k.toLowerCase() === 'user-agent');
|
||||
if (!hasUA && resolvedRnVideoType === 'm3u8') {
|
||||
merged['User-Agent'] = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36';
|
||||
merged['Accept'] = '*/*';
|
||||
}
|
||||
return merged;
|
||||
})();
|
||||
|
||||
const exoRequestHeadersArray = Object.entries(exoRequestHeaders).map(([key, value]) => ({ key, value }));
|
||||
|
||||
const lastLoggedExoRequestKeyRef = useRef<string>('');
|
||||
useEffect(() => {
|
||||
if (!__DEV__ || !useExoPlayer) return;
|
||||
const key = `${streamUrl}::${JSON.stringify(exoRequestHeaders)}`;
|
||||
if (lastLoggedExoRequestKeyRef.current === key) return;
|
||||
lastLoggedExoRequestKeyRef.current = key;
|
||||
console.log('[VideoSurface] Headers:', exoRequestHeaders);
|
||||
}, [streamUrl, useExoPlayer, exoRequestHeaders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mpvPlayerRef?.current && !useExoPlayer) {
|
||||
mpvPlayerRef.current.setResizeMode(getMpvResizeMode());
|
||||
}
|
||||
}, [resizeMode, useExoPlayer, mpvPlayerRef]);
|
||||
|
||||
const handleMpvLoad = (data: { duration: number; width: number; height: number }) => {
|
||||
console.log('[VideoSurface] MPV onLoad received:', data);
|
||||
onLoad({
|
||||
|
|
@ -169,31 +221,18 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
onEnd();
|
||||
};
|
||||
|
||||
// ========== ExoPlayer Handlers ==========
|
||||
const handleExoLoad = (data: any) => {
|
||||
console.log('[VideoSurface] ExoPlayer onLoad received:', data);
|
||||
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: i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
})) ?? [];
|
||||
|
||||
const subtitleTracks = data.textTracks?.map((t: any, i: number) => {
|
||||
const track = {
|
||||
id: i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
};
|
||||
console.log('[VideoSurface] Mapped subtitle track:', track, 'original:', t);
|
||||
return track;
|
||||
}) ?? [];
|
||||
const subtitleTracks = data.textTracks?.map((t: any, i: number) => ({
|
||||
id: i,
|
||||
name: t.title || t.language || `Track ${i + 1}`,
|
||||
language: t.language,
|
||||
})) ?? [];
|
||||
|
||||
if (onTracksChanged && (audioTracks.length > 0 || subtitleTracks.length > 0)) {
|
||||
onTracksChanged({ audioTracks, subtitleTracks });
|
||||
|
|
@ -215,45 +254,26 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
};
|
||||
|
||||
const handleExoError = (error: any) => {
|
||||
console.log('[VideoSurface] ExoPlayer onError received:', JSON.stringify(error, null, 2));
|
||||
|
||||
// Extract error string - try multiple paths
|
||||
let errorString = 'Unknown error';
|
||||
// Extract error message from multiple possible paths
|
||||
const errorParts: string[] = [];
|
||||
if (typeof error?.error === 'string') errorParts.push(error.error);
|
||||
if (error?.error?.errorString) errorParts.push(error.error.errorString);
|
||||
if (error?.error?.errorCode) errorParts.push(String(error.error.errorCode));
|
||||
if (typeof error === 'string') errorParts.push(error);
|
||||
if (error?.nativeStackAndroid) errorParts.push(error.nativeStackAndroid.join(' '));
|
||||
if (error?.message) errorParts.push(error.message);
|
||||
const errorString = errorParts.length > 0 ? errorParts.join(' ') : JSON.stringify(error);
|
||||
|
||||
if (typeof error?.error === 'string') {
|
||||
errorParts.push(error.error);
|
||||
}
|
||||
if (error?.error?.errorString) {
|
||||
errorParts.push(error.error.errorString);
|
||||
}
|
||||
if (error?.error?.errorCode) {
|
||||
errorParts.push(String(error.error.errorCode));
|
||||
}
|
||||
if (typeof error === 'string') {
|
||||
errorParts.push(error);
|
||||
}
|
||||
if (error?.nativeStackAndroid) {
|
||||
errorParts.push(error.nativeStackAndroid.join(' '));
|
||||
}
|
||||
if (error?.message) {
|
||||
errorParts.push(error.message);
|
||||
}
|
||||
|
||||
// Combine all error parts for comprehensive checking
|
||||
errorString = errorParts.length > 0 ? errorParts.join(' ') : JSON.stringify(error);
|
||||
|
||||
console.log('[VideoSurface] Extracted error string:', errorString);
|
||||
console.log('[VideoSurface] isCodecError result:', isCodecError(errorString));
|
||||
|
||||
// Check if this is a codec error that should trigger fallback
|
||||
if (isCodecError(errorString)) {
|
||||
logger.warn('[VideoSurface] ExoPlayer codec error detected, triggering MPV fallback:', errorString);
|
||||
logger.warn('[VideoSurface] Codec error → MPV fallback:', errorString);
|
||||
onCodecError?.();
|
||||
return; // Don't propagate codec errors - we're falling back silently
|
||||
return;
|
||||
}
|
||||
|
||||
if (__DEV__ && (errorString.includes('ERROR_CODE_PARSING_MANIFEST_MALFORMED') || errorString.includes('23002'))) {
|
||||
probeHlsResponse(streamUrl);
|
||||
}
|
||||
|
||||
// Non-codec errors should be propagated
|
||||
onError({
|
||||
error: {
|
||||
errorString: errorString,
|
||||
|
|
@ -274,7 +294,6 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
onSeek({ currentTime: data.currentTime });
|
||||
};
|
||||
|
||||
// Map ResizeModeType to react-native-video ResizeMode
|
||||
const getExoResizeMode = (): ResizeMode => {
|
||||
switch (resizeMode) {
|
||||
case 'cover':
|
||||
|
|
@ -287,6 +306,18 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const getMpvResizeMode = (): 'contain' | 'cover' | 'stretch' => {
|
||||
switch (resizeMode) {
|
||||
case 'cover':
|
||||
return 'cover';
|
||||
case 'stretch':
|
||||
return 'stretch';
|
||||
case 'contain':
|
||||
default:
|
||||
return 'contain';
|
||||
}
|
||||
};
|
||||
|
||||
const alphaHex = (opacity01: number) => {
|
||||
const a = Math.max(0, Math.min(1, opacity01));
|
||||
return Math.round(a * 255).toString(16).padStart(2, '0').toUpperCase();
|
||||
|
|
@ -303,11 +334,25 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
ref={exoPlayerRef}
|
||||
source={{
|
||||
uri: streamUrl,
|
||||
headers: headers,
|
||||
}}
|
||||
headers: exoRequestHeaders,
|
||||
requestHeaders: exoRequestHeadersArray,
|
||||
...(resolvedRnVideoType ? { type: resolvedRnVideoType } : null),
|
||||
bufferConfig: {
|
||||
minBufferMs: 10000,
|
||||
maxBufferMs: 20000,
|
||||
bufferForPlaybackMs: 2000,
|
||||
bufferForPlaybackAfterRebufferMs: 4000,
|
||||
// @ts-ignore - Extra props supported by patched react-native-video
|
||||
minBufferMemoryReservePercent: 0.15,
|
||||
// @ts-ignore - Extra props supported by patched react-native-video
|
||||
maxHeapAllocationPercent: 0.25,
|
||||
}
|
||||
} as any}
|
||||
paused={paused}
|
||||
volume={volume}
|
||||
rate={playbackSpeed}
|
||||
// @ts-ignore - Prop supported by react-native-video 6.0+
|
||||
bufferingStrategy="DependingOnMemory"
|
||||
resizeMode={getExoResizeMode()}
|
||||
selectedAudioTrack={selectedAudioTrack}
|
||||
selectedTextTrack={selectedTextTrack}
|
||||
|
|
@ -324,39 +369,18 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
ignoreSilentSwitch="ignore"
|
||||
automaticallyWaitsToMinimizeStalling={true}
|
||||
useTextureView={true}
|
||||
// Subtitle Styling for ExoPlayer
|
||||
// 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 UI size (AndroidVideoPlayer passes MPV-scaled values here)
|
||||
fontSize: subtitleSize ? Math.round(subtitleSize / 1.5) : 28,
|
||||
paddingTop: 0,
|
||||
// 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'),
|
||||
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}
|
||||
/>
|
||||
) : (
|
||||
|
|
@ -377,7 +401,6 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
onTracksChanged={onTracksChanged}
|
||||
decoderMode={decoderMode}
|
||||
gpuMode={gpuMode}
|
||||
// Subtitle Styling
|
||||
subtitleSize={subtitleSize}
|
||||
subtitleColor={subtitleColor}
|
||||
subtitleBackgroundOpacity={subtitleBackgroundOpacity}
|
||||
|
|
@ -390,7 +413,6 @@ export const VideoSurface: React.FC<VideoSurfaceProps> = ({
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Gesture overlay - transparent, on top of the player */}
|
||||
<PinchGestureHandler
|
||||
ref={pinchRef}
|
||||
onGestureEvent={onPinchGestureEvent}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
import { StatusBar, Platform, Dimensions, AppState } from 'react-native';
|
||||
import * as NavigationBar from 'expo-navigation-bar';
|
||||
import * as Brightness from 'expo-brightness';
|
||||
|
||||
import { activateKeepAwakeAsync, deactivateKeepAwake } from 'expo-keep-awake';
|
||||
import { logger } from '../../../../utils/logger';
|
||||
import { useFocusEffect } from '@react-navigation/native';
|
||||
|
|
@ -22,11 +22,10 @@ const DEBUG_MODE = false;
|
|||
export const usePlayerSetup = (
|
||||
setScreenDimensions: (dim: any) => void,
|
||||
setVolume: (vol: number) => void,
|
||||
setBrightness: (bri: number) => void,
|
||||
|
||||
paused: boolean
|
||||
) => {
|
||||
const originalSystemBrightnessRef = useRef<number | null>(null);
|
||||
const originalSystemBrightnessModeRef = useRef<number | null>(null);
|
||||
|
||||
const isAppBackgrounded = useRef(false);
|
||||
|
||||
// Prevent screen sleep while playing
|
||||
|
|
@ -103,38 +102,9 @@ export const usePlayerSetup = (
|
|||
// Initialize volume (default to 1.0)
|
||||
setVolume(1.0);
|
||||
|
||||
// Initialize Brightness
|
||||
const initBrightness = async () => {
|
||||
try {
|
||||
if (Platform.OS === 'android') {
|
||||
try {
|
||||
const [sysBright, sysMode] = await Promise.all([
|
||||
(Brightness as any).getSystemBrightnessAsync?.(),
|
||||
(Brightness as any).getSystemBrightnessModeAsync?.()
|
||||
]);
|
||||
originalSystemBrightnessRef.current = typeof sysBright === 'number' ? sysBright : null;
|
||||
originalSystemBrightnessModeRef.current = typeof sysMode === 'number' ? sysMode : null;
|
||||
} catch (e) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const currentBrightness = await Brightness.getBrightnessAsync();
|
||||
setBrightness(currentBrightness);
|
||||
} catch (error) {
|
||||
logger.warn('[usePlayerSetup] Error setting brightness', error);
|
||||
setBrightness(1.0);
|
||||
}
|
||||
};
|
||||
initBrightness();
|
||||
|
||||
return () => {
|
||||
subscription?.remove();
|
||||
disableImmersiveMode();
|
||||
|
||||
// Restore brightness on unmount
|
||||
if (Platform.OS === 'android' && originalSystemBrightnessRef.current !== null) {
|
||||
// restoration logic normally happens here or in a separate effect
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { Animated } from 'react-native';
|
|||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { CreditsInfo } from '../../../services/introService';
|
||||
|
||||
export interface Insets {
|
||||
top: number;
|
||||
|
|
@ -33,6 +34,7 @@ interface UpNextButtonProps {
|
|||
metadata?: { poster?: string; id?: string }; // Added metadata prop
|
||||
controlsVisible?: boolean;
|
||||
controlsFixedOffset?: number;
|
||||
creditsInfo?: CreditsInfo | null; // Add credits info from API
|
||||
}
|
||||
|
||||
const UpNextButton: React.FC<UpNextButtonProps> = ({
|
||||
|
|
@ -49,6 +51,7 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
|
|||
metadata,
|
||||
controlsVisible = false,
|
||||
controlsFixedOffset = 100,
|
||||
creditsInfo,
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const opacity = useRef(new Animated.Value(0)).current;
|
||||
|
|
@ -76,10 +79,19 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
|
|||
|
||||
const shouldShow = useMemo(() => {
|
||||
if (!nextEpisode || duration <= 0) return false;
|
||||
|
||||
// If we have credits timing from API, use that as primary source
|
||||
if (creditsInfo?.startTime !== null && creditsInfo?.startTime !== undefined) {
|
||||
// Show button when we reach credits start time and stay visible until 10s before end
|
||||
const timeRemaining = duration - currentTime;
|
||||
const isInCredits = currentTime >= creditsInfo.startTime;
|
||||
return isInCredits && timeRemaining > 10;
|
||||
}
|
||||
|
||||
// Fallback: Use fixed timing (show when under ~1 minute and above 10s)
|
||||
const timeRemaining = duration - currentTime;
|
||||
// Be tolerant to timer jitter: show when under ~1 minute and above 10s
|
||||
return timeRemaining < 61 && timeRemaining > 10;
|
||||
}, [nextEpisode, duration, currentTime]);
|
||||
}, [nextEpisode, duration, currentTime, creditsInfo]);
|
||||
|
||||
// Debug logging removed to reduce console noise
|
||||
// The state is computed in shouldShow useMemo above
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, Animated } from 'react-native';
|
||||
import {
|
||||
TapGestureHandler,
|
||||
PanGestureHandler,
|
||||
|
|
@ -19,8 +19,15 @@ interface GestureControlsProps {
|
|||
showControls: boolean;
|
||||
hideControls: () => void;
|
||||
volume: number;
|
||||
brightness: number;
|
||||
brightness?: number;
|
||||
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
resizeMode?: string;
|
||||
// New props for double-tap skip and horizontal seek
|
||||
skip?: (seconds: number) => void;
|
||||
currentTime?: number;
|
||||
duration?: number;
|
||||
seekToTime?: (seconds: number) => void;
|
||||
formatTime?: (seconds: number) => string;
|
||||
}
|
||||
|
||||
export const GestureControls: React.FC<GestureControlsProps> = ({
|
||||
|
|
@ -33,8 +40,14 @@ export const GestureControls: React.FC<GestureControlsProps> = ({
|
|||
showControls,
|
||||
hideControls,
|
||||
volume,
|
||||
brightness,
|
||||
controlsTimeout
|
||||
brightness = 0.5,
|
||||
controlsTimeout,
|
||||
resizeMode = 'contain',
|
||||
skip,
|
||||
currentTime,
|
||||
duration,
|
||||
seekToTime,
|
||||
formatTime,
|
||||
}) => {
|
||||
|
||||
const getVolumeIcon = (value: number) => {
|
||||
|
|
@ -50,105 +63,279 @@ export const GestureControls: React.FC<GestureControlsProps> = ({
|
|||
return 'brightness-high';
|
||||
};
|
||||
|
||||
// Refs for gesture handlers
|
||||
const leftDoubleTapRef = React.useRef(null);
|
||||
const rightDoubleTapRef = React.useRef(null);
|
||||
const horizontalSeekPanRef = React.useRef(null);
|
||||
const leftVerticalPanRef = React.useRef(null);
|
||||
const rightVerticalPanRef = React.useRef(null);
|
||||
|
||||
// State for double-tap skip overlays
|
||||
const [showSkipForwardOverlay, setShowSkipForwardOverlay] = useState(false);
|
||||
const [showSkipBackwardOverlay, setShowSkipBackwardOverlay] = useState(false);
|
||||
const [skipAmount, setSkipAmount] = useState(10);
|
||||
|
||||
// State for horizontal seek
|
||||
const [isHorizontalSeeking, setIsHorizontalSeeking] = useState(false);
|
||||
const [seekPreviewTime, setSeekPreviewTime] = useState(0);
|
||||
const [seekStartTime, setSeekStartTime] = useState(0);
|
||||
|
||||
// Refs for overlay timeouts
|
||||
const skipForwardTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
const skipBackwardTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Cleanup timeouts on unmount
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (skipForwardTimeoutRef.current) clearTimeout(skipForwardTimeoutRef.current);
|
||||
if (skipBackwardTimeoutRef.current) clearTimeout(skipBackwardTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Refs for tracking rapid seek state
|
||||
const seekBaselineTime = React.useRef<number | null>(null);
|
||||
const gestureSkipAmount = React.useRef(0);
|
||||
|
||||
// Double-tap handlers
|
||||
const handleLeftDoubleTap = () => {
|
||||
if (seekToTime && currentTime !== undefined) {
|
||||
// If overlay is not visible, this is a new seek sequence
|
||||
if (!showSkipBackwardOverlay) {
|
||||
seekBaselineTime.current = currentTime;
|
||||
gestureSkipAmount.current = 0;
|
||||
}
|
||||
|
||||
// Increment skip amount
|
||||
gestureSkipAmount.current += 10;
|
||||
const currentSkip = gestureSkipAmount.current;
|
||||
|
||||
// Calculate target time based on locked baseline
|
||||
const baseTime = seekBaselineTime.current !== null ? seekBaselineTime.current : currentTime;
|
||||
const targetTime = Math.max(0, baseTime - currentSkip);
|
||||
|
||||
// Execute seek
|
||||
seekToTime(targetTime);
|
||||
|
||||
// Update UI state
|
||||
setSkipAmount(currentSkip);
|
||||
setShowSkipBackwardOverlay(true);
|
||||
|
||||
if (skipBackwardTimeoutRef.current) {
|
||||
clearTimeout(skipBackwardTimeoutRef.current);
|
||||
}
|
||||
skipBackwardTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipBackwardOverlay(false);
|
||||
setSkipAmount(10);
|
||||
gestureSkipAmount.current = 0;
|
||||
seekBaselineTime.current = null;
|
||||
}, 800);
|
||||
} else if (skip) {
|
||||
// Fallback if seekToTime not available
|
||||
skip(-10);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRightDoubleTap = () => {
|
||||
if (seekToTime && currentTime !== undefined) {
|
||||
// If overlay is not visible, this is a new seek sequence
|
||||
if (!showSkipForwardOverlay) {
|
||||
seekBaselineTime.current = currentTime;
|
||||
gestureSkipAmount.current = 0;
|
||||
}
|
||||
|
||||
// Increment skip amount
|
||||
gestureSkipAmount.current += 10;
|
||||
const currentSkip = gestureSkipAmount.current;
|
||||
|
||||
// Calculate target time based on locked baseline
|
||||
const baseTime = seekBaselineTime.current !== null ? seekBaselineTime.current : currentTime;
|
||||
const targetTime = baseTime + currentSkip;
|
||||
// Note: duration check happens in seekToTime
|
||||
|
||||
// Execute seek
|
||||
seekToTime(targetTime);
|
||||
|
||||
// Update UI state
|
||||
setSkipAmount(currentSkip);
|
||||
setShowSkipForwardOverlay(true);
|
||||
|
||||
if (skipForwardTimeoutRef.current) {
|
||||
clearTimeout(skipForwardTimeoutRef.current);
|
||||
}
|
||||
skipForwardTimeoutRef.current = setTimeout(() => {
|
||||
setShowSkipForwardOverlay(false);
|
||||
setSkipAmount(10);
|
||||
gestureSkipAmount.current = 0;
|
||||
seekBaselineTime.current = null;
|
||||
}, 800);
|
||||
} else if (skip) {
|
||||
// Fallback
|
||||
skip(10);
|
||||
}
|
||||
};
|
||||
|
||||
// Shared styles for gesture areas (relative to parent container)
|
||||
const leftSideStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: '100%' as const,
|
||||
};
|
||||
|
||||
const rightSideStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: 0,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: '100%' as const,
|
||||
};
|
||||
|
||||
// Full gesture area style
|
||||
const gestureAreaStyle = {
|
||||
position: 'absolute' as const,
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left side gesture handler - brightness + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
{/* Horizontal seek gesture - OUTERMOST wrapper, fails on vertical movement */}
|
||||
<PanGestureHandler
|
||||
ref={horizontalSeekPanRef}
|
||||
onGestureEvent={(event: any) => {
|
||||
const { translationX, state } = event.nativeEvent;
|
||||
|
||||
{/* Right side gesture handler - volume + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
if (state === State.ACTIVE) {
|
||||
if (!isHorizontalSeeking && currentTime !== undefined) {
|
||||
setIsHorizontalSeeking(true);
|
||||
setSeekStartTime(currentTime);
|
||||
}
|
||||
|
||||
if (duration && duration > 0) {
|
||||
const sensitivityFactor = duration > 3600 ? 120 : duration > 1800 ? 90 : 60;
|
||||
const seekDelta = (translationX / screenDimensions.width) * sensitivityFactor;
|
||||
const newTime = Math.max(0, Math.min(duration, seekStartTime + seekDelta));
|
||||
setSeekPreviewTime(newTime);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 5,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
onHandlerStateChange={(event: any) => {
|
||||
const { state } = event.nativeEvent;
|
||||
|
||||
{/* Volume/Brightness Pill Overlay - Compact top design */}
|
||||
if (state === State.END || state === State.CANCELLED) {
|
||||
if (isHorizontalSeeking && seekToTime) {
|
||||
seekToTime(seekPreviewTime);
|
||||
}
|
||||
setIsHorizontalSeeking(false);
|
||||
}
|
||||
}}
|
||||
activeOffsetX={[-30, 30]}
|
||||
failOffsetY={[-20, 20]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<View style={gestureAreaStyle}>
|
||||
{/* Left side gestures */}
|
||||
<TapGestureHandler
|
||||
ref={leftDoubleTapRef}
|
||||
numberOfTaps={2}
|
||||
onActivated={handleLeftDoubleTap}
|
||||
>
|
||||
<View style={leftSideStyle}>
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<PanGestureHandler
|
||||
ref={leftVerticalPanRef}
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-20, 20]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<TapGestureHandler
|
||||
waitFor={leftDoubleTapRef}
|
||||
onActivated={toggleControls}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
</TapGestureHandler>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
</View>
|
||||
</LongPressGestureHandler>
|
||||
</View>
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: '100%',
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Right side gestures */}
|
||||
<TapGestureHandler
|
||||
ref={rightDoubleTapRef}
|
||||
numberOfTaps={2}
|
||||
onActivated={handleRightDoubleTap}
|
||||
>
|
||||
<View style={rightSideStyle}>
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<PanGestureHandler
|
||||
ref={rightVerticalPanRef}
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-20, 20]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill}>
|
||||
<TapGestureHandler
|
||||
waitFor={rightDoubleTapRef}
|
||||
onActivated={toggleControls}
|
||||
>
|
||||
<View style={StyleSheet.absoluteFill} />
|
||||
</TapGestureHandler>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
</View>
|
||||
</LongPressGestureHandler>
|
||||
</View>
|
||||
</TapGestureHandler>
|
||||
</View>
|
||||
</PanGestureHandler>
|
||||
|
||||
{/* Volume/Brightness Pill Overlay */}
|
||||
{(gestureControls.showVolumeOverlay || gestureControls.showBrightnessOverlay) && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={[
|
||||
|
|
@ -194,6 +381,84 @@ export const GestureControls: React.FC<GestureControlsProps> = ({
|
|||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{gestureControls.showResizeModeOverlay && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<Animated.View
|
||||
style={[
|
||||
localStyles.gestureIndicatorPill,
|
||||
{ opacity: gestureControls.resizeModeOverlayOpacity }
|
||||
]}
|
||||
>
|
||||
<View style={localStyles.iconWrapper}>
|
||||
<MaterialIcons
|
||||
name="aspect-ratio"
|
||||
size={18}
|
||||
color={'rgba(255, 255, 255, 0.9)'}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<Text style={localStyles.gestureText}>
|
||||
{resizeMode.charAt(0).toUpperCase() + resizeMode.slice(1)}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Skip Forward Overlay - Right side */}
|
||||
{showSkipForwardOverlay && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={localStyles.gestureIndicatorPill}>
|
||||
<View style={localStyles.iconWrapper}>
|
||||
<MaterialIcons name="fast-forward" size={18} color="rgba(255, 255, 255, 0.9)" />
|
||||
</View>
|
||||
<Text style={localStyles.gestureText}>
|
||||
+{skipAmount}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Skip Backward Overlay - Left side */}
|
||||
{showSkipBackwardOverlay && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={localStyles.gestureIndicatorPill}>
|
||||
<View style={localStyles.iconWrapper}>
|
||||
<MaterialIcons name="fast-rewind" size={18} color="rgba(255, 255, 255, 0.9)" />
|
||||
</View>
|
||||
<Text style={localStyles.gestureText}>
|
||||
-{skipAmount}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Horizontal Seek Preview Overlay */}
|
||||
{isHorizontalSeeking && formatTime && (
|
||||
<View style={localStyles.gestureIndicatorContainer}>
|
||||
<View style={localStyles.gestureIndicatorPill}>
|
||||
<View style={[localStyles.iconWrapper, { backgroundColor: 'rgba(59, 59, 59)' }]}>
|
||||
<MaterialIcons
|
||||
name={seekPreviewTime > (currentTime || 0) ? "fast-forward" : "fast-rewind"}
|
||||
size={18}
|
||||
color="rgba(255, 255, 255, 0.9)"
|
||||
/>
|
||||
</View>
|
||||
<Text style={localStyles.gestureText}>
|
||||
{formatTime(seekPreviewTime)}
|
||||
</Text>
|
||||
<Text style={{
|
||||
color: seekPreviewTime > (currentTime || 0) ? '#4CAF50' : '#FF5722',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginLeft: 4,
|
||||
}}>
|
||||
{seekPreviewTime > (currentTime || 0) ? '+' : ''}
|
||||
{Math.round(seekPreviewTime - (currentTime || 0))}s
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform, Dimensions } from 'react-native';
|
||||
import { View, Text, TouchableOpacity, Animated, StyleSheet, Platform, Dimensions, ActivityIndicator } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Feather from 'react-native-vector-icons/Feather';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
|
@ -54,6 +54,7 @@ interface PlayerControlsProps {
|
|||
// MPV Switch (Android only)
|
||||
onSwitchToMPV?: () => void;
|
||||
useExoPlayer?: boolean;
|
||||
isBuffering?: boolean;
|
||||
}
|
||||
|
||||
export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
||||
|
|
@ -98,6 +99,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
onAirPlayPress,
|
||||
onSwitchToMPV,
|
||||
useExoPlayer,
|
||||
isBuffering = false,
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -386,7 +388,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
{/* Center Controls - CloudStream Style */}
|
||||
<View style={[styles.controls, {
|
||||
transform: [{ translateY: -(playButtonSize / 2) }]
|
||||
}]}>
|
||||
}]} pointerEvents="box-none">
|
||||
|
||||
{/* Backward Seek Button (-10s) */}
|
||||
<TouchableOpacity
|
||||
|
|
@ -463,7 +465,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
<TouchableOpacity
|
||||
onPress={handlePlayPauseWithAnimation}
|
||||
activeOpacity={0.7}
|
||||
style={{ marginHorizontal: buttonSpacing }}
|
||||
disabled={isBuffering}
|
||||
>
|
||||
<View style={[styles.playButtonCircle, { width: playButtonSize, height: playButtonSize }]}>
|
||||
<Animated.View style={[
|
||||
|
|
@ -479,11 +481,15 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
transform: [{ scale: playIconScale }],
|
||||
opacity: playIconOpacity
|
||||
}}>
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={playIconSizeCalculated}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
{isBuffering ? (
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={playIconSizeCalculated}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
|
|
|||
|
|
@ -37,30 +37,32 @@ export const usePlayerControls = (config: PlayerControlsConfig) => {
|
|||
setPaused(!paused);
|
||||
}, [paused, setPaused]);
|
||||
|
||||
const seekTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const seekToTime = useCallback((rawSeconds: number) => {
|
||||
const timeInSeconds = Math.max(0, Math.min(rawSeconds, duration > 0 ? duration - END_EPSILON : rawSeconds));
|
||||
|
||||
if (playerRef.current && duration > 0 && !isSeeking.current) {
|
||||
if (playerRef.current && duration > 0) {
|
||||
if (DEBUG_MODE) logger.log(`[usePlayerControls] Seeking to ${timeInSeconds}`);
|
||||
|
||||
isSeeking.current = true;
|
||||
|
||||
// iOS optimization: pause while seeking for smoother experience
|
||||
|
||||
// Clear existing timeout to keep isSeeking true during rapid seeks
|
||||
if (seekTimeoutRef.current) {
|
||||
clearTimeout(seekTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Actually perform the seek
|
||||
playerRef.current.seek(timeInSeconds);
|
||||
|
||||
// Debounce the seeking state reset
|
||||
setTimeout(() => {
|
||||
seekTimeoutRef.current = setTimeout(() => {
|
||||
if (isMounted.current && isSeeking.current) {
|
||||
isSeeking.current = false;
|
||||
// Resume if it was playing (iOS specific)
|
||||
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}, [duration, paused, setPaused, playerRef, isSeeking, isMounted]);
|
||||
}, [duration, paused, playerRef, isSeeking, isMounted]);
|
||||
|
||||
const skip = useCallback((seconds: number) => {
|
||||
seekToTime(currentTime + seconds);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { Dimensions, Platform } from 'react-native';
|
|||
|
||||
// Use only resize modes supported by all player backends
|
||||
// (not all players support 'stretch' or 'none')
|
||||
export type PlayerResizeMode = 'contain' | 'cover';
|
||||
export type PlayerResizeMode = 'contain' | 'cover' | 'stretch';
|
||||
|
||||
export const usePlayerState = () => {
|
||||
// Playback State
|
||||
|
|
|
|||
|
|
@ -1,333 +0,0 @@
|
|||
import React from 'react';
|
||||
import { View, Text, Animated } from 'react-native';
|
||||
import {
|
||||
TapGestureHandler,
|
||||
PanGestureHandler,
|
||||
LongPressGestureHandler,
|
||||
} from 'react-native-gesture-handler';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
|
||||
interface GestureControlsProps {
|
||||
screenDimensions: { width: number, height: number };
|
||||
gestureControls: any;
|
||||
onLongPressActivated: () => void;
|
||||
onLongPressEnd: () => void;
|
||||
onLongPressStateChange: (event: any) => void;
|
||||
toggleControls: () => void;
|
||||
showControls: boolean;
|
||||
hideControls: () => void;
|
||||
volume: number;
|
||||
brightness: number;
|
||||
controlsTimeout: React.MutableRefObject<NodeJS.Timeout | null>;
|
||||
}
|
||||
|
||||
export const GestureControls: React.FC<GestureControlsProps> = ({
|
||||
screenDimensions,
|
||||
gestureControls,
|
||||
onLongPressActivated,
|
||||
onLongPressEnd,
|
||||
onLongPressStateChange,
|
||||
toggleControls,
|
||||
showControls,
|
||||
hideControls,
|
||||
volume,
|
||||
brightness,
|
||||
controlsTimeout
|
||||
}) => {
|
||||
// Helper to get dimensions (using passed screenDimensions)
|
||||
const getDimensions = () => screenDimensions;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Left side gesture handler - brightness + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onBrightnessGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Right side gesture handler - volume + tap + long press */}
|
||||
<LongPressGestureHandler
|
||||
onActivated={onLongPressActivated}
|
||||
onEnded={onLongPressEnd}
|
||||
onHandlerStateChange={onLongPressStateChange}
|
||||
minDurationMs={500}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<PanGestureHandler
|
||||
onGestureEvent={gestureControls.onVolumeGestureEvent}
|
||||
activeOffsetY={[-10, 10]}
|
||||
failOffsetX={[-30, 30]}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
maxPointers={1}
|
||||
>
|
||||
<TapGestureHandler
|
||||
onActivated={toggleControls}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
right: 0,
|
||||
width: screenDimensions.width * 0.4,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 10,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
</PanGestureHandler>
|
||||
</LongPressGestureHandler>
|
||||
|
||||
{/* Center area tap handler */}
|
||||
<TapGestureHandler
|
||||
onActivated={() => {
|
||||
if (showControls) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
hideControls();
|
||||
}, 0);
|
||||
if (controlsTimeout.current) {
|
||||
clearTimeout(controlsTimeout.current);
|
||||
}
|
||||
controlsTimeout.current = timeoutId;
|
||||
} else {
|
||||
toggleControls();
|
||||
}
|
||||
}}
|
||||
shouldCancelWhenOutside={false}
|
||||
simultaneousHandlers={[]}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: screenDimensions.height * 0.15,
|
||||
left: screenDimensions.width * 0.4,
|
||||
width: screenDimensions.width * 0.2,
|
||||
height: screenDimensions.height * 0.7,
|
||||
zIndex: 5,
|
||||
}} />
|
||||
</TapGestureHandler>
|
||||
|
||||
{/* Volume Overlay */}
|
||||
{gestureControls.showVolumeOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: getDimensions().width / 2 - 60,
|
||||
top: getDimensions().height / 2 - 60,
|
||||
opacity: gestureControls.volumeOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<MaterialIcons
|
||||
name={volume === 0 ? "volume-off" : volume < 30 ? "volume-mute" : volume < 70 ? "volume-down" : "volume-up"}
|
||||
size={24}
|
||||
color={volume === 0 ? "#FF6B6B" : "#FFFFFF"}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Horizontal Dotted Progress Bar */}
|
||||
<View style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* Dotted background */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 1,
|
||||
}}>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 0.75,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Progress fill */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${volume}%`,
|
||||
height: 6,
|
||||
backgroundColor: volume === 0 ? '#FF6B6B' : '#E50914',
|
||||
borderRadius: 3,
|
||||
shadowColor: volume === 0 ? '#FF6B6B' : '#E50914',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 2,
|
||||
}} />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{Math.round(volume)}%
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Brightness Overlay */}
|
||||
{gestureControls.showBrightnessOverlay && (
|
||||
<Animated.View
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: getDimensions().width / 2 - 60,
|
||||
top: getDimensions().height / 2 - 60,
|
||||
opacity: gestureControls.brightnessOverlayOpacity,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
<View style={{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.9)',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
alignItems: 'center',
|
||||
width: 120,
|
||||
height: 120,
|
||||
justifyContent: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.5,
|
||||
shadowRadius: 8,
|
||||
elevation: 10,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
}}>
|
||||
<MaterialIcons
|
||||
name={brightness < 0.2 ? "brightness-low" : brightness < 0.5 ? "brightness-medium" : brightness < 0.8 ? "brightness-high" : "brightness-auto"}
|
||||
size={24}
|
||||
color={brightness < 0.2 ? "#FFD700" : "#FFFFFF"}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
|
||||
{/* Horizontal Dotted Progress Bar */}
|
||||
<View style={{
|
||||
width: 80,
|
||||
height: 6,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
borderRadius: 3,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
marginBottom: 8,
|
||||
}}>
|
||||
{/* Dotted background */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 1,
|
||||
}}>
|
||||
{Array.from({ length: 16 }, (_, i) => (
|
||||
<View
|
||||
key={i}
|
||||
style={{
|
||||
width: 1.5,
|
||||
height: 1.5,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: 0.75,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* Progress fill */}
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: `${brightness * 100}%`,
|
||||
height: 6,
|
||||
backgroundColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
|
||||
borderRadius: 3,
|
||||
shadowColor: brightness < 0.2 ? '#FFD700' : '#FFA500',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.6,
|
||||
shadowRadius: 2,
|
||||
}} />
|
||||
</View>
|
||||
|
||||
<Text style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
}}>
|
||||
{Math.round(brightness * 100)}%
|
||||
</Text>
|
||||
</View>
|
||||
</Animated.View>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -44,7 +44,7 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
// Reset
|
||||
logoOpacity.value = 0;
|
||||
logoScale.value = 1;
|
||||
|
||||
|
||||
// Start animations after 1 second delay
|
||||
logoOpacity.value = withDelay(
|
||||
1000,
|
||||
|
|
@ -53,7 +53,7 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
easing: Easing.out(Easing.cubic),
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
logoScale.value = withDelay(
|
||||
1000,
|
||||
withRepeat(
|
||||
|
|
@ -82,24 +82,23 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.openingOverlay,
|
||||
{
|
||||
opacity: backgroundFadeAnim,
|
||||
zIndex: 3000,
|
||||
},
|
||||
// Cast to any to support both number and string dimensions
|
||||
{ width, height } as any,
|
||||
{ width: '100%', height: '100%' },
|
||||
]}
|
||||
>
|
||||
{backdrop && (
|
||||
<Animated.View style={[
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
opacity: backdropImageOpacityAnim
|
||||
}
|
||||
]}>
|
||||
StyleSheet.absoluteFill,
|
||||
{
|
||||
opacity: backdropImageOpacityAnim
|
||||
}
|
||||
]}>
|
||||
<Image
|
||||
source={{ uri: backdrop }}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
|
|
@ -117,15 +116,15 @@ const LoadingOverlay: React.FC<LoadingOverlayProps> = ({
|
|||
locations={[0, 0.3, 0.7, 1]}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.loadingCloseButton}
|
||||
onPress={onClose}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons name="close" size={24} color="#ffffff" />
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
<View style={styles.openingContent}>
|
||||
{hasLogo && logo ? (
|
||||
<Reanimated.View style={[
|
||||
|
|
|
|||
|
|
@ -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, SkipInterval, SkipType } from '../../../services/introService';
|
||||
import { introService, SkipInterval, SkipType, CreditsInfo } from '../../../services/introService';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { useSettings } from '../../../hooks/useSettings';
|
||||
|
|
@ -22,8 +22,10 @@ interface SkipIntroButtonProps {
|
|||
episode?: number;
|
||||
malId?: string;
|
||||
kitsuId?: string;
|
||||
tmdbId?: number;
|
||||
currentTime: number;
|
||||
onSkip: (endTime: number) => void;
|
||||
onCreditsInfo?: (credits: CreditsInfo | null) => void;
|
||||
controlsVisible?: boolean;
|
||||
controlsFixedOffset?: number;
|
||||
}
|
||||
|
|
@ -35,8 +37,10 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
episode,
|
||||
malId,
|
||||
kitsuId,
|
||||
tmdbId,
|
||||
currentTime,
|
||||
onSkip,
|
||||
onCreditsInfo,
|
||||
controlsVisible = false,
|
||||
controlsFixedOffset = 100,
|
||||
}) => {
|
||||
|
|
@ -65,20 +69,22 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
|
||||
// Fetch skip data when episode changes
|
||||
useEffect(() => {
|
||||
const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`;
|
||||
const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}-${tmdbId}`;
|
||||
|
||||
if (!skipIntroEnabled) {
|
||||
setSkipIntervals([]);
|
||||
setCurrentInterval(null);
|
||||
setIsVisible(false);
|
||||
fetchedRef.current = false;
|
||||
if (onCreditsInfo) onCreditsInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (type !== 'series' || (!imdbId && !malId && !kitsuId && !tmdbId) || !season || !episode) {
|
||||
setSkipIntervals([]);
|
||||
fetchedRef.current = false;
|
||||
if (onCreditsInfo) onCreditsInfo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -94,24 +100,35 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
setSkipIntervals([]);
|
||||
|
||||
const fetchSkipData = async () => {
|
||||
logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`);
|
||||
logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (TMDB: ${tmdbId}, IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`);
|
||||
try {
|
||||
const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId);
|
||||
setSkipIntervals(intervals);
|
||||
const mediaType = type === 'series' ? 'tv' : type === 'movie' ? 'movie' : 'tv';
|
||||
const result = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId, tmdbId, mediaType);
|
||||
setSkipIntervals(result.intervals);
|
||||
|
||||
if (intervals.length > 0) {
|
||||
logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals);
|
||||
// Pass credits info to parent via callback
|
||||
if (onCreditsInfo) {
|
||||
onCreditsInfo(result.credits);
|
||||
}
|
||||
|
||||
if (result.intervals.length > 0) {
|
||||
logger.log(`[SkipIntroButton] ✓ Found ${result.intervals.length} skip intervals:`, result.intervals);
|
||||
} else {
|
||||
logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`);
|
||||
}
|
||||
|
||||
if (result.credits) {
|
||||
logger.log(`[SkipIntroButton] ✓ Found credits timing:`, result.credits);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[SkipIntroButton] Error fetching skip data:', error);
|
||||
setSkipIntervals([]);
|
||||
if (onCreditsInfo) onCreditsInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSkipData();
|
||||
}, [imdbId, type, season, episode, malId, kitsuId, skipIntroEnabled]);
|
||||
}, [imdbId, type, season, episode, malId, kitsuId, tmdbId, skipIntroEnabled, onCreditsInfo]);
|
||||
|
||||
// Determine active interval based on current playback position
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const qualityPadH = isTV ? 10 : isLargeTablet ? 9 : isTablet ? 8 : 8;
|
|||
const qualityPadV = isTV ? 4 : isLargeTablet ? 3 : isTablet ? 3 : 2;
|
||||
const qualityRadius = isTV ? 6 : isLargeTablet ? 5 : isTablet ? 4 : 4;
|
||||
const qualityTextFont = isTV ? 13 : isLargeTablet ? 12 : isTablet ? 11 : 11;
|
||||
const controlsGap = isTV ? 56 : isLargeTablet ? 48 : isTablet ? 44 : 40;
|
||||
const controlsGap = isTV ? 140 : isLargeTablet ? 110 : isTablet ? 90 : 70;
|
||||
const controlsTranslateY = isTV ? -48 : isLargeTablet ? -42 : isTablet ? -36 : -30;
|
||||
const skipTextFont = isTV ? 14 : isLargeTablet ? 13 : isTablet ? 12 : 12;
|
||||
const sliderBottom = isTV ? 60 : isLargeTablet ? 50 : isTablet ? 45 : 35;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
const { currentTheme } = useTheme();
|
||||
const { isTrailerPlaying: globalTrailerPlaying } = useTrailer();
|
||||
const videoRef = useRef<VideoRef>(null);
|
||||
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isPlaying, setIsPlaying] = useState(autoPlay);
|
||||
const [isMuted, setIsMuted] = useState(muted);
|
||||
|
|
@ -90,16 +90,16 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
if (videoRef.current) {
|
||||
// Pause the video
|
||||
setIsPlaying(false);
|
||||
|
||||
|
||||
// Seek to beginning to stop any background processing
|
||||
videoRef.current.seek(0);
|
||||
|
||||
|
||||
// Clear any pending timeouts
|
||||
if (hideControlsTimeout.current) {
|
||||
clearTimeout(hideControlsTimeout.current);
|
||||
hideControlsTimeout.current = null;
|
||||
}
|
||||
|
||||
|
||||
logger.info('TrailerPlayer', 'Video cleanup completed');
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -138,7 +138,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
// Component mount/unmount tracking
|
||||
useEffect(() => {
|
||||
setIsComponentMounted(true);
|
||||
|
||||
|
||||
return () => {
|
||||
setIsComponentMounted(false);
|
||||
cleanupVideo();
|
||||
|
|
@ -185,15 +185,15 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
|
||||
const showControlsWithTimeout = useCallback(() => {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
|
||||
setShowControls(true);
|
||||
controlsOpacity.value = withTiming(1, { duration: 200 });
|
||||
|
||||
|
||||
// Clear existing timeout
|
||||
if (hideControlsTimeout.current) {
|
||||
clearTimeout(hideControlsTimeout.current);
|
||||
}
|
||||
|
||||
|
||||
// Set new timeout to hide controls
|
||||
hideControlsTimeout.current = setTimeout(() => {
|
||||
if (isComponentMounted) {
|
||||
|
|
@ -205,7 +205,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
|
||||
const handleVideoPress = useCallback(() => {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
|
||||
if (showControls) {
|
||||
// If controls are visible, toggle play/pause
|
||||
handlePlayPause();
|
||||
|
|
@ -218,7 +218,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
const handlePlayPause = useCallback(async () => {
|
||||
try {
|
||||
if (!videoRef.current || !isComponentMounted) return;
|
||||
|
||||
|
||||
playButtonScale.value = withTiming(0.8, { duration: 100 }, () => {
|
||||
if (isComponentMounted) {
|
||||
playButtonScale.value = withTiming(1, { duration: 100 });
|
||||
|
|
@ -226,7 +226,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
});
|
||||
|
||||
setIsPlaying(!isPlaying);
|
||||
|
||||
|
||||
showControlsWithTimeout();
|
||||
} catch (error) {
|
||||
logger.error('TrailerPlayer', 'Error toggling playback:', error);
|
||||
|
|
@ -236,7 +236,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
const handleMuteToggle = useCallback(async () => {
|
||||
try {
|
||||
if (!videoRef.current || !isComponentMounted) return;
|
||||
|
||||
|
||||
setIsMuted(!isMuted);
|
||||
showControlsWithTimeout();
|
||||
} catch (error) {
|
||||
|
|
@ -246,28 +246,28 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
|
||||
setIsLoading(true);
|
||||
setHasError(false);
|
||||
// Only show loading spinner if not hidden
|
||||
loadingOpacity.value = hideLoadingSpinner ? 0 : 1;
|
||||
onLoadStart?.();
|
||||
logger.info('TrailerPlayer', 'Video load started');
|
||||
// logger.info('TrailerPlayer', 'Video load started');
|
||||
}, [loadingOpacity, onLoadStart, hideLoadingSpinner, isComponentMounted]);
|
||||
|
||||
const handleLoad = useCallback((data: OnLoadData) => {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
|
||||
setIsLoading(false);
|
||||
loadingOpacity.value = withTiming(0, { duration: 300 });
|
||||
setDuration(data.duration * 1000); // Convert to milliseconds
|
||||
onLoad?.();
|
||||
logger.info('TrailerPlayer', 'Video loaded successfully');
|
||||
// logger.info('TrailerPlayer', 'Video loaded successfully');
|
||||
}, [loadingOpacity, onLoad, isComponentMounted]);
|
||||
|
||||
const handleError = useCallback((error: any) => {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
|
||||
setIsLoading(false);
|
||||
setHasError(true);
|
||||
loadingOpacity.value = withTiming(0, { duration: 300 });
|
||||
|
|
@ -278,10 +278,10 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
|
||||
const handleProgress = useCallback((data: OnProgressData) => {
|
||||
if (!isComponentMounted) return;
|
||||
|
||||
|
||||
setPosition(data.currentTime * 1000); // Convert to milliseconds
|
||||
onProgress?.(data);
|
||||
|
||||
|
||||
if (onPlaybackStatusUpdate) {
|
||||
onPlaybackStatusUpdate({
|
||||
isLoaded: data.currentTime > 0,
|
||||
|
|
@ -304,7 +304,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
clearTimeout(hideControlsTimeout.current);
|
||||
hideControlsTimeout.current = null;
|
||||
}
|
||||
|
||||
|
||||
// Reset all animated values to prevent memory leaks
|
||||
try {
|
||||
controlsOpacity.value = 0;
|
||||
|
|
@ -313,7 +313,7 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
} catch (error) {
|
||||
logger.error('TrailerPlayer', 'Error cleaning up animation values:', error);
|
||||
}
|
||||
|
||||
|
||||
// Ensure video is stopped
|
||||
cleanupVideo();
|
||||
};
|
||||
|
|
@ -420,9 +420,9 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* Video controls overlay */}
|
||||
{/* Video controls overlay */}
|
||||
{!hideControls && (
|
||||
<TouchableOpacity
|
||||
<TouchableOpacity
|
||||
style={styles.videoOverlay}
|
||||
onPress={handleVideoPress}
|
||||
activeOpacity={1}
|
||||
|
|
@ -439,10 +439,10 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
<View style={styles.centerControls}>
|
||||
<Animated.View style={playButtonAnimatedStyle}>
|
||||
<TouchableOpacity style={styles.playButton} onPress={handlePlayPause}>
|
||||
<MaterialIcons
|
||||
name={isPlaying ? 'pause' : 'play-arrow'}
|
||||
size={isTablet ? 64 : 48}
|
||||
color="white"
|
||||
<MaterialIcons
|
||||
name={isPlaying ? 'pause' : 'play-arrow'}
|
||||
size={isTablet ? 64 : 48}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
|
@ -457,8 +457,8 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
{/* Progress bar */}
|
||||
<View style={styles.progressContainer}>
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
|
||||
<View
|
||||
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -466,27 +466,27 @@ const TrailerPlayer = React.forwardRef<any, TrailerPlayerProps>(({
|
|||
{/* Control buttons */}
|
||||
<View style={styles.controlButtons}>
|
||||
<TouchableOpacity style={styles.controlButton} onPress={handlePlayPause}>
|
||||
<MaterialIcons
|
||||
name={isPlaying ? 'pause' : 'play-arrow'}
|
||||
size={isTablet ? 32 : 24}
|
||||
color="white"
|
||||
<MaterialIcons
|
||||
name={isPlaying ? 'pause' : 'play-arrow'}
|
||||
size={isTablet ? 32 : 24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
<TouchableOpacity style={styles.controlButton} onPress={handleMuteToggle}>
|
||||
<MaterialIcons
|
||||
name={isMuted ? 'volume-off' : 'volume-up'}
|
||||
size={isTablet ? 32 : 24}
|
||||
color="white"
|
||||
<MaterialIcons
|
||||
name={isMuted ? 'volume-off' : 'volume-up'}
|
||||
size={isTablet ? 32 : 24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
|
||||
{onFullscreenToggle && (
|
||||
<TouchableOpacity style={styles.controlButton} onPress={onFullscreenToggle}>
|
||||
<MaterialIcons
|
||||
name="fullscreen"
|
||||
size={isTablet ? 32 : 24}
|
||||
color="white"
|
||||
<MaterialIcons
|
||||
name="fullscreen"
|
||||
size={isTablet ? 32 : 24}
|
||||
color="white"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -6,5 +6,8 @@ export const LOCALES = [
|
|||
{ code: 'ar', key: 'arabic' },
|
||||
{ code: 'fr', key: 'french' },
|
||||
{ code: 'it', key: 'italian' },
|
||||
{ code: 'es', key: 'spanish' }
|
||||
{ code: 'es', key: 'spanish' },
|
||||
{ code: 'hr', key: 'croatian' },
|
||||
{ code: 'zh-CN', key: 'chinese' },
|
||||
{ code: 'hi', key: 'hindi' }
|
||||
];
|
||||
File diff suppressed because it is too large
Load diff
85
src/contexts/SimklContext.tsx
Normal file
85
src/contexts/SimklContext.tsx
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import React, { createContext, useContext, ReactNode } from 'react';
|
||||
import { useSimklIntegration } from '../hooks/useSimklIntegration';
|
||||
import {
|
||||
SimklWatchlistItem,
|
||||
SimklPlaybackData,
|
||||
SimklRatingItem,
|
||||
SimklUserSettings,
|
||||
SimklStats,
|
||||
SimklStatus
|
||||
} from '../services/simklService';
|
||||
|
||||
export interface SimklContextProps {
|
||||
// Authentication
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
userSettings: SimklUserSettings | null;
|
||||
userStats: SimklStats | null;
|
||||
|
||||
// Collections - Shows
|
||||
watchingShows: SimklWatchlistItem[];
|
||||
planToWatchShows: SimklWatchlistItem[];
|
||||
completedShows: SimklWatchlistItem[];
|
||||
onHoldShows: SimklWatchlistItem[];
|
||||
droppedShows: SimklWatchlistItem[];
|
||||
|
||||
// Collections - Movies
|
||||
watchingMovies: SimklWatchlistItem[];
|
||||
planToWatchMovies: SimklWatchlistItem[];
|
||||
completedMovies: SimklWatchlistItem[];
|
||||
onHoldMovies: SimklWatchlistItem[];
|
||||
droppedMovies: SimklWatchlistItem[];
|
||||
|
||||
// Collections - Anime
|
||||
watchingAnime: SimklWatchlistItem[];
|
||||
planToWatchAnime: SimklWatchlistItem[];
|
||||
completedAnime: SimklWatchlistItem[];
|
||||
onHoldAnime: SimklWatchlistItem[];
|
||||
droppedAnime: SimklWatchlistItem[];
|
||||
|
||||
// Special collections
|
||||
continueWatching: SimklPlaybackData[];
|
||||
ratedContent: SimklRatingItem[];
|
||||
|
||||
// Lookup Sets (for O(1) status checks)
|
||||
watchingSet: Set<string>;
|
||||
planToWatchSet: Set<string>;
|
||||
completedSet: Set<string>;
|
||||
onHoldSet: Set<string>;
|
||||
droppedSet: Set<string>;
|
||||
|
||||
// Methods
|
||||
checkAuthStatus: () => Promise<void>;
|
||||
refreshAuthStatus: () => Promise<void>;
|
||||
loadAllCollections: () => Promise<void>;
|
||||
addToStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => Promise<boolean>;
|
||||
removeFromStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => Promise<boolean>;
|
||||
isInStatus: (imdbId: string, type: 'movie' | 'show' | 'anime', status: SimklStatus) => boolean;
|
||||
|
||||
// Scrobbling methods (from existing hook)
|
||||
startWatching?: (content: any, progress: number) => Promise<boolean>;
|
||||
updateProgress?: (content: any, progress: number) => Promise<boolean>;
|
||||
stopWatching?: (content: any, progress: number) => Promise<boolean>;
|
||||
syncAllProgress?: () => Promise<boolean>;
|
||||
fetchAndMergeSimklProgress?: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
const SimklContext = createContext<SimklContextProps | undefined>(undefined);
|
||||
|
||||
export function SimklProvider({ children }: { children: ReactNode }) {
|
||||
const simklIntegration = useSimklIntegration();
|
||||
|
||||
return (
|
||||
<SimklContext.Provider value={simklIntegration}>
|
||||
{children}
|
||||
</SimklContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSimklContext() {
|
||||
const context = useContext(SimklContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useSimklContext must be used within a SimklProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -77,7 +77,10 @@ export function useFeaturedContent() {
|
|||
}
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
// Only show loading if we don't have any content
|
||||
if (!featuredContent && !persistentStore.featuredContent) {
|
||||
setLoading(true);
|
||||
}
|
||||
cleanup();
|
||||
abortControllerRef.current = new AbortController();
|
||||
const signal = abortControllerRef.current.signal;
|
||||
|
|
@ -116,8 +119,8 @@ export function useFeaturedContent() {
|
|||
try {
|
||||
if (base.logo && !isTmdbUrl(base.logo)) {
|
||||
return base;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if (!settings.enrichMetadataWithTMDB) {
|
||||
return { ...base, logo: base.logo || undefined };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,11 +7,14 @@ import { fetchLatestGithubRelease, isAnyUpgrade } from '../services/githubReleas
|
|||
|
||||
const DISMISSED_KEY = '@github_major_update_dismissed_version';
|
||||
|
||||
import { GithubReleaseInfo } from '../services/githubReleaseService';
|
||||
|
||||
export interface MajorUpdateData {
|
||||
visible: boolean;
|
||||
latestTag?: string;
|
||||
releaseNotes?: string;
|
||||
releaseUrl?: string;
|
||||
releaseData?: GithubReleaseInfo;
|
||||
onDismiss: () => void;
|
||||
onLater: () => void;
|
||||
refresh: () => void;
|
||||
|
|
@ -22,6 +25,7 @@ export function useGithubMajorUpdate(): MajorUpdateData {
|
|||
const [latestTag, setLatestTag] = useState<string | undefined>();
|
||||
const [releaseNotes, setReleaseNotes] = useState<string | undefined>();
|
||||
const [releaseUrl, setReleaseUrl] = useState<string | undefined>();
|
||||
const [releaseData, setReleaseData] = useState<GithubReleaseInfo | undefined>();
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (Platform.OS === 'ios') return;
|
||||
|
|
@ -47,6 +51,7 @@ export function useGithubMajorUpdate(): MajorUpdateData {
|
|||
setLatestTag(info.tag_name);
|
||||
setReleaseNotes(info.body);
|
||||
setReleaseUrl(info.html_url);
|
||||
setReleaseData(info);
|
||||
setVisible(true);
|
||||
}
|
||||
} catch {
|
||||
|
|
@ -67,7 +72,7 @@ export function useGithubMajorUpdate(): MajorUpdateData {
|
|||
setVisible(false);
|
||||
}, []);
|
||||
|
||||
return { visible, latestTag, releaseNotes, releaseUrl, onDismiss, onLater, refresh: check };
|
||||
return { visible, latestTag, releaseNotes, releaseUrl, releaseData, onDismiss, onLater, refresh: check };
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -3,48 +3,6 @@ import { logger } from '../utils/logger';
|
|||
import { TMDBService } from '../services/tmdbService';
|
||||
import { isTmdbUrl } from '../utils/logoUtils';
|
||||
import FastImage from '@d11/react-native-fast-image';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
|
||||
// Cache for image availability checks
|
||||
const imageAvailabilityCache: Record<string, boolean> = {};
|
||||
|
||||
// Helper function to check image availability with caching
|
||||
const checkImageAvailability = async (url: string): Promise<boolean> => {
|
||||
// Check memory cache first
|
||||
if (imageAvailabilityCache[url] !== undefined) {
|
||||
return imageAvailabilityCache[url];
|
||||
}
|
||||
|
||||
// Check AsyncStorage cache
|
||||
try {
|
||||
const cachedResult = await mmkvStorage.getItem(`image_available:${url}`);
|
||||
if (cachedResult !== null) {
|
||||
const isAvailable = cachedResult === 'true';
|
||||
imageAvailabilityCache[url] = isAvailable;
|
||||
return isAvailable;
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore AsyncStorage errors
|
||||
}
|
||||
|
||||
// Perform actual check
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' });
|
||||
const isAvailable = response.ok;
|
||||
|
||||
// Update caches
|
||||
imageAvailabilityCache[url] = isAvailable;
|
||||
try {
|
||||
await mmkvStorage.setItem(`image_available:${url}`, isAvailable ? 'true' : 'false');
|
||||
} catch (error) {
|
||||
// Ignore AsyncStorage errors
|
||||
}
|
||||
|
||||
return isAvailable;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const useMetadataAssets = (
|
||||
metadata: any,
|
||||
|
|
@ -177,15 +135,15 @@ export const useMetadataAssets = (
|
|||
// Only update if request wasn't aborted and component is still mounted
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (metadata?.banner) {
|
||||
finalBanner = metadata.banner;
|
||||
bannerSourceType = 'default';
|
||||
} else if (details?.backdrop_path) {
|
||||
if (details?.backdrop_path) {
|
||||
finalBanner = tmdbService.getImageUrl(details.backdrop_path);
|
||||
bannerSourceType = 'tmdb';
|
||||
if (finalBanner) {
|
||||
FastImage.preload([{ uri: finalBanner }]);
|
||||
}
|
||||
} else if (metadata?.banner) {
|
||||
finalBanner = metadata.banner;
|
||||
bannerSourceType = 'default';
|
||||
} else {
|
||||
finalBanner = bannerImage || null;
|
||||
bannerSourceType = 'default';
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import * as Brightness from 'expo-brightness';
|
|||
interface GestureControlConfig {
|
||||
volume: number;
|
||||
setVolume: (value: number) => void;
|
||||
brightness: number;
|
||||
setBrightness: (value: number) => void;
|
||||
brightness?: number;
|
||||
setBrightness?: (value: number) => void;
|
||||
volumeRange?: { min: number; max: number }; // Default: { min: 0, max: 1 }
|
||||
volumeSensitivity?: number; // Default: 0.006 (iOS), 0.0084 (Android with 1.4x multiplier)
|
||||
brightnessSensitivity?: number; // Default: 0.004 (iOS), 0.0056 (Android with 1.4x multiplier)
|
||||
|
|
@ -19,67 +19,70 @@ export const usePlayerGestureControls = (config: GestureControlConfig) => {
|
|||
// State for overlays
|
||||
const [showVolumeOverlay, setShowVolumeOverlay] = useState(false);
|
||||
const [showBrightnessOverlay, setShowBrightnessOverlay] = useState(false);
|
||||
|
||||
const [showResizeModeOverlay, setShowResizeModeOverlay] = useState(false);
|
||||
|
||||
// Animated values
|
||||
const volumeGestureTranslateY = useRef(new Animated.Value(0)).current;
|
||||
const brightnessGestureTranslateY = useRef(new Animated.Value(0)).current;
|
||||
const volumeOverlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
const brightnessOverlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const resizeModeOverlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Tracking refs
|
||||
const lastVolumeGestureY = useRef(0);
|
||||
const lastBrightnessGestureY = useRef(0);
|
||||
const volumeOverlayTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
const brightnessOverlayTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const resizeModeOverlayTimeout = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Extract config with defaults and platform adjustments
|
||||
const volumeRange = config.volumeRange || { min: 0, max: 1 };
|
||||
const baseVolumeSensitivity = config.volumeSensitivity || 0.006;
|
||||
const baseBrightnessSensitivity = config.brightnessSensitivity || 0.004;
|
||||
const overlayTimeout = config.overlayTimeout || 1500;
|
||||
|
||||
|
||||
// Platform-specific sensitivity adjustments
|
||||
// Android needs higher sensitivity due to different touch handling
|
||||
const platformMultiplier = Platform.OS === 'android' ? 1.6 : 1.0;
|
||||
const volumeSensitivity = baseVolumeSensitivity * platformMultiplier;
|
||||
const brightnessSensitivity = baseBrightnessSensitivity * platformMultiplier;
|
||||
|
||||
|
||||
// Volume gesture handler
|
||||
const onVolumeGestureEvent = Animated.event(
|
||||
[{ nativeEvent: { translationY: volumeGestureTranslateY } }],
|
||||
{
|
||||
{
|
||||
useNativeDriver: false,
|
||||
listener: (event: PanGestureHandlerGestureEvent) => {
|
||||
const { translationY, state } = event.nativeEvent;
|
||||
|
||||
|
||||
if (state === State.ACTIVE) {
|
||||
// Auto-initialize on first active frame
|
||||
if (Math.abs(translationY) < 5 && Math.abs(lastVolumeGestureY.current - translationY) > 20) {
|
||||
lastVolumeGestureY.current = translationY;
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Calculate delta from last position
|
||||
const deltaY = -(translationY - lastVolumeGestureY.current);
|
||||
lastVolumeGestureY.current = translationY;
|
||||
|
||||
|
||||
// Normalize sensitivity based on volume range
|
||||
const rangeMultiplier = volumeRange.max - volumeRange.min;
|
||||
const volumeChange = deltaY * volumeSensitivity * rangeMultiplier;
|
||||
const newVolume = Math.max(volumeRange.min, Math.min(volumeRange.max, config.volume + volumeChange));
|
||||
|
||||
|
||||
config.setVolume(newVolume);
|
||||
|
||||
|
||||
if (config.debugMode) {
|
||||
console.log(`[GestureControls] Volume set to: ${newVolume} (Platform: ${Platform.OS}, Sensitivity: ${volumeSensitivity})`);
|
||||
}
|
||||
|
||||
|
||||
// Show overlay
|
||||
if (!showVolumeOverlay) {
|
||||
setShowVolumeOverlay(true);
|
||||
volumeOverlayOpacity.setValue(1);
|
||||
}
|
||||
|
||||
|
||||
// Reset hide timer
|
||||
if (volumeOverlayTimeout.current) {
|
||||
clearTimeout(volumeOverlayTimeout.current);
|
||||
|
|
@ -95,55 +98,59 @@ export const usePlayerGestureControls = (config: GestureControlConfig) => {
|
|||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Brightness gesture handler
|
||||
const onBrightnessGestureEvent = Animated.event(
|
||||
[{ nativeEvent: { translationY: brightnessGestureTranslateY } }],
|
||||
{
|
||||
useNativeDriver: false,
|
||||
listener: (event: PanGestureHandlerGestureEvent) => {
|
||||
const { translationY, state } = event.nativeEvent;
|
||||
|
||||
if (state === State.ACTIVE) {
|
||||
// Auto-initialize
|
||||
if (Math.abs(translationY) < 5 && Math.abs(lastBrightnessGestureY.current - translationY) > 20) {
|
||||
|
||||
// Brightness gesture handler - only active if brightness is provided
|
||||
const onBrightnessGestureEvent = config.brightness !== undefined && config.setBrightness
|
||||
? Animated.event(
|
||||
[{ nativeEvent: { translationY: brightnessGestureTranslateY } }],
|
||||
{
|
||||
useNativeDriver: false,
|
||||
listener: (event: PanGestureHandlerGestureEvent) => {
|
||||
const { translationY, state } = event.nativeEvent;
|
||||
|
||||
if (state === State.ACTIVE) {
|
||||
// Auto-initialize
|
||||
if (Math.abs(translationY) < 5 && Math.abs(lastBrightnessGestureY.current - translationY) > 20) {
|
||||
lastBrightnessGestureY.current = translationY;
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaY = -(translationY - lastBrightnessGestureY.current);
|
||||
lastBrightnessGestureY.current = translationY;
|
||||
return;
|
||||
|
||||
const brightnessSensitivity = (config.brightnessSensitivity || 0.004) * platformMultiplier;
|
||||
const brightnessChange = deltaY * brightnessSensitivity;
|
||||
const currentBrightness = config.brightness as number; // Safe cast as we checked undefined
|
||||
const newBrightness = Math.max(0, Math.min(1, currentBrightness + brightnessChange));
|
||||
|
||||
config.setBrightness!(newBrightness);
|
||||
Brightness.setBrightnessAsync(newBrightness).catch(() => { });
|
||||
|
||||
if (config.debugMode) {
|
||||
console.log(`[GestureControls] Device brightness set to: ${newBrightness} (Platform: ${Platform.OS}, Sensitivity: ${brightnessSensitivity})`);
|
||||
}
|
||||
|
||||
if (!showBrightnessOverlay) {
|
||||
setShowBrightnessOverlay(true);
|
||||
brightnessOverlayOpacity.setValue(1);
|
||||
}
|
||||
|
||||
if (brightnessOverlayTimeout.current) {
|
||||
clearTimeout(brightnessOverlayTimeout.current);
|
||||
}
|
||||
brightnessOverlayTimeout.current = setTimeout(() => {
|
||||
Animated.timing(brightnessOverlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start(() => setShowBrightnessOverlay(false));
|
||||
}, overlayTimeout);
|
||||
}
|
||||
|
||||
const deltaY = -(translationY - lastBrightnessGestureY.current);
|
||||
lastBrightnessGestureY.current = translationY;
|
||||
|
||||
const brightnessChange = deltaY * brightnessSensitivity;
|
||||
const newBrightness = Math.max(0, Math.min(1, config.brightness + brightnessChange));
|
||||
|
||||
config.setBrightness(newBrightness);
|
||||
Brightness.setBrightnessAsync(newBrightness).catch(() => {});
|
||||
|
||||
if (config.debugMode) {
|
||||
console.log(`[GestureControls] Device brightness set to: ${newBrightness} (Platform: ${Platform.OS}, Sensitivity: ${brightnessSensitivity})`);
|
||||
}
|
||||
|
||||
if (!showBrightnessOverlay) {
|
||||
setShowBrightnessOverlay(true);
|
||||
brightnessOverlayOpacity.setValue(1);
|
||||
}
|
||||
|
||||
if (brightnessOverlayTimeout.current) {
|
||||
clearTimeout(brightnessOverlayTimeout.current);
|
||||
}
|
||||
brightnessOverlayTimeout.current = setTimeout(() => {
|
||||
Animated.timing(brightnessOverlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}).start(() => setShowBrightnessOverlay(false));
|
||||
}, overlayTimeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = () => {
|
||||
if (volumeOverlayTimeout.current) {
|
||||
|
|
@ -152,19 +159,48 @@ export const usePlayerGestureControls = (config: GestureControlConfig) => {
|
|||
if (brightnessOverlayTimeout.current) {
|
||||
clearTimeout(brightnessOverlayTimeout.current);
|
||||
}
|
||||
if (resizeModeOverlayTimeout.current) {
|
||||
clearTimeout(resizeModeOverlayTimeout.current);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const showResizeModeOverlayFn = (callback?: () => void) => {
|
||||
if (resizeModeOverlayTimeout.current) {
|
||||
clearTimeout(resizeModeOverlayTimeout.current);
|
||||
}
|
||||
setShowResizeModeOverlay(true);
|
||||
Animated.timing(resizeModeOverlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 100,
|
||||
useNativeDriver: true,
|
||||
}).start(() => {
|
||||
if (callback) callback();
|
||||
resizeModeOverlayTimeout.current = setTimeout(() => {
|
||||
Animated.timing(resizeModeOverlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}).start(() => setShowResizeModeOverlay(false));
|
||||
}, overlayTimeout);
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
// Gesture handlers
|
||||
onVolumeGestureEvent,
|
||||
onBrightnessGestureEvent,
|
||||
|
||||
|
||||
// Overlay state
|
||||
showVolumeOverlay,
|
||||
showBrightnessOverlay,
|
||||
showResizeModeOverlay,
|
||||
volumeOverlayOpacity,
|
||||
brightnessOverlayOpacity,
|
||||
|
||||
resizeModeOverlayOpacity,
|
||||
|
||||
// Overlay functions
|
||||
showResizeModeOverlayFn,
|
||||
|
||||
// Cleanup
|
||||
cleanup,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@ export interface AppSettings {
|
|||
scraperRepositoryUrl: string; // URL to the scraper repository
|
||||
enableLocalScrapers: boolean; // Enable/disable local scraper functionality
|
||||
scraperTimeout: number; // Timeout for scraper execution in seconds
|
||||
enableScraperUrlValidation: boolean; // Enable/disable URL validation for scrapers
|
||||
streamDisplayMode: 'separate' | 'grouped'; // How to display streaming links - separately by provider or grouped under one name
|
||||
streamSortMode: 'scraper-then-quality' | 'quality-then-scraper'; // How to sort streams - by scraper first or quality first
|
||||
showScraperLogos: boolean; // Show scraper logos next to streaming links
|
||||
|
|
@ -60,6 +59,7 @@ export interface AppSettings {
|
|||
// Playback behavior
|
||||
alwaysResume: boolean; // If true, resume automatically without prompt when progress < 85%
|
||||
skipIntroEnabled: boolean; // Enable/disable Skip Intro overlay (IntroDB)
|
||||
introDbSource: 'theintrodb' | 'introdb'; // Preferred IntroDB source: TheIntroDB (new) or IntroDB (legacy)
|
||||
// Downloads
|
||||
enableDownloads: boolean; // Show Downloads tab and enable saving streams
|
||||
// Theme settings
|
||||
|
|
@ -138,7 +138,6 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
scraperRepositoryUrl: '',
|
||||
enableLocalScrapers: true,
|
||||
scraperTimeout: 60, // 60 seconds timeout
|
||||
enableScraperUrlValidation: true, // Enable URL validation by default
|
||||
streamDisplayMode: 'separate', // Default to separate display by provider
|
||||
streamSortMode: 'scraper-then-quality', // Default to current behavior (scraper first, then quality)
|
||||
showScraperLogos: true, // Show scraper logos by default
|
||||
|
|
@ -149,6 +148,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
|||
// Playback behavior defaults
|
||||
alwaysResume: true,
|
||||
skipIntroEnabled: true,
|
||||
introDbSource: 'theintrodb', // Default to TheIntroDB (new API)
|
||||
// Downloads
|
||||
enableDownloads: false,
|
||||
useExternalPlayerForDownloads: false,
|
||||
|
|
|
|||
789
src/hooks/useSimklIntegration.ts
Normal file
789
src/hooks/useSimklIntegration.ts
Normal file
|
|
@ -0,0 +1,789 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import {
|
||||
SimklService,
|
||||
SimklContentData,
|
||||
SimklPlaybackData,
|
||||
SimklUserSettings,
|
||||
SimklStats,
|
||||
SimklActivities,
|
||||
SimklWatchlistItem,
|
||||
SimklRatingItem,
|
||||
SimklStatus
|
||||
} from '../services/simklService';
|
||||
import { storageService } from '../services/storageService';
|
||||
import { mmkvStorage } from '../services/mmkvStorage';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const simklService = SimklService.getInstance();
|
||||
|
||||
// Cache keys
|
||||
const SIMKL_ACTIVITIES_CACHE = '@simkl:activities';
|
||||
const SIMKL_COLLECTIONS_CACHE = '@simkl:collections';
|
||||
const SIMKL_CACHE_TIMESTAMP = '@simkl:cache_timestamp';
|
||||
|
||||
let hasLoadedProfileOnce = false;
|
||||
let cachedUserSettings: SimklUserSettings | null = null;
|
||||
let cachedUserStats: SimklStats | null = null;
|
||||
|
||||
interface CollectionsCache {
|
||||
timestamp: number;
|
||||
watchingShows: SimklWatchlistItem[];
|
||||
watchingMovies: SimklWatchlistItem[];
|
||||
watchingAnime: SimklWatchlistItem[];
|
||||
planToWatchShows: SimklWatchlistItem[];
|
||||
planToWatchMovies: SimklWatchlistItem[];
|
||||
planToWatchAnime: SimklWatchlistItem[];
|
||||
completedShows: SimklWatchlistItem[];
|
||||
completedMovies: SimklWatchlistItem[];
|
||||
completedAnime: SimklWatchlistItem[];
|
||||
onHoldShows: SimklWatchlistItem[];
|
||||
onHoldMovies: SimklWatchlistItem[];
|
||||
onHoldAnime: SimklWatchlistItem[];
|
||||
droppedShows: SimklWatchlistItem[];
|
||||
droppedMovies: SimklWatchlistItem[];
|
||||
droppedAnime: SimklWatchlistItem[];
|
||||
continueWatching: SimklPlaybackData[];
|
||||
ratedContent: SimklRatingItem[];
|
||||
}
|
||||
|
||||
export function useSimklIntegration() {
|
||||
// Authentication state
|
||||
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [userSettings, setUserSettings] = useState<SimklUserSettings | null>(() => cachedUserSettings);
|
||||
const [userStats, setUserStats] = useState<SimklStats | null>(() => cachedUserStats);
|
||||
|
||||
// Collection state - Shows
|
||||
const [watchingShows, setWatchingShows] = useState<SimklWatchlistItem[]>([]);
|
||||
const [planToWatchShows, setPlanToWatchShows] = useState<SimklWatchlistItem[]>([]);
|
||||
const [completedShows, setCompletedShows] = useState<SimklWatchlistItem[]>([]);
|
||||
const [onHoldShows, setOnHoldShows] = useState<SimklWatchlistItem[]>([]);
|
||||
const [droppedShows, setDroppedShows] = useState<SimklWatchlistItem[]>([]);
|
||||
|
||||
// Collection state - Movies
|
||||
const [watchingMovies, setWatchingMovies] = useState<SimklWatchlistItem[]>([]);
|
||||
const [planToWatchMovies, setPlanToWatchMovies] = useState<SimklWatchlistItem[]>([]);
|
||||
const [completedMovies, setCompletedMovies] = useState<SimklWatchlistItem[]>([]);
|
||||
const [onHoldMovies, setOnHoldMovies] = useState<SimklWatchlistItem[]>([]);
|
||||
const [droppedMovies, setDroppedMovies] = useState<SimklWatchlistItem[]>([]);
|
||||
|
||||
// Collection state - Anime
|
||||
const [watchingAnime, setWatchingAnime] = useState<SimklWatchlistItem[]>([]);
|
||||
const [planToWatchAnime, setPlanToWatchAnime] = useState<SimklWatchlistItem[]>([]);
|
||||
const [completedAnime, setCompletedAnime] = useState<SimklWatchlistItem[]>([]);
|
||||
const [onHoldAnime, setOnHoldAnime] = useState<SimklWatchlistItem[]>([]);
|
||||
const [droppedAnime, setDroppedAnime] = useState<SimklWatchlistItem[]>([]);
|
||||
|
||||
// Special collections
|
||||
const [continueWatching, setContinueWatching] = useState<SimklPlaybackData[]>([]);
|
||||
const [ratedContent, setRatedContent] = useState<SimklRatingItem[]>([]);
|
||||
|
||||
// Lookup Sets for O(1) status checks (combined across types)
|
||||
const [watchingSet, setWatchingSet] = useState<Set<string>>(new Set());
|
||||
const [planToWatchSet, setPlanToWatchSet] = useState<Set<string>>(new Set());
|
||||
const [completedSet, setCompletedSet] = useState<Set<string>>(new Set());
|
||||
const [onHoldSet, setOnHoldSet] = useState<Set<string>>(new Set());
|
||||
const [droppedSet, setDroppedSet] = useState<Set<string>>(new Set());
|
||||
|
||||
// Activity tracking for caching
|
||||
const [lastActivityCheck, setLastActivityCheck] = useState<SimklActivities | null>(null);
|
||||
|
||||
const lastPlaybackFetchAt = useRef(0);
|
||||
const lastActivitiesCheckAt = useRef(0);
|
||||
const lastPlaybackActivityAt = useRef<number | null>(null);
|
||||
|
||||
// Helper: Normalize IMDB ID
|
||||
const normalizeImdbId = (imdbId: string): string => {
|
||||
return imdbId.replace('tt', '');
|
||||
};
|
||||
|
||||
// Helper: Parse activity date
|
||||
const parseActivityDate = (value?: string): number | null => {
|
||||
if (!value) return null;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
};
|
||||
|
||||
// Helper: Get latest playback activity timestamp
|
||||
const getLatestPlaybackActivity = (activities: SimklActivities | null): number | null => {
|
||||
if (!activities) return null;
|
||||
|
||||
const candidates: Array<number | null> = [
|
||||
parseActivityDate(activities.playback?.all),
|
||||
parseActivityDate(activities.playback?.movies),
|
||||
parseActivityDate(activities.playback?.episodes),
|
||||
parseActivityDate(activities.playback?.tv),
|
||||
parseActivityDate(activities.playback?.anime),
|
||||
parseActivityDate(activities.all),
|
||||
parseActivityDate((activities as any).last_update),
|
||||
parseActivityDate((activities as any).updated_at)
|
||||
];
|
||||
|
||||
const timestamps = candidates.filter((value): value is number => typeof value === 'number');
|
||||
if (timestamps.length === 0) return null;
|
||||
return Math.max(...timestamps);
|
||||
};
|
||||
|
||||
// Helper: Build lookup Sets
|
||||
const buildLookupSets = useCallback((
|
||||
watchingItems: SimklWatchlistItem[],
|
||||
planItems: SimklWatchlistItem[],
|
||||
completedItems: SimklWatchlistItem[],
|
||||
holdItems: SimklWatchlistItem[],
|
||||
droppedItems: SimklWatchlistItem[]
|
||||
) => {
|
||||
const buildSet = (items: SimklWatchlistItem[]): Set<string> => {
|
||||
const set = new Set<string>();
|
||||
items.forEach(item => {
|
||||
const content = item.show || item.movie || item.anime;
|
||||
if (content?.ids?.imdb) {
|
||||
const type = item.show ? 'show' : item.movie ? 'movie' : 'anime';
|
||||
const key = `${type}:${normalizeImdbId(content.ids.imdb)}`;
|
||||
set.add(key);
|
||||
}
|
||||
});
|
||||
return set;
|
||||
};
|
||||
|
||||
setWatchingSet(buildSet(watchingItems));
|
||||
setPlanToWatchSet(buildSet(planItems));
|
||||
setCompletedSet(buildSet(completedItems));
|
||||
setOnHoldSet(buildSet(holdItems));
|
||||
setDroppedSet(buildSet(droppedItems));
|
||||
}, []);
|
||||
|
||||
// Load collections from cache
|
||||
const loadFromCache = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
const cachedData = await mmkvStorage.getItem(SIMKL_COLLECTIONS_CACHE);
|
||||
if (!cachedData) return false;
|
||||
|
||||
const cache: CollectionsCache = JSON.parse(cachedData);
|
||||
|
||||
// Check cache age (5 minutes)
|
||||
const age = Date.now() - cache.timestamp;
|
||||
if (age > 5 * 60 * 1000) {
|
||||
logger.log('[useSimklIntegration] Cache expired');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Debug: Log cache sample to check poster data
|
||||
if (cache.watchingShows && cache.watchingShows.length > 0) {
|
||||
logger.log('[useSimklIntegration] Cache sample - first watching show:', JSON.stringify(cache.watchingShows[0], null, 2));
|
||||
}
|
||||
if (cache.watchingMovies && cache.watchingMovies.length > 0) {
|
||||
logger.log('[useSimklIntegration] Cache sample - first watching movie:', JSON.stringify(cache.watchingMovies[0], null, 2));
|
||||
}
|
||||
|
||||
// Load into state
|
||||
setWatchingShows(cache.watchingShows || []);
|
||||
setWatchingMovies(cache.watchingMovies || []);
|
||||
setWatchingAnime(cache.watchingAnime || []);
|
||||
setPlanToWatchShows(cache.planToWatchShows || []);
|
||||
setPlanToWatchMovies(cache.planToWatchMovies || []);
|
||||
setPlanToWatchAnime(cache.planToWatchAnime || []);
|
||||
setCompletedShows(cache.completedShows || []);
|
||||
setCompletedMovies(cache.completedMovies || []);
|
||||
setCompletedAnime(cache.completedAnime || []);
|
||||
setOnHoldShows(cache.onHoldShows || []);
|
||||
setOnHoldMovies(cache.onHoldMovies || []);
|
||||
setOnHoldAnime(cache.onHoldAnime || []);
|
||||
setDroppedShows(cache.droppedShows || []);
|
||||
setDroppedMovies(cache.droppedMovies || []);
|
||||
setDroppedAnime(cache.droppedAnime || []);
|
||||
setContinueWatching(cache.continueWatching || []);
|
||||
setRatedContent(cache.ratedContent || []);
|
||||
|
||||
// Build lookup Sets
|
||||
buildLookupSets(
|
||||
[...cache.watchingShows, ...cache.watchingMovies, ...cache.watchingAnime],
|
||||
[...cache.planToWatchShows, ...cache.planToWatchMovies, ...cache.planToWatchAnime],
|
||||
[...cache.completedShows, ...cache.completedMovies, ...cache.completedAnime],
|
||||
[...cache.onHoldShows, ...cache.onHoldMovies, ...cache.onHoldAnime],
|
||||
[...cache.droppedShows, ...cache.droppedMovies, ...cache.droppedAnime]
|
||||
);
|
||||
|
||||
logger.log('[useSimklIntegration] Loaded from cache');
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Failed to load from cache:', error);
|
||||
return false;
|
||||
}
|
||||
}, [buildLookupSets]);
|
||||
|
||||
// Save collections to cache
|
||||
const saveToCache = useCallback(async (collections: Omit<CollectionsCache, 'timestamp'>) => {
|
||||
try {
|
||||
const cache: CollectionsCache = {
|
||||
...collections,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
await mmkvStorage.setItem(SIMKL_COLLECTIONS_CACHE, JSON.stringify(cache));
|
||||
logger.log('[useSimklIntegration] Saved to cache');
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Failed to save to cache:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Compare activities to check if refresh needed
|
||||
const compareActivities = useCallback((
|
||||
newActivities: SimklActivities | null,
|
||||
cachedActivities: SimklActivities | null
|
||||
): boolean => {
|
||||
if (!cachedActivities) return true;
|
||||
if (!newActivities) return false;
|
||||
|
||||
// Compare timestamps
|
||||
const newAll = parseActivityDate(newActivities.all);
|
||||
const cachedAll = parseActivityDate(cachedActivities.all);
|
||||
|
||||
if (newAll && cachedAll && newAll > cachedAll) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}, []);
|
||||
|
||||
// Check authentication status
|
||||
const checkAuthStatus = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const authenticated = await simklService.isAuthenticated();
|
||||
setIsAuthenticated(authenticated);
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error checking auth status:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Force refresh
|
||||
const refreshAuthStatus = useCallback(async () => {
|
||||
await checkAuthStatus();
|
||||
}, [checkAuthStatus]);
|
||||
|
||||
// Load all collections (main data loading method)
|
||||
const loadAllCollections = useCallback(async () => {
|
||||
if (!isAuthenticated) {
|
||||
logger.log('[useSimklIntegration] Cannot load collections: not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// 1. Check activities first (efficient timestamp check)
|
||||
const activities = await simklService.getActivities();
|
||||
|
||||
// 2. Try to load from cache if activities haven't changed
|
||||
const cachedActivitiesStr = await mmkvStorage.getItem(SIMKL_ACTIVITIES_CACHE);
|
||||
const cachedActivities: SimklActivities | null = cachedActivitiesStr ? JSON.parse(cachedActivitiesStr) : null;
|
||||
|
||||
const needsRefresh = compareActivities(activities, cachedActivities);
|
||||
|
||||
if (!needsRefresh && cachedActivities) {
|
||||
const cacheLoaded = await loadFromCache();
|
||||
if (cacheLoaded) {
|
||||
setLastActivityCheck(activities);
|
||||
logger.log('[useSimklIntegration] Using cached collections');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.log('[useSimklIntegration] Fetching fresh collections from API');
|
||||
|
||||
// 3. Fetch all collections in parallel
|
||||
const [
|
||||
watchingShowsData,
|
||||
watchingMoviesData,
|
||||
watchingAnimeData,
|
||||
planToWatchShowsData,
|
||||
planToWatchMoviesData,
|
||||
planToWatchAnimeData,
|
||||
completedShowsData,
|
||||
completedMoviesData,
|
||||
completedAnimeData,
|
||||
onHoldShowsData,
|
||||
onHoldMoviesData,
|
||||
onHoldAnimeData,
|
||||
droppedShowsData,
|
||||
droppedMoviesData,
|
||||
droppedAnimeData,
|
||||
continueWatchingData,
|
||||
ratingsData
|
||||
] = await Promise.all([
|
||||
simklService.getAllItems('shows', 'watching'),
|
||||
simklService.getAllItems('movies', 'watching'),
|
||||
simklService.getAllItems('anime', 'watching'),
|
||||
simklService.getAllItems('shows', 'plantowatch'),
|
||||
simklService.getAllItems('movies', 'plantowatch'),
|
||||
simklService.getAllItems('anime', 'plantowatch'),
|
||||
simklService.getAllItems('shows', 'completed'),
|
||||
simklService.getAllItems('movies', 'completed'),
|
||||
simklService.getAllItems('anime', 'completed'),
|
||||
simklService.getAllItems('shows', 'hold'),
|
||||
simklService.getAllItems('movies', 'hold'),
|
||||
simklService.getAllItems('anime', 'hold'),
|
||||
simklService.getAllItems('shows', 'dropped'),
|
||||
simklService.getAllItems('movies', 'dropped'),
|
||||
simklService.getAllItems('anime', 'dropped'),
|
||||
simklService.getPlaybackStatus(),
|
||||
simklService.getRatings()
|
||||
]);
|
||||
|
||||
// 4. Update state
|
||||
setWatchingShows(watchingShowsData);
|
||||
setWatchingMovies(watchingMoviesData);
|
||||
setWatchingAnime(watchingAnimeData);
|
||||
setPlanToWatchShows(planToWatchShowsData);
|
||||
setPlanToWatchMovies(planToWatchMoviesData);
|
||||
setPlanToWatchAnime(planToWatchAnimeData);
|
||||
setCompletedShows(completedShowsData);
|
||||
setCompletedMovies(completedMoviesData);
|
||||
setCompletedAnime(completedAnimeData);
|
||||
setOnHoldShows(onHoldShowsData);
|
||||
setOnHoldMovies(onHoldMoviesData);
|
||||
setOnHoldAnime(onHoldAnimeData);
|
||||
setDroppedShows(droppedShowsData);
|
||||
setDroppedMovies(droppedMoviesData);
|
||||
setDroppedAnime(droppedAnimeData);
|
||||
setContinueWatching(continueWatchingData);
|
||||
setRatedContent(ratingsData);
|
||||
|
||||
// 5. Build lookup Sets
|
||||
buildLookupSets(
|
||||
[...watchingShowsData, ...watchingMoviesData, ...watchingAnimeData],
|
||||
[...planToWatchShowsData, ...planToWatchMoviesData, ...planToWatchAnimeData],
|
||||
[...completedShowsData, ...completedMoviesData, ...completedAnimeData],
|
||||
[...onHoldShowsData, ...onHoldMoviesData, ...onHoldAnimeData],
|
||||
[...droppedShowsData, ...droppedMoviesData, ...droppedAnimeData]
|
||||
);
|
||||
|
||||
// 6. Cache everything
|
||||
await saveToCache({
|
||||
watchingShows: watchingShowsData,
|
||||
watchingMovies: watchingMoviesData,
|
||||
watchingAnime: watchingAnimeData,
|
||||
planToWatchShows: planToWatchShowsData,
|
||||
planToWatchMovies: planToWatchMoviesData,
|
||||
planToWatchAnime: planToWatchAnimeData,
|
||||
completedShows: completedShowsData,
|
||||
completedMovies: completedMoviesData,
|
||||
completedAnime: completedAnimeData,
|
||||
onHoldShows: onHoldShowsData,
|
||||
onHoldMovies: onHoldMoviesData,
|
||||
onHoldAnime: onHoldAnimeData,
|
||||
droppedShows: droppedShowsData,
|
||||
droppedMovies: droppedMoviesData,
|
||||
droppedAnime: droppedAnimeData,
|
||||
continueWatching: continueWatchingData,
|
||||
ratedContent: ratingsData
|
||||
});
|
||||
|
||||
// Save activities
|
||||
if (activities) {
|
||||
await mmkvStorage.setItem(SIMKL_ACTIVITIES_CACHE, JSON.stringify(activities));
|
||||
setLastActivityCheck(activities);
|
||||
}
|
||||
|
||||
logger.log('[useSimklIntegration] Collections loaded successfully');
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error loading collections:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated, buildLookupSets, compareActivities, loadFromCache, saveToCache]);
|
||||
|
||||
// Status management methods
|
||||
const addToStatus = useCallback(async (
|
||||
imdbId: string,
|
||||
type: 'movie' | 'show' | 'anime',
|
||||
status: SimklStatus
|
||||
): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
try {
|
||||
const success = await simklService.addToList(imdbId, type, status);
|
||||
|
||||
if (success) {
|
||||
// Optimistic Set update
|
||||
const normalizedId = normalizeImdbId(imdbId);
|
||||
const key = `${type}:${normalizedId}`;
|
||||
|
||||
// Update appropriate Set
|
||||
switch (status) {
|
||||
case 'watching':
|
||||
setWatchingSet(prev => new Set(prev).add(key));
|
||||
break;
|
||||
case 'plantowatch':
|
||||
setPlanToWatchSet(prev => new Set(prev).add(key));
|
||||
break;
|
||||
case 'completed':
|
||||
setCompletedSet(prev => new Set(prev).add(key));
|
||||
break;
|
||||
case 'hold':
|
||||
setOnHoldSet(prev => new Set(prev).add(key));
|
||||
break;
|
||||
case 'dropped':
|
||||
setDroppedSet(prev => new Set(prev).add(key));
|
||||
break;
|
||||
}
|
||||
|
||||
// Reload collections to get fresh data
|
||||
setTimeout(() => loadAllCollections(), 1000);
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error adding to status:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated, loadAllCollections]);
|
||||
|
||||
const removeFromStatus = useCallback(async (
|
||||
imdbId: string,
|
||||
type: 'movie' | 'show' | 'anime',
|
||||
status: SimklStatus
|
||||
): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
try {
|
||||
const success = await simklService.removeFromList(imdbId, type);
|
||||
|
||||
if (success) {
|
||||
// Optimistic Set update
|
||||
const normalizedId = normalizeImdbId(imdbId);
|
||||
const key = `${type}:${normalizedId}`;
|
||||
|
||||
// Remove from all Sets
|
||||
setWatchingSet(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(key);
|
||||
return newSet;
|
||||
});
|
||||
setPlanToWatchSet(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(key);
|
||||
return newSet;
|
||||
});
|
||||
setCompletedSet(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(key);
|
||||
return newSet;
|
||||
});
|
||||
setOnHoldSet(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(key);
|
||||
return newSet;
|
||||
});
|
||||
setDroppedSet(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(key);
|
||||
return newSet;
|
||||
});
|
||||
|
||||
// Reload collections
|
||||
setTimeout(() => loadAllCollections(), 1000);
|
||||
}
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error removing from status:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated, loadAllCollections]);
|
||||
|
||||
const isInStatus = useCallback((
|
||||
imdbId: string,
|
||||
type: 'movie' | 'show' | 'anime',
|
||||
status: SimklStatus
|
||||
): boolean => {
|
||||
const normalizedId = normalizeImdbId(imdbId);
|
||||
const key = `${type}:${normalizedId}`;
|
||||
|
||||
switch (status) {
|
||||
case 'watching':
|
||||
return watchingSet.has(key);
|
||||
case 'plantowatch':
|
||||
return planToWatchSet.has(key);
|
||||
case 'completed':
|
||||
return completedSet.has(key);
|
||||
case 'hold':
|
||||
return onHoldSet.has(key);
|
||||
case 'dropped':
|
||||
return droppedSet.has(key);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}, [watchingSet, planToWatchSet, completedSet, onHoldSet, droppedSet]);
|
||||
|
||||
// Load playback/continue watching (kept from original)
|
||||
const loadPlaybackStatus = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
try {
|
||||
const playback = await simklService.getPlaybackStatus();
|
||||
setContinueWatching(playback);
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error loading playback status:', error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Load user settings and stats (kept from original)
|
||||
const loadUserProfile = useCallback(async () => {
|
||||
if (!isAuthenticated) return;
|
||||
try {
|
||||
const settings = await simklService.getUserSettings();
|
||||
setUserSettings(settings);
|
||||
cachedUserSettings = settings;
|
||||
|
||||
const accountId = settings?.account?.id;
|
||||
if (accountId) {
|
||||
const stats = await simklService.getUserStats(accountId);
|
||||
setUserStats(stats);
|
||||
cachedUserStats = stats;
|
||||
} else {
|
||||
setUserStats(null);
|
||||
cachedUserStats = null;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error loading user profile:', error);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Scrobbling methods (kept from original)
|
||||
const startWatching = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
try {
|
||||
const res = await simklService.scrobbleStart(content, progress);
|
||||
return !!res;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error starting watch:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const updateProgress = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
try {
|
||||
const res = await simklService.scrobblePause(content, progress);
|
||||
return !!res;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error updating progress:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const stopWatching = useCallback(async (content: SimklContentData, progress: number): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
try {
|
||||
const res = await simklService.scrobbleStop(content, progress);
|
||||
return !!res;
|
||||
} catch (error) {
|
||||
logger.error('[useSimklIntegration] Error stopping watch:', error);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
// Sync methods (kept from original)
|
||||
const syncAllProgress = useCallback(async (): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
try {
|
||||
const unsynced = await storageService.getUnsyncedProgress();
|
||||
const itemsToSync = unsynced.filter(i => !i.progress.simklSynced || (i.progress.simklLastSynced && i.progress.lastUpdated > i.progress.simklLastSynced));
|
||||
|
||||
if (itemsToSync.length === 0) return true;
|
||||
|
||||
logger.log(`[useSimklIntegration] Found ${itemsToSync.length} items to sync to Simkl`);
|
||||
|
||||
for (const item of itemsToSync) {
|
||||
try {
|
||||
const season = item.episodeId ? parseInt(item.episodeId.split('S')[1]?.split('E')[0] || '0') : undefined;
|
||||
const episode = item.episodeId ? parseInt(item.episodeId.split('E')[1] || '0') : undefined;
|
||||
|
||||
const content: SimklContentData = {
|
||||
type: item.type === 'series' ? 'episode' : 'movie',
|
||||
title: 'Unknown',
|
||||
ids: { imdb: item.id },
|
||||
season,
|
||||
episode
|
||||
};
|
||||
|
||||
const progressPercent = (item.progress.currentTime / item.progress.duration) * 100;
|
||||
|
||||
let success = false;
|
||||
if (progressPercent >= 85) {
|
||||
if (content.type === 'movie') {
|
||||
await simklService.addToHistory({ movies: [{ ids: { imdb: item.id } }] });
|
||||
} else {
|
||||
await simklService.addToHistory({ shows: [{ ids: { imdb: item.id }, seasons: [{ number: season, episodes: [{ number: episode }] }] }] });
|
||||
}
|
||||
success = true;
|
||||
} else {
|
||||
const res = await simklService.scrobblePause(content, progressPercent);
|
||||
success = !!res;
|
||||
}
|
||||
|
||||
if (success) {
|
||||
await storageService.updateSimklSyncStatus(item.id, item.type, true, progressPercent, item.episodeId);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`[useSimklIntegration] Failed to sync item ${item.id}`, e);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('[useSimklIntegration] Error syncing all progress', e);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const fetchAndMergeSimklProgress = useCallback(async (): Promise<boolean> => {
|
||||
if (!isAuthenticated) return false;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (now - lastActivitiesCheckAt.current < 30000) {
|
||||
return true;
|
||||
}
|
||||
lastActivitiesCheckAt.current = now;
|
||||
|
||||
const activities = await simklService.getActivities();
|
||||
const latestPlaybackActivity = getLatestPlaybackActivity(activities);
|
||||
|
||||
if (latestPlaybackActivity && lastPlaybackActivityAt.current === latestPlaybackActivity) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (latestPlaybackActivity) {
|
||||
lastPlaybackActivityAt.current = latestPlaybackActivity;
|
||||
}
|
||||
|
||||
if (now - lastPlaybackFetchAt.current < 60000) {
|
||||
return true;
|
||||
}
|
||||
lastPlaybackFetchAt.current = now;
|
||||
|
||||
const playback = await simklService.getPlaybackStatus();
|
||||
logger.log(`[useSimklIntegration] fetched Simkl playback: ${playback.length}`);
|
||||
|
||||
setContinueWatching(playback);
|
||||
|
||||
for (const item of playback) {
|
||||
let id: string | undefined;
|
||||
let type: string;
|
||||
let episodeId: string | undefined;
|
||||
|
||||
if (item.movie) {
|
||||
id = item.movie.ids.imdb;
|
||||
type = 'movie';
|
||||
} else if (item.show && item.episode) {
|
||||
id = item.show.ids.imdb;
|
||||
type = 'series';
|
||||
const epNum = (item.episode as any).episode ?? (item.episode as any).number;
|
||||
episodeId = epNum !== undefined ? `${id}:${item.episode.season}:${epNum}` : undefined;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
await storageService.mergeWithSimklProgress(
|
||||
id,
|
||||
type!,
|
||||
item.progress,
|
||||
item.paused_at,
|
||||
episodeId
|
||||
);
|
||||
|
||||
await storageService.updateSimklSyncStatus(id, type!, true, item.progress, episodeId);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.error('[useSimklIntegration] Error fetching/merging Simkl progress', e);
|
||||
return false;
|
||||
}
|
||||
}, [isAuthenticated, getLatestPlaybackActivity]);
|
||||
|
||||
// Effects
|
||||
useEffect(() => {
|
||||
checkAuthStatus();
|
||||
}, [checkAuthStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
fetchAndMergeSimklProgress();
|
||||
if (!hasLoadedProfileOnce) {
|
||||
hasLoadedProfileOnce = true;
|
||||
loadUserProfile();
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated, fetchAndMergeSimklProgress, loadUserProfile]);
|
||||
|
||||
// App state listener for sync
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') {
|
||||
fetchAndMergeSimklProgress();
|
||||
loadAllCollections();
|
||||
}
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [isAuthenticated, fetchAndMergeSimklProgress, loadAllCollections]);
|
||||
|
||||
return {
|
||||
// Authentication
|
||||
isAuthenticated,
|
||||
isLoading,
|
||||
userSettings,
|
||||
userStats,
|
||||
checkAuthStatus,
|
||||
refreshAuthStatus,
|
||||
|
||||
// Collections - Shows
|
||||
watchingShows,
|
||||
planToWatchShows,
|
||||
completedShows,
|
||||
onHoldShows,
|
||||
droppedShows,
|
||||
|
||||
// Collections - Movies
|
||||
watchingMovies,
|
||||
planToWatchMovies,
|
||||
completedMovies,
|
||||
onHoldMovies,
|
||||
droppedMovies,
|
||||
|
||||
// Collections - Anime
|
||||
watchingAnime,
|
||||
planToWatchAnime,
|
||||
completedAnime,
|
||||
onHoldAnime,
|
||||
droppedAnime,
|
||||
|
||||
// Special collections
|
||||
continueWatching,
|
||||
ratedContent,
|
||||
|
||||
// Lookup Sets
|
||||
watchingSet,
|
||||
planToWatchSet,
|
||||
completedSet,
|
||||
onHoldSet,
|
||||
droppedSet,
|
||||
|
||||
// Methods
|
||||
loadAllCollections,
|
||||
addToStatus,
|
||||
removeFromStatus,
|
||||
isInStatus,
|
||||
|
||||
// Scrobbling (kept from original)
|
||||
startWatching,
|
||||
updateProgress,
|
||||
stopWatching,
|
||||
syncAllProgress,
|
||||
fetchAndMergeSimklProgress,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { useTraktIntegration } from './useTraktIntegration';
|
||||
import { useSimklIntegration } from './useSimklIntegration';
|
||||
import { useTraktAutosyncSettings } from './useTraktAutosyncSettings';
|
||||
import { TraktContentData } from '../services/traktService';
|
||||
import { SimklContentData } from '../services/simklService';
|
||||
import { storageService } from '../services/storageService';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
|
|
@ -30,6 +32,13 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
stopWatchingImmediate
|
||||
} = useTraktIntegration();
|
||||
|
||||
const {
|
||||
isAuthenticated: isSimklAuthenticated,
|
||||
startWatching: startSimkl,
|
||||
updateProgress: updateSimkl,
|
||||
stopWatching: stopSimkl
|
||||
} = useSimklIntegration();
|
||||
|
||||
const { settings: autosyncSettings } = useTraktAutosyncSettings();
|
||||
|
||||
const hasStartedWatching = useRef(false);
|
||||
|
|
@ -145,14 +154,29 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
}
|
||||
}, [options]);
|
||||
|
||||
const buildSimklContentData = useCallback((): SimklContentData => {
|
||||
return {
|
||||
type: options.type === 'series' ? 'episode' : 'movie',
|
||||
title: options.title,
|
||||
ids: {
|
||||
imdb: options.imdbId
|
||||
},
|
||||
season: options.season,
|
||||
episode: options.episode
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
// Start watching (scrobble start)
|
||||
const handlePlaybackStart = useCallback(async (currentTime: number, duration: number) => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
|
||||
logger.log(`[TraktAutosync] handlePlaybackStart called: time=${currentTime}, duration=${duration}, authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}, alreadyStarted=${hasStartedWatching.current}, alreadyStopped=${hasStopped.current}, sessionComplete=${isSessionComplete.current}, session=${sessionKey.current}`);
|
||||
|
||||
if (!isAuthenticated || !autosyncSettings.enabled) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackStart: Trakt (auth=${isAuthenticated}, enabled=${autosyncSettings.enabled}), Simkl (auth=${isSimklAuthenticated})`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -190,16 +214,28 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
return;
|
||||
}
|
||||
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
if (shouldSyncTrakt) {
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
hasStartedWatching.current = true;
|
||||
hasStopped.current = false; // Reset stop flag when starting
|
||||
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
|
||||
}
|
||||
} else {
|
||||
// If Trakt is disabled but Simkl is enabled, we still mark stated/stopped flags for local logic
|
||||
hasStartedWatching.current = true;
|
||||
hasStopped.current = false; // Reset stop flag when starting
|
||||
logger.log(`[TraktAutosync] Started watching: ${contentData.title} (session: ${sessionKey.current})`);
|
||||
hasStopped.current = false;
|
||||
}
|
||||
|
||||
// Simkl Start
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await startSimkl(simklData, progressPercent);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error starting watch:', error);
|
||||
}
|
||||
}, [isAuthenticated, autosyncSettings.enabled, startWatching, buildContentData]);
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, startWatching, startSimkl, buildContentData, buildSimklContentData]);
|
||||
|
||||
// Sync progress during playback
|
||||
const handleProgressUpdate = useCallback(async (
|
||||
|
|
@ -209,7 +245,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
) => {
|
||||
if (isUnmounted.current) return; // Prevent execution after component unmount
|
||||
|
||||
if (!isAuthenticated || !autosyncSettings.enabled || duration <= 0) {
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if ((!shouldSyncTrakt && !shouldSyncSimkl) || duration <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -225,70 +264,95 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
|
||||
// IMMEDIATE SYNC: Use immediate method for user-triggered actions (force=true)
|
||||
// Use regular queued method for background periodic syncs
|
||||
let success: boolean;
|
||||
let traktSuccess: boolean = false;
|
||||
|
||||
if (force) {
|
||||
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
success = await updateProgressImmediate(contentData, progressPercent);
|
||||
if (shouldSyncTrakt) {
|
||||
if (force) {
|
||||
// IMMEDIATE: User action (pause/unpause) - bypass queue
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
traktSuccess = await updateProgressImmediate(contentData, progressPercent);
|
||||
|
||||
if (success) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
if (traktSuccess) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
|
||||
logger.log(`[TraktAutosync] IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
} else {
|
||||
// BACKGROUND: Periodic sync - use queued method
|
||||
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
|
||||
logger.log(`[TraktAutosync] Trakt IMMEDIATE: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
} else {
|
||||
// BACKGROUND: Periodic sync - use queued method
|
||||
const progressDiff = Math.abs(progressPercent - lastSyncProgress.current);
|
||||
|
||||
// Only skip if not forced and progress difference is minimal (< 0.5%)
|
||||
if (progressDiff < 0.5) {
|
||||
return;
|
||||
}
|
||||
// Only skip if not forced and progress difference is minimal (< 0.5%)
|
||||
if (progressDiff < 0.5) {
|
||||
logger.log(`[TraktAutosync] Trakt: Skipping periodic progress update, progress diff too small (${progressDiff.toFixed(2)}%)`);
|
||||
// If only Trakt is active and we skip, we should return here.
|
||||
// If Simkl is also active, we continue to let Simkl update.
|
||||
if (!shouldSyncSimkl) return;
|
||||
}
|
||||
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
success = await updateProgress(contentData, progressPercent, force);
|
||||
const contentData = buildContentData();
|
||||
if (!contentData) {
|
||||
logger.warn('[TraktAutosync] Skipping Trakt progress update: invalid content data');
|
||||
return;
|
||||
}
|
||||
traktSuccess = await updateProgress(contentData, progressPercent, force);
|
||||
|
||||
if (success) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
if (traktSuccess) {
|
||||
lastSyncTime.current = now;
|
||||
lastSyncProgress.current = progressPercent;
|
||||
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
// Update local storage sync status
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
|
||||
// Progress sync logging removed
|
||||
// Progress sync logging removed
|
||||
logger.log(`[TraktAutosync] Trakt: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simkl Update (No immediate/queued differentiation for now in Simkl hook, just call update)
|
||||
if (shouldSyncSimkl) {
|
||||
// Debounce simkl updates slightly if needed, but hook handles calls.
|
||||
// We do basic difference check here
|
||||
const simklData = buildSimklContentData();
|
||||
await updateSimkl(simklData, progressPercent);
|
||||
|
||||
// Update local storage for Simkl
|
||||
await storageService.updateSimklSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId
|
||||
);
|
||||
logger.log(`[TraktAutosync] Simkl: Progress updated to ${progressPercent.toFixed(1)}%`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[TraktAutosync] Error syncing progress:', error);
|
||||
}
|
||||
}, [isAuthenticated, autosyncSettings.enabled, updateProgress, updateProgressImmediate, buildContentData, options]);
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, updateProgress, updateSimkl, updateProgressImmediate, buildContentData, buildSimklContentData, options]);
|
||||
|
||||
// Handle playback end/pause
|
||||
const handlePlaybackEnd = useCallback(async (currentTime: number, duration: number, reason: 'ended' | 'unmount' | 'user_close' = 'ended') => {
|
||||
|
|
@ -298,8 +362,11 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
|
||||
// Removed excessive logging for handlePlaybackEnd calls
|
||||
|
||||
if (!isAuthenticated || !autosyncSettings.enabled) {
|
||||
// logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: authenticated=${isAuthenticated}, enabled=${autosyncSettings.enabled}`);
|
||||
const shouldSyncTrakt = isAuthenticated && autosyncSettings.enabled;
|
||||
const shouldSyncSimkl = isSimklAuthenticated;
|
||||
|
||||
if (!shouldSyncTrakt && !shouldSyncSimkl) {
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: Neither Trakt nor Simkl are active.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -323,6 +390,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
isSignificantUpdate = true;
|
||||
} else {
|
||||
// Already stopped this session, skipping duplicate call
|
||||
logger.log(`[TraktAutosync] Skipping handlePlaybackEnd: session already stopped and no significant progress improvement.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -390,8 +458,20 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
if (!hasStartedWatching.current && progressPercent > 1) {
|
||||
const contentData = buildContentData();
|
||||
if (contentData) {
|
||||
const success = await startWatching(contentData, progressPercent);
|
||||
if (success) {
|
||||
let started = false;
|
||||
// Try starting Trakt if enabled
|
||||
if (shouldSyncTrakt) {
|
||||
const s = await startWatching(contentData, progressPercent);
|
||||
if (s) started = true;
|
||||
}
|
||||
// Try starting Simkl if enabled (always 'true' effectively if authenticated)
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await startSimkl(simklData, progressPercent);
|
||||
started = true;
|
||||
}
|
||||
|
||||
if (started) {
|
||||
hasStartedWatching.current = true;
|
||||
}
|
||||
}
|
||||
|
|
@ -401,6 +481,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
// Lower threshold for unmount calls to catch more edge cases
|
||||
if (reason === 'unmount' && progressPercent < 0.5) {
|
||||
// Early unmount stop logging removed
|
||||
logger.log(`[TraktAutosync] Skipping unmount stop call due to minimal progress (${progressPercent.toFixed(1)}%)`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -419,13 +500,24 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
return;
|
||||
}
|
||||
|
||||
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
||||
const success = useImmediate
|
||||
? await stopWatchingImmediate(contentData, progressPercent)
|
||||
: await stopWatching(contentData, progressPercent);
|
||||
let overallSuccess = false;
|
||||
|
||||
if (success) {
|
||||
// Update local storage sync status
|
||||
// IMMEDIATE: Use immediate method for user-initiated closes, regular method for natural ends
|
||||
let traktStopSuccess = false;
|
||||
if (shouldSyncTrakt) {
|
||||
traktStopSuccess = useImmediate
|
||||
? await stopWatchingImmediate(contentData, progressPercent)
|
||||
: await stopWatching(contentData, progressPercent);
|
||||
if (traktStopSuccess) {
|
||||
logger.log(`[TraktAutosync] Trakt: ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
overallSuccess = true;
|
||||
} else {
|
||||
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (traktStopSuccess) {
|
||||
// Update local storage sync status for Trakt
|
||||
await storageService.updateTraktSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
|
|
@ -434,7 +526,30 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
options.episodeId,
|
||||
currentTime
|
||||
);
|
||||
} else if (shouldSyncTrakt) {
|
||||
// If Trakt stop failed, reset the stop flag so we can try again later
|
||||
hasStopped.current = false;
|
||||
logger.warn(`[TraktAutosync] Trakt: Failed to stop watching, reset stop flag for retry`);
|
||||
}
|
||||
|
||||
// Simkl Stop
|
||||
if (shouldSyncSimkl) {
|
||||
const simklData = buildSimklContentData();
|
||||
await stopSimkl(simklData, progressPercent);
|
||||
|
||||
// Update local storage sync status for Simkl
|
||||
await storageService.updateSimklSyncStatus(
|
||||
options.id,
|
||||
options.type,
|
||||
true,
|
||||
progressPercent,
|
||||
options.episodeId
|
||||
);
|
||||
logger.log(`[TraktAutosync] Simkl: Successfully stopped watching: ${simklData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
overallSuccess = true; // Mark overall success if at least one worked (Simkl doesn't have immediate/queued logic yet)
|
||||
}
|
||||
|
||||
if (overallSuccess) {
|
||||
// Mark session as complete if >= user completion threshold
|
||||
if (progressPercent >= autosyncSettings.completionThreshold) {
|
||||
isSessionComplete.current = true;
|
||||
|
|
@ -450,8 +565,10 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
currentTime: duration,
|
||||
duration,
|
||||
lastUpdated: Date.now(),
|
||||
traktSynced: true,
|
||||
traktProgress: Math.max(progressPercent, 100),
|
||||
traktSynced: shouldSyncTrakt ? true : undefined,
|
||||
traktProgress: shouldSyncTrakt ? Math.max(progressPercent, 100) : undefined,
|
||||
simklSynced: shouldSyncSimkl ? true : undefined,
|
||||
simklProgress: shouldSyncSimkl ? Math.max(progressPercent, 100) : undefined,
|
||||
} as any,
|
||||
options.episodeId,
|
||||
{ forceNotify: true }
|
||||
|
|
@ -460,11 +577,14 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
} catch { }
|
||||
}
|
||||
|
||||
logger.log(`[TraktAutosync] ${useImmediate ? 'IMMEDIATE: ' : ''}Successfully stopped watching: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
// General success log if at least one service succeeded
|
||||
if (!shouldSyncTrakt || traktStopSuccess) { // Only log this if Trakt succeeded or wasn't active
|
||||
logger.log(`[TraktAutosync] Overall: Successfully processed stop for: ${contentData.title} (${progressPercent.toFixed(1)}% - ${reason})`);
|
||||
}
|
||||
} else {
|
||||
// If stop failed, reset the stop flag so we can try again later
|
||||
// If neither service succeeded, reset the stop flag
|
||||
hasStopped.current = false;
|
||||
logger.warn(`[TraktAutosync] Failed to stop watching, reset stop flag for retry`);
|
||||
logger.warn(`[TraktAutosync] Overall: Failed to stop watching, reset stop flag for retry`);
|
||||
}
|
||||
|
||||
// Reset state only for natural end or very high progress unmounts
|
||||
|
|
@ -480,7 +600,7 @@ export function useTraktAutosync(options: TraktAutosyncOptions) {
|
|||
// Reset stop flag on error so we can try again
|
||||
hasStopped.current = false;
|
||||
}
|
||||
}, [isAuthenticated, autosyncSettings.enabled, stopWatching, stopWatchingImmediate, startWatching, buildContentData, options]);
|
||||
}, [isAuthenticated, isSimklAuthenticated, autosyncSettings.enabled, stopWatching, stopSimkl, stopWatchingImmediate, startWatching, buildContentData, buildSimklContentData, options]);
|
||||
|
||||
// Reset state (useful when switching content)
|
||||
const resetState = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ export const useTraktComments = ({
|
|||
const traktService = TraktService.getInstance();
|
||||
let fetchedComments: TraktContentComment[] = [];
|
||||
|
||||
console.log(`[useTraktComments] Loading comments for ${type} - IMDb: ${imdbId}, TMDB: ${tmdbId}, page: ${pageNum}`);
|
||||
|
||||
|
||||
switch (type) {
|
||||
case 'movie':
|
||||
|
|
@ -87,10 +87,10 @@ export const useTraktComments = ({
|
|||
setComments(prevComments => {
|
||||
if (append) {
|
||||
const newComments = [...prevComments, ...fetchedComments];
|
||||
console.log(`[useTraktComments] Appended ${fetchedComments.length} comments, total: ${newComments.length}`);
|
||||
|
||||
return newComments;
|
||||
} else {
|
||||
console.log(`[useTraktComments] Loaded ${fetchedComments.length} comments`);
|
||||
|
||||
return fetchedComments;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -522,6 +522,27 @@
|
|||
"sync_success_msg": "تمت مزامنة تقدم المشاهدة مع Trakt بنجاح.",
|
||||
"sync_error_msg": "فشلت المزامنة. يرجى المحاولة مرة أخرى."
|
||||
},
|
||||
"simkl": {
|
||||
"title": "إعدادات Simkl",
|
||||
"settings_title": "إعدادات Simkl",
|
||||
"connect_title": "الاتصال بـ Simkl",
|
||||
"connect_desc": "زامن تاريخ مشاهدتك وتتبع ما تشاهده",
|
||||
"sign_in": "تسجيل الدخول بـ Simkl",
|
||||
"sign_out": "قطع الاتصال",
|
||||
"sign_out_confirm": "هل أنت متأكد من أنك تريد قطع الاتصال من Simkl؟",
|
||||
"syncing_desc": "عناصرك المشاهدة تتم مزامنتها مع Simkl.",
|
||||
"auth_success_title": "تم الاتصال بنجاح",
|
||||
"auth_success_msg": "تم ربط حساب Simkl الخاص بك بنجاح.",
|
||||
"auth_error_title": "خطأ في المصادقة",
|
||||
"auth_error_msg": "فشل في إكمال المصادقة مع Simkl.",
|
||||
"auth_error_generic": "حدث خطأ أثناء المصادقة.",
|
||||
"sign_out_error": "فشل في قطع الاتصال من Simkl.",
|
||||
"config_error_title": "خطأ في التكوين",
|
||||
"config_error_msg": "معرف عميل Simkl مفقود في متغيرات البيئة.",
|
||||
"conflict_title": "تعارض",
|
||||
"conflict_msg": "لا يمكنك ربط Simkl بينما Trakt متصل. يرجى قطع اتصال Trakt أولاً.",
|
||||
"disclaimer": "Nuvio غير مرتبط بشركة Simkl."
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "إعدادات TMDb",
|
||||
"metadata_enrichment": "إثراء البيانات التعريفية",
|
||||
|
|
@ -609,6 +630,9 @@
|
|||
"spanish": "الإسبانية",
|
||||
"french": "الفرنسية",
|
||||
"italian": "الإيطالية",
|
||||
"croatian": "الكرواتية",
|
||||
"chinese": "الصينية (المبسطة)",
|
||||
"hindi": "الهندية",
|
||||
"account": "الحساب",
|
||||
"content_discovery": "المحتوى والاكتشاف",
|
||||
"appearance": "المظهر",
|
||||
|
|
@ -675,6 +699,9 @@
|
|||
"mdblist": "MDBList",
|
||||
"mdblist_connected": "متصل",
|
||||
"mdblist_desc": "تفعيل لإضافة التقييمات والمراجعات",
|
||||
"simkl": "Simkl",
|
||||
"simkl_connected": "متصل",
|
||||
"simkl_desc": "تتبع ما تشاهده",
|
||||
"tmdb": "TMDB",
|
||||
"tmdb_desc": "مزود البيانات التعريفية والشعارات",
|
||||
"openrouter": "OpenRouter API",
|
||||
|
|
@ -795,6 +822,7 @@
|
|||
"special_mentions": "ذكر خاص",
|
||||
"tab_contributors": "المساهمون",
|
||||
"tab_special": "ذكر خاص",
|
||||
"tab_donors": "المانحون",
|
||||
"manager_role": "مدير المجتمع",
|
||||
"manager_desc": "يدير مجتمعات Discord و Reddit الخاصة بـ Nuvio",
|
||||
"sponsor_role": "راعي السيرفر",
|
||||
|
|
@ -808,6 +836,11 @@
|
|||
"gratitude_desc": "كل سطر برمجي، بلاغ عن خطأ، واقتراح يساعد في جعل Nuvio أفضل للجميع",
|
||||
"special_thanks_title": "شكر خاص",
|
||||
"special_thanks_desc": "هؤلاء الأشخاص الرائعون يساعدون في الحفاظ على مجتمع Nuvio وتشغيل السيرفرات",
|
||||
"donors_desc": "شكراً لإيمانك بما نقوم ببناؤه. دعمك يحافظ على Nuvio مجاناً وفي تحسن مستمر.",
|
||||
"latest_donations": "الأحدث",
|
||||
"leaderboard": "الترتيب",
|
||||
"loading_donors": "جاري تحميل المانحين…",
|
||||
"no_donors": "لا يوجد مانحون حتى الآن",
|
||||
"error_rate_limit": "تم تجاوز حد معدل GitHub API. يرجى المحاولة لاحقاً أو التمرير للتحديث.",
|
||||
"error_failed": "فشل تحميل المساهمين. يرجى التحقق من اتصالك بالإنترنت.",
|
||||
"retry": "حاول مرة أخرى",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,27 @@
|
|||
"sync_success_msg": "Wiedergabefortschritt erfolgreich mit Trakt synchronisiert.",
|
||||
"sync_error_msg": "Synchronisierung fehlgeschlagen. Bitte versuchen Sie es erneut."
|
||||
},
|
||||
"simkl": {
|
||||
"title": "Simkl Einstellungen",
|
||||
"settings_title": "Simkl Einstellungen",
|
||||
"connect_title": "Mit Simkl verbinden",
|
||||
"connect_desc": "Synchronisieren Sie Ihren Verlauf und verfolgen Sie, was Sie sehen",
|
||||
"sign_in": "Mit Simkl anmelden",
|
||||
"sign_out": "Trennen",
|
||||
"sign_out_confirm": "Sind Sie sicher, dass Sie die Verbindung zu Simkl trennen möchten?",
|
||||
"syncing_desc": "Ihre gesehenen Elemente werden mit Simkl synchronisiert.",
|
||||
"auth_success_title": "Erfolgreich verbunden",
|
||||
"auth_success_msg": "Ihr Simkl-Konto wurde erfolgreich verbunden.",
|
||||
"auth_error_title": "Authentifizierungsfehler",
|
||||
"auth_error_msg": "Authentifizierung mit Simkl fehlgeschlagen.",
|
||||
"auth_error_generic": "Bei der Authentifizierung ist ein Fehler aufgetreten.",
|
||||
"sign_out_error": "Verbindung zu Simkl konnte nicht getrennt werden.",
|
||||
"config_error_title": "Konfigurationsfehler",
|
||||
"config_error_msg": "Simkl Client ID fehlt in den Umgebungsvariablen.",
|
||||
"conflict_title": "Konflikt",
|
||||
"conflict_msg": "Sie können Simkl nicht verbinden, während Trakt verbunden ist. Bitte trennen Sie zuerst Trakt.",
|
||||
"disclaimer": "Nuvio ist nicht mit Simkl verbunden."
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "TMDb Einstellungen",
|
||||
"metadata_enrichment": "Metadaten-Anreicherung",
|
||||
|
|
@ -609,6 +630,9 @@
|
|||
"spanish": "Spanisch",
|
||||
"french": "Französisch",
|
||||
"italian": "Italienisch",
|
||||
"croatian": "Kroatisch",
|
||||
"chinese": "Chinesisch (Vereinfacht)",
|
||||
"hindi": "Hindi",
|
||||
"account": "Konto",
|
||||
"content_discovery": "Inhalt & Entdeckung",
|
||||
"appearance": "Aussehen",
|
||||
|
|
@ -675,6 +699,9 @@
|
|||
"mdblist": "MDBList",
|
||||
"mdblist_connected": "Verbunden",
|
||||
"mdblist_desc": "Aktivieren, um Bewertungen & Rezensionen hinzuzufügen",
|
||||
"simkl": "Simkl",
|
||||
"simkl_connected": "Verbunden",
|
||||
"simkl_desc": "Verfolge, was du schaust",
|
||||
"tmdb": "TMDB",
|
||||
"tmdb_desc": "Metadaten- & Logo-Quellanbieter",
|
||||
"openrouter": "OpenRouter API",
|
||||
|
|
@ -690,7 +717,7 @@
|
|||
"auto_select_subs_desc": "Wählt automatisch Untertitel nach Ihren Präferenzen",
|
||||
"show_trailers": "Trailer anzeigen",
|
||||
"show_trailers_desc": "Trailer im Hero-Bereich anzeigen",
|
||||
"enable_downloads": "Downloads aktivieren (Beta)",
|
||||
"enable_downloads": "Downloads aktivieren",
|
||||
"enable_downloads_desc": "Downloads-Tab anzeigen und Speichern von Streams aktivieren",
|
||||
"notifications": "Benachrichtigungen",
|
||||
"notifications_desc": "Episodenerinnerungen",
|
||||
|
|
@ -808,6 +835,12 @@
|
|||
"gratitude_desc": "Jede Zeile Code hilft",
|
||||
"special_thanks_title": "Besonderer Dank",
|
||||
"special_thanks_desc": "Diese erstaunlichen Menschen helfen",
|
||||
"donors_desc": "Danke, dass Sie an das glauben, was wir aufbauen. Ihre Unterstützung hält Nuvio kostenlos und ständig verbessert.",
|
||||
"tab_donors": "Spender",
|
||||
"latest_donations": "Aktuell",
|
||||
"leaderboard": "Bestenliste",
|
||||
"loading_donors": "Spender werden geladen…",
|
||||
"no_donors": "Noch keine Spender",
|
||||
"error_rate_limit": "GitHub API Rate Limit überschritten.",
|
||||
"error_failed": "Fehler beim Laden der Mitwirkenden.",
|
||||
"retry": "Erneut versuchen",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,27 @@
|
|||
"sync_success_msg": "Successfully synced your watch progress with Trakt.",
|
||||
"sync_error_msg": "Sync failed. Please try again."
|
||||
},
|
||||
"simkl": {
|
||||
"title": "Simkl Settings",
|
||||
"settings_title": "Simkl Settings",
|
||||
"connect_title": "Connect with Simkl",
|
||||
"connect_desc": "Sync your watch history and track what you're watching",
|
||||
"sign_in": "Sign In with Simkl",
|
||||
"sign_out": "Disconnect",
|
||||
"sign_out_confirm": "Are you sure you want to disconnect from Simkl?",
|
||||
"syncing_desc": "Your watched items are syncing with Simkl.",
|
||||
"auth_success_title": "Successfully Connected",
|
||||
"auth_success_msg": "Your Simkl account has been connected successfully.",
|
||||
"auth_error_title": "Authentication Error",
|
||||
"auth_error_msg": "Failed to complete authentication with Simkl.",
|
||||
"auth_error_generic": "An error occurred during authentication.",
|
||||
"sign_out_error": "Failed to disconnect from Simkl.",
|
||||
"config_error_title": "Configuration Error",
|
||||
"config_error_msg": "Simkl Client ID is missing in environment variables.",
|
||||
"conflict_title": "Conflict",
|
||||
"conflict_msg": "You cannot connect to Simkl while Trakt is connected. Please disconnect Trakt first.",
|
||||
"disclaimer": "Nuvio is not affiliated with Simkl."
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "TMDb Settings",
|
||||
"metadata_enrichment": "Metadata Enrichment",
|
||||
|
|
@ -609,6 +630,9 @@
|
|||
"spanish": "Spanish",
|
||||
"french": "French",
|
||||
"italian": "Italian",
|
||||
"croatian": "Croatian",
|
||||
"chinese": "Chinese (Simplified)",
|
||||
"hindi": "Hindi",
|
||||
"account": "Account",
|
||||
"content_discovery": "Content & Discovery",
|
||||
"appearance": "Appearance",
|
||||
|
|
@ -675,6 +699,9 @@
|
|||
"mdblist": "MDBList",
|
||||
"mdblist_connected": "Connected",
|
||||
"mdblist_desc": "Enable to add ratings & reviews",
|
||||
"simkl": "Simkl",
|
||||
"simkl_connected": "Connected",
|
||||
"simkl_desc": "Track what you watch",
|
||||
"tmdb": "TMDB",
|
||||
"tmdb_desc": "Metadata & logo source provider",
|
||||
"openrouter": "OpenRouter API",
|
||||
|
|
@ -690,7 +717,7 @@
|
|||
"auto_select_subs_desc": "Automatically select subtitles matching your preferences",
|
||||
"show_trailers": "Show Trailers",
|
||||
"show_trailers_desc": "Display trailers in hero section",
|
||||
"enable_downloads": "Enable Downloads (Beta)",
|
||||
"enable_downloads": "Enable Downloads",
|
||||
"enable_downloads_desc": "Show Downloads tab and enable saving streams",
|
||||
"notifications": "Notifications",
|
||||
"notifications_desc": "Episode reminders",
|
||||
|
|
@ -795,6 +822,7 @@
|
|||
"special_mentions": "Special Mentions",
|
||||
"tab_contributors": "Contributors",
|
||||
"tab_special": "Special Mentions",
|
||||
"tab_donors": "Donors",
|
||||
"manager_role": "Community Manager",
|
||||
"manager_desc": "Manages the Discord & Reddit communities for Nuvio",
|
||||
"sponsor_role": "Server Sponsor",
|
||||
|
|
@ -808,6 +836,11 @@
|
|||
"gratitude_desc": "Each line of code, bug report, and suggestion helps make Nuvio better for everyone",
|
||||
"special_thanks_title": "Special Thanks",
|
||||
"special_thanks_desc": "These amazing people help keep the Nuvio community running and the servers online",
|
||||
"donors_desc": "Thank you for believing in what we're building. Your support keeps Nuvio free and constantly improving.",
|
||||
"latest_donations": "Latest",
|
||||
"leaderboard": "Leaderboard",
|
||||
"loading_donors": "Loading donors…",
|
||||
"no_donors": "No donors yet",
|
||||
"error_rate_limit": "GitHub API rate limit exceeded. Please try again later or pull to refresh.",
|
||||
"error_failed": "Failed to load contributors. Please check your internet connection.",
|
||||
"retry": "Try Again",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,27 @@
|
|||
"sync_success_msg": "Sincronización del progreso con Trakt completada con éxito.",
|
||||
"sync_error_msg": "La sincronización falló. Por favor, inténtalo de nuevo."
|
||||
},
|
||||
"simkl": {
|
||||
"title": "Configuración de Simkl",
|
||||
"settings_title": "Configuración de Simkl",
|
||||
"connect_title": "Conectar con Simkl",
|
||||
"connect_desc": "Sincroniza tu historial de visualización y rastrea lo que ves",
|
||||
"sign_in": "Iniciar sesión con Simkl",
|
||||
"sign_out": "Desconectar",
|
||||
"sign_out_confirm": "¿Estás seguro de que quieres desconectar de Simkl?",
|
||||
"syncing_desc": "Tus elementos vistos se están sincronizando con Simkl.",
|
||||
"auth_success_title": "Conectado exitosamente",
|
||||
"auth_success_msg": "Tu cuenta de Simkl se ha conectado exitosamente.",
|
||||
"auth_error_title": "Error de autenticación",
|
||||
"auth_error_msg": "Error al completar la autenticación con Simkl.",
|
||||
"auth_error_generic": "Ocurrió un error durante la autenticación.",
|
||||
"sign_out_error": "Error al desconectar de Simkl.",
|
||||
"config_error_title": "Error de configuración",
|
||||
"config_error_msg": "El ID de cliente de Simkl falta en las variables de entorno.",
|
||||
"conflict_title": "Conflicto",
|
||||
"conflict_msg": "No puedes conectar Simkl mientras Trakt está conectado. Por favor, desconecta Trakt primero.",
|
||||
"disclaimer": "Nuvio no está afiliado con Simkl."
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "Ajustes de TMDb",
|
||||
"metadata_enrichment": "Enriquecimiento de metadatos",
|
||||
|
|
@ -609,6 +630,9 @@
|
|||
"spanish": "Español",
|
||||
"french": "Francés",
|
||||
"italian": "Italiano",
|
||||
"croatian": "Croata",
|
||||
"chinese": "Chino (Simplificado)",
|
||||
"hindi": "Hindi",
|
||||
"account": "Cuenta",
|
||||
"content_discovery": "Contenido y descubrimiento",
|
||||
"appearance": "Apariencia",
|
||||
|
|
@ -675,6 +699,9 @@
|
|||
"mdblist": "MDBList",
|
||||
"mdblist_connected": "Conectado",
|
||||
"mdblist_desc": "Activar para añadir valoraciones y reseñas",
|
||||
"simkl": "Simkl",
|
||||
"simkl_connected": "Conectado",
|
||||
"simkl_desc": "Rastrea lo que ves",
|
||||
"tmdb": "TMDB",
|
||||
"tmdb_desc": "Proveedor de metadatos y logos",
|
||||
"openrouter": "API de OpenRouter",
|
||||
|
|
@ -690,7 +717,7 @@
|
|||
"auto_select_subs_desc": "Selecciona automáticamente los subtítulos que coincidan con tus preferencias",
|
||||
"show_trailers": "Mostrar tráileres",
|
||||
"show_trailers_desc": "Mostrar tráileres en la sección destacada",
|
||||
"enable_downloads": "Activar descargas (Beta)",
|
||||
"enable_downloads": "Activar descargas",
|
||||
"enable_downloads_desc": "Mostrar pestaña de descargas y permitir guardar fuentes",
|
||||
"notifications": "Notificaciones",
|
||||
"notifications_desc": "Recordatorios de episodios",
|
||||
|
|
@ -795,6 +822,7 @@
|
|||
"special_mentions": "Menciones especiales",
|
||||
"tab_contributors": "Colaboradores",
|
||||
"tab_special": "Menciones especiales",
|
||||
"tab_donors": "Donantes",
|
||||
"manager_role": "Community Manager",
|
||||
"manager_desc": "Gestiona las comunidades de Discord y Reddit para Nuvio",
|
||||
"sponsor_role": "Patrocinador del servidor",
|
||||
|
|
@ -808,6 +836,11 @@
|
|||
"gratitude_desc": "Cada línea de código, informe de fallo y sugerencia ayuda a mejorar Nuvio para todos",
|
||||
"special_thanks_title": "Agradecimientos especiales",
|
||||
"special_thanks_desc": "Estas personas increíbles ayudan a mantener la comunidad de Nuvio en marcha y los servidores online",
|
||||
"donors_desc": "Gracias por creer en lo que estamos construyendo. Tu apoyo mantiene Nuvio gratis y en constante mejora.",
|
||||
"latest_donations": "Recientes",
|
||||
"leaderboard": "Clasificación",
|
||||
"loading_donors": "Cargando donantes…",
|
||||
"no_donors": "Sin donantes aún",
|
||||
"error_rate_limit": "Se superó el límite de la API de GitHub. Inténtalo de nuevo más tarde o desliza para actualizar.",
|
||||
"error_failed": "Error al cargar los colaboradores. Comprueba tu conexión a internet.",
|
||||
"retry": "Reintentar",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,27 @@
|
|||
"sync_success_msg": "Votre progression a été synchronisée avec succès avec Trakt.",
|
||||
"sync_error_msg": "La synchronisation a échoué. Veuillez réessayer."
|
||||
},
|
||||
"simkl": {
|
||||
"title": "Paramètres Simkl",
|
||||
"settings_title": "Paramètres Simkl",
|
||||
"connect_title": "Se connecter avec Simkl",
|
||||
"connect_desc": "Synchronisez votre historique de visionnage et suivez ce que vous regardez",
|
||||
"sign_in": "Se connecter avec Simkl",
|
||||
"sign_out": "Déconnecter",
|
||||
"sign_out_confirm": "Êtes-vous sûr de vouloir vous déconnecter de Simkl ?",
|
||||
"syncing_desc": "Vos éléments regardés sont synchronisés avec Simkl.",
|
||||
"auth_success_title": "Connecté avec succès",
|
||||
"auth_success_msg": "Votre compte Simkl a été connecté avec succès.",
|
||||
"auth_error_title": "Erreur d'authentification",
|
||||
"auth_error_msg": "Échec de l'authentification avec Simkl.",
|
||||
"auth_error_generic": "Une erreur s'est produite lors de l'authentification.",
|
||||
"sign_out_error": "Échec de la déconnexion de Simkl.",
|
||||
"config_error_title": "Erreur de configuration",
|
||||
"config_error_msg": "L'ID client Simkl est manquant dans les variables d'environnement.",
|
||||
"conflict_title": "Conflit",
|
||||
"conflict_msg": "Vous ne pouvez pas connecter Simkl tant que Trakt est connecté. Veuillez d'abord déconnecter Trakt.",
|
||||
"disclaimer": "Nuvio n'est pas affilié à Simkl."
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "Paramètres TMDb",
|
||||
"metadata_enrichment": "Enrichissement des métadonnées",
|
||||
|
|
@ -609,6 +630,9 @@
|
|||
"spanish": "Espagnol",
|
||||
"french": "Français",
|
||||
"italian": "Italien",
|
||||
"croatian": "Croate",
|
||||
"chinese": "Chinois (Simplifié)",
|
||||
"hindi": "Hindi",
|
||||
"account": "Compte",
|
||||
"content_discovery": "Contenu et découverte",
|
||||
"appearance": "Apparence",
|
||||
|
|
@ -675,6 +699,9 @@
|
|||
"mdblist": "MDBList",
|
||||
"mdblist_connected": "Connecté",
|
||||
"mdblist_desc": "Activer pour ajouter les notes et avis",
|
||||
"simkl": "Simkl",
|
||||
"simkl_connected": "Connecté",
|
||||
"simkl_desc": "Suivez ce que vous regardez",
|
||||
"tmdb": "TMDB",
|
||||
"tmdb_desc": "Fournisseur de métadonnées et de logos",
|
||||
"openrouter": "API OpenRouter",
|
||||
|
|
@ -795,6 +822,7 @@
|
|||
"special_mentions": "Mentions spéciales",
|
||||
"tab_contributors": "Contributeurs",
|
||||
"tab_special": "Mentions spéciales",
|
||||
"tab_donors": "Donateurs",
|
||||
"manager_role": "Responsable de communauté",
|
||||
"manager_desc": "Gère les communautés Discord et Reddit pour Nuvio",
|
||||
"sponsor_role": "Sponsor serveur",
|
||||
|
|
@ -808,6 +836,11 @@
|
|||
"gratitude_desc": "Chaque ligne de code, rapport de bug et suggestion aide à rendre Nuvio meilleur pour tous",
|
||||
"special_thanks_title": "Remerciements spéciaux",
|
||||
"special_thanks_desc": "Ces personnes formidables aident à faire fonctionner la communauté Nuvio et à maintenir les serveurs en ligne",
|
||||
"donors_desc": "Merci de croire en ce que nous construisons. Votre soutien garde Nuvio gratuit et en constant progrès.",
|
||||
"latest_donations": "Récents",
|
||||
"leaderboard": "Classement",
|
||||
"loading_donors": "Chargement des donateurs…",
|
||||
"no_donors": "Pas encore de donateurs",
|
||||
"error_rate_limit": "Limite de débit de l'API GitHub dépassée. Veuillez réessayer plus tard ou faire glisser pour actualiser.",
|
||||
"error_failed": "Échec du chargement des contributeurs. Veuillez vérifier votre connexion Internet.",
|
||||
"retry": "Réessayer",
|
||||
|
|
|
|||
1366
src/i18n/locales/hi.json
Normal file
1366
src/i18n/locales/hi.json
Normal file
File diff suppressed because it is too large
Load diff
1366
src/i18n/locales/hr.json
Normal file
1366
src/i18n/locales/hr.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -522,6 +522,27 @@
|
|||
"sync_success_msg": "Progressi di visione sincronizzati con successo con Trakt.",
|
||||
"sync_error_msg": "Sincronizzazione fallita. Riprova."
|
||||
},
|
||||
"simkl": {
|
||||
"title": "Impostazioni Simkl",
|
||||
"settings_title": "Impostazioni Simkl",
|
||||
"connect_title": "Connetti con Simkl",
|
||||
"connect_desc": "Sincronizza la tua cronologia di visione e traccia ciò che guardi",
|
||||
"sign_in": "Accedi con Simkl",
|
||||
"sign_out": "Disconnetti",
|
||||
"sign_out_confirm": "Sei sicuro di voler disconnettere da Simkl?",
|
||||
"syncing_desc": "I tuoi elementi guardati sono in sincronizzazione con Simkl.",
|
||||
"auth_success_title": "Connesso con successo",
|
||||
"auth_success_msg": "Il tuo account Simkl è stato connesso con successo.",
|
||||
"auth_error_title": "Errore di autenticazione",
|
||||
"auth_error_msg": "Impossibile completare l'autenticazione con Simkl.",
|
||||
"auth_error_generic": "Si è verificato un errore durante l'autenticazione.",
|
||||
"sign_out_error": "Impossibile disconnettere da Simkl.",
|
||||
"config_error_title": "Errore di configurazione",
|
||||
"config_error_msg": "L'ID client Simkl manca nelle variabili d'ambiente.",
|
||||
"conflict_title": "Conflitto",
|
||||
"conflict_msg": "Non puoi connettere Simkl mentre Trakt è connesso. Disconnetti prima Trakt.",
|
||||
"disclaimer": "Nuvio non è affiliato con Simkl."
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "Impostazioni TMDb",
|
||||
"metadata_enrichment": "Arricchimento metadati",
|
||||
|
|
@ -609,6 +630,9 @@
|
|||
"spanish": "Spagnolo",
|
||||
"french": "Francese",
|
||||
"italian": "Italiano",
|
||||
"croatian": "Croato",
|
||||
"chinese": "Cinese (Semplificato)",
|
||||
"hindi": "Hindi",
|
||||
"account": "Account",
|
||||
"content_discovery": "Contenuti e Scoperta",
|
||||
"appearance": "Aspetto",
|
||||
|
|
@ -675,6 +699,9 @@
|
|||
"mdblist": "MDBList",
|
||||
"mdblist_connected": "Connesso",
|
||||
"mdblist_desc": "Abilita per aggiungere voti e recensioni",
|
||||
"simkl": "Simkl",
|
||||
"simkl_connected": "Connesso",
|
||||
"simkl_desc": "Traccia ciò che guardi",
|
||||
"tmdb": "TMDB",
|
||||
"tmdb_desc": "Sorgente metadati e loghi",
|
||||
"openrouter": "API OpenRouter",
|
||||
|
|
@ -690,7 +717,7 @@
|
|||
"auto_select_subs_desc": "Seleziona automaticamente i sottotitoli in base alle tue preferenze",
|
||||
"show_trailers": "Mostra Trailer",
|
||||
"show_trailers_desc": "Visualizza i trailer nella sezione principale",
|
||||
"enable_downloads": "Abilita Download (Beta)",
|
||||
"enable_downloads": "Abilita Download",
|
||||
"enable_downloads_desc": "Mostra la scheda Download e abilita il salvataggio degli streaming",
|
||||
"notifications": "Notifiche",
|
||||
"notifications_desc": "Promemoria episodi",
|
||||
|
|
@ -795,6 +822,7 @@
|
|||
"special_mentions": "Menzioni speciali",
|
||||
"tab_contributors": "Collaboratori",
|
||||
"tab_special": "Menzioni speciali",
|
||||
"tab_donors": "Donatori",
|
||||
"manager_role": "Community Manager",
|
||||
"manager_desc": "Gestisce le community Discord e Reddit per Nuvio",
|
||||
"sponsor_role": "Sponsor del Server",
|
||||
|
|
@ -808,6 +836,11 @@
|
|||
"gratitude_desc": "Ogni riga di codice, segnalazione di bug e suggerimento aiuta a rendere Nuvio migliore per tutti",
|
||||
"special_thanks_title": "Ringraziamenti speciali",
|
||||
"special_thanks_desc": "Queste persone fantastiche aiutano a mantenere attiva la community di Nuvio e i server online",
|
||||
"donors_desc": "Grazie per credere in quello che stiamo costruendo. Il vostro supporto mantiene Nuvio gratuito e in continuo miglioramento.",
|
||||
"latest_donations": "Recenti",
|
||||
"leaderboard": "Classifica",
|
||||
"loading_donors": "Caricamento donatori…",
|
||||
"no_donors": "Nessun donatore ancora",
|
||||
"error_rate_limit": "Limite di frequenza API GitHub superato. Riprova più tardi o trascina per aggiornare.",
|
||||
"error_failed": "Impossibile caricare i collaboratori. Controlla la tua connessione internet.",
|
||||
"retry": "Riprova",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,27 @@
|
|||
"sync_success_msg": "Progresso sincronizado com sucesso com o Trakt.",
|
||||
"sync_error_msg": "Falha na sincronização. Tente novamente."
|
||||
},
|
||||
"simkl": {
|
||||
"title": "Configurações do Simkl",
|
||||
"settings_title": "Configurações do Simkl",
|
||||
"connect_title": "Conectar com Simkl",
|
||||
"connect_desc": "Sincronize seu histórico de visualização e rastreie o que você assiste",
|
||||
"sign_in": "Entrar com Simkl",
|
||||
"sign_out": "Desconectar",
|
||||
"sign_out_confirm": "Tem certeza de que deseja desconectar do Simkl?",
|
||||
"syncing_desc": "Seus itens assistidos estão sendo sincronizados com o Simkl.",
|
||||
"auth_success_title": "Conectado com sucesso",
|
||||
"auth_success_msg": "Sua conta Simkl foi conectada com sucesso.",
|
||||
"auth_error_title": "Erro de autenticação",
|
||||
"auth_error_msg": "Falha ao completar a autenticação com o Simkl.",
|
||||
"auth_error_generic": "Ocorreu um erro durante a autenticação.",
|
||||
"sign_out_error": "Falha ao desconectar do Simkl.",
|
||||
"config_error_title": "Erro de configuração",
|
||||
"config_error_msg": "O ID do cliente Simkl está faltando nas variáveis de ambiente.",
|
||||
"conflict_title": "Conflito",
|
||||
"conflict_msg": "Você não pode conectar o Simkl enquanto o Trakt está conectado. Desconecte o Trakt primeiro.",
|
||||
"disclaimer": "Nuvio não é afiliado ao Simkl."
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "Configurações do TMDb",
|
||||
"metadata_enrichment": "Enriquecimento de Metadados",
|
||||
|
|
@ -623,6 +644,9 @@
|
|||
"spanish": "Espanhol",
|
||||
"french": "Francês",
|
||||
"italian": "Italiano",
|
||||
"croatian": "Croata",
|
||||
"chinese": "Chinês (Simplificado)",
|
||||
"hindi": "Hindi",
|
||||
"account": "Conta",
|
||||
"content_discovery": "Conteúdo e Descoberta",
|
||||
"appearance": "Aparência",
|
||||
|
|
@ -689,6 +713,9 @@
|
|||
"mdblist": "MDBList",
|
||||
"mdblist_connected": "Conectado",
|
||||
"mdblist_desc": "Habilitar para adicionar avaliações e resenhas",
|
||||
"simkl": "Simkl",
|
||||
"simkl_connected": "Conectado",
|
||||
"simkl_desc": "Acompanhe o que você assiste",
|
||||
"tmdb": "TMDB",
|
||||
"tmdb_desc": "Provedor de metadados e logos",
|
||||
"openrouter": "OpenRouter API",
|
||||
|
|
@ -704,7 +731,7 @@
|
|||
"auto_select_subs_desc": "Selecionar legendas automaticamente",
|
||||
"show_trailers": "Mostrar Trailers",
|
||||
"show_trailers_desc": "Exibir trailers na seção hero",
|
||||
"enable_downloads": "Habilitar Downloads (Beta)",
|
||||
"enable_downloads": "Habilitar Downloads",
|
||||
"enable_downloads_desc": "Mostrar aba Downloads e permitir salvar streams",
|
||||
"notifications": "Notificações",
|
||||
"notifications_desc": "Lembretes de episódios",
|
||||
|
|
@ -809,6 +836,7 @@
|
|||
"special_mentions": "Menções Especiais",
|
||||
"tab_contributors": "Contribuidores",
|
||||
"tab_special": "Menções Especiais",
|
||||
"tab_donors": "Doadores",
|
||||
"manager_role": "Gerente da Comunidade",
|
||||
"manager_desc": "Gerencia as comunidades do Discord e Reddit",
|
||||
"sponsor_role": "Patrocinador do Servidor",
|
||||
|
|
@ -822,6 +850,11 @@
|
|||
"gratitude_desc": "Cada linha de código, relatório de bug e sugestão ajuda a tornar o Nuvio melhor para todos",
|
||||
"special_thanks_title": "Agradecimentos Especiais",
|
||||
"special_thanks_desc": "Essas pessoas incríveis ajudam a manter a comunidade Nuvio funcionando e os servidores online",
|
||||
"donors_desc": "Obrigado por acreditar no que estamos construindo. Seu apoio mantém o Nuvio gratuito e continuamente melhorando.",
|
||||
"latest_donations": "Recentes",
|
||||
"leaderboard": "Placar",
|
||||
"loading_donors": "Carregando doadores…",
|
||||
"no_donors": "Sem doadores ainda",
|
||||
"error_rate_limit": "Limite de taxa da API do GitHub excedido. Tente novamente mais tarde.",
|
||||
"error_failed": "Falha ao carregar colaboradores. Verifique sua conexão com a internet.",
|
||||
"retry": "Tentar Novamente",
|
||||
|
|
|
|||
|
|
@ -522,6 +522,27 @@
|
|||
"sync_success_msg": "Progresso sincronizado com sucesso com o Trakt.",
|
||||
"sync_error_msg": "Falha na sincronização. Tenta novamente."
|
||||
},
|
||||
"simkl": {
|
||||
"title": "Configurações do Simkl",
|
||||
"settings_title": "Configurações do Simkl",
|
||||
"connect_title": "Ligar ao Simkl",
|
||||
"connect_desc": "Sincroniza o teu histórico de visualização e rastreia o que vês",
|
||||
"sign_in": "Entrar com Simkl",
|
||||
"sign_out": "Desligar",
|
||||
"sign_out_confirm": "Tens a certeza de que queres desligar do Simkl?",
|
||||
"syncing_desc": "Os teus itens vistos estão a ser sincronizados com o Simkl.",
|
||||
"auth_success_title": "Ligado com sucesso",
|
||||
"auth_success_msg": "A tua conta Simkl foi ligada com sucesso.",
|
||||
"auth_error_title": "Erro de autenticação",
|
||||
"auth_error_msg": "Falha ao completar a autenticação com o Simkl.",
|
||||
"auth_error_generic": "Ocorreu um erro durante a autenticação.",
|
||||
"sign_out_error": "Falha ao desligar do Simkl.",
|
||||
"config_error_title": "Erro de configuração",
|
||||
"config_error_msg": "O ID do cliente Simkl está em falta nas variáveis de ambiente.",
|
||||
"conflict_title": "Conflito",
|
||||
"conflict_msg": "Não podes ligar o Simkl enquanto o Trakt está ligado. Desliga primeiro o Trakt.",
|
||||
"disclaimer": "Nuvio não é afiliado ao Simkl."
|
||||
},
|
||||
"tmdb_settings": {
|
||||
"title": "Configurações do TMDb",
|
||||
"metadata_enrichment": "Enriquecimento de Metadados",
|
||||
|
|
@ -623,6 +644,9 @@
|
|||
"spanish": "Espanhol",
|
||||
"french": "Francês",
|
||||
"italian": "Italiano",
|
||||
"croatian": "Croata",
|
||||
"chinese": "Chinês (Simplificado)",
|
||||
"hindi": "Hindi",
|
||||
"account": "Conta",
|
||||
"content_discovery": "Conteúdo e Descoberta",
|
||||
"appearance": "Aparência",
|
||||
|
|
@ -689,6 +713,9 @@
|
|||
"mdblist": "MDBList",
|
||||
"mdblist_connected": "Conectado",
|
||||
"mdblist_desc": "Ativar para adicionar avaliações e críticas",
|
||||
"simkl": "Simkl",
|
||||
"simkl_connected": "Conectado",
|
||||
"simkl_desc": "Acompanhe o que vê",
|
||||
"tmdb": "TMDB",
|
||||
"tmdb_desc": "Provedor de metadados e logos",
|
||||
"openrouter": "OpenRouter API",
|
||||
|
|
@ -704,7 +731,7 @@
|
|||
"auto_select_subs_desc": "Selecionar legendas automaticamente",
|
||||
"show_trailers": "Mostrar Trailers",
|
||||
"show_trailers_desc": "Exibir trailers na secção hero",
|
||||
"enable_downloads": "Habilitar Downloads (Beta)",
|
||||
"enable_downloads": "Habilitar Downloads",
|
||||
"enable_downloads_desc": "Mostrar aba Downloads e permitir guardar streams",
|
||||
"notifications": "Notificações",
|
||||
"notifications_desc": "Lembretes de episódios",
|
||||
|
|
@ -809,6 +836,7 @@
|
|||
"special_mentions": "Menções Especiais",
|
||||
"tab_contributors": "Contribuidores",
|
||||
"tab_special": "Menções Especiais",
|
||||
"tab_donors": "Doadores",
|
||||
"manager_role": "Gestor da Comunidade",
|
||||
"manager_desc": "Gere as comunidades do Discord e Reddit",
|
||||
"sponsor_role": "Patrocinador do Servidor",
|
||||
|
|
@ -822,6 +850,11 @@
|
|||
"gratitude_desc": "Cada linha de código, relatório de bug e sugestão ajuda a tornar o Nuvio melhor para todos",
|
||||
"special_thanks_title": "Agradecimentos Especiais",
|
||||
"special_thanks_desc": "Essas pessoas incríveis ajudam a manter a comunidade Nuvio a funcionar e os servidores online",
|
||||
"donors_desc": "Obrigado por acreditar no que estamos a construir. O seu apoio mantém o Nuvio gratuito e continuamente a melhorar.",
|
||||
"latest_donations": "Recentes",
|
||||
"leaderboard": "Placar",
|
||||
"loading_donors": "A carregar doadores…",
|
||||
"no_donors": "Sem doadores ainda",
|
||||
"error_rate_limit": "Limite de taxa da API do GitHub excedido. Tenta novamente mais tarde.",
|
||||
"error_failed": "Falha ao carregar colaboradores. Verifica a tua conexão com a internet.",
|
||||
"retry": "Tentar Novamente",
|
||||
|
|
|
|||
1366
src/i18n/locales/zh-CN.json
Normal file
1366
src/i18n/locales/zh-CN.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,10 @@ import fr from './locales/fr.json';
|
|||
import it from './locales/it.json';
|
||||
import de from './locales/de.json';
|
||||
|
||||
import hr from './locales/hr.json';
|
||||
import hi from './locales/hi.json';
|
||||
import zhCN from './locales/zh-CN.json';
|
||||
|
||||
export const resources = {
|
||||
en: { translation: en },
|
||||
'pt-BR': { translation: ptBR },
|
||||
|
|
@ -16,4 +20,7 @@ export const resources = {
|
|||
fr: { translation: fr },
|
||||
it: { translation: it },
|
||||
de: { translation: de },
|
||||
hr: { translation: hr },
|
||||
'zh-CN': { translation: zhCN },
|
||||
hi: { translation: hi },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import HeroCatalogsScreen from '../screens/HeroCatalogsScreen';
|
|||
import TraktSettingsScreen from '../screens/TraktSettingsScreen';
|
||||
import MalSettingsScreen from '../screens/MalSettingsScreen';
|
||||
import MalLibraryScreen from '../screens/MalLibraryScreen';
|
||||
import SimklSettingsScreen from '../screens/SimklSettingsScreen';
|
||||
import PlayerSettingsScreen from '../screens/PlayerSettingsScreen';
|
||||
import ThemeScreen from '../screens/ThemeScreen';
|
||||
import OnboardingScreen from '../screens/OnboardingScreen';
|
||||
|
|
@ -74,7 +75,7 @@ import BackdropGalleryScreen from '../screens/BackdropGalleryScreen';
|
|||
import BackupScreen from '../screens/BackupScreen';
|
||||
import ContinueWatchingSettingsScreen from '../screens/ContinueWatchingSettingsScreen';
|
||||
import ContributorsScreen from '../screens/ContributorsScreen';
|
||||
import DebridIntegrationScreen from '../screens/DebridIntegrationScreen';
|
||||
|
||||
import {
|
||||
ContentDiscoverySettingsScreen,
|
||||
AppearanceSettingsScreen,
|
||||
|
|
@ -191,6 +192,7 @@ export type RootStackParamList = {
|
|||
TraktSettings: undefined;
|
||||
MalSettings: undefined;
|
||||
MalLibrary: undefined;
|
||||
SimklSettings: undefined;
|
||||
PlayerSettings: undefined;
|
||||
ThemeSettings: undefined;
|
||||
ScraperSettings: undefined;
|
||||
|
|
@ -218,7 +220,7 @@ export type RootStackParamList = {
|
|||
};
|
||||
ContinueWatchingSettings: undefined;
|
||||
Contributors: undefined;
|
||||
DebridIntegration: undefined;
|
||||
|
||||
// New organized settings screens
|
||||
ContentDiscoverySettings: undefined;
|
||||
AppearanceSettings: undefined;
|
||||
|
|
@ -765,7 +767,7 @@ const MainTabs = () => {
|
|||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: Platform.OS === 'android' ? 70 + insets.bottom : 85 + insets.bottom,
|
||||
height: Platform.OS === 'android' ? 70 : 85 + insets.bottom,
|
||||
backgroundColor: 'transparent',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
|
|
@ -815,7 +817,7 @@ const MainTabs = () => {
|
|||
<View
|
||||
style={{
|
||||
height: '100%',
|
||||
paddingBottom: Platform.OS === 'android' ? 15 + insets.bottom : 20 + insets.bottom,
|
||||
paddingBottom: Platform.OS === 'android' ? 15 : 20 + insets.bottom,
|
||||
paddingTop: Platform.OS === 'android' ? 8 : 12,
|
||||
backgroundColor: 'transparent',
|
||||
}}
|
||||
|
|
@ -1236,7 +1238,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<>
|
||||
<StatusBar
|
||||
translucent
|
||||
backgroundColor="transparent"
|
||||
|
|
@ -1247,6 +1249,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
flex: 1,
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
...(Platform.OS === 'android' && {
|
||||
paddingBottom: insets.bottom, // Respect safe area bottom for Android nav bar
|
||||
// Prevent white flashes on Android
|
||||
opacity: 1,
|
||||
})
|
||||
|
|
@ -1601,6 +1604,21 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="SimklSettings"
|
||||
component={SimklSettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'default' : 'fade',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 200,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="PlayerSettings"
|
||||
component={PlayerSettingsScreen}
|
||||
|
|
@ -1748,21 +1766,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="DebridIntegration"
|
||||
component={DebridIntegrationScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'default' : 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Stack.Screen
|
||||
name="ContentDiscoverySettings"
|
||||
component={ContentDiscoverySettingsScreen}
|
||||
|
|
@ -1871,7 +1875,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
</Stack.Navigator>
|
||||
</View>
|
||||
</PaperProvider>
|
||||
</SafeAreaProvider>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue