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:
paregi12 2026-01-27 13:24:53 +05:30
commit 540f364c82
134 changed files with 15178 additions and 4740 deletions

10
.gitignore vendored
View file

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

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

@ -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 • Crossplatform • 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 crossplatform 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
[![Download APK](https://img.shields.io/badge/Download-APK-green?style=for-the-badge)](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"> [![Join TestFlight](https://img.shields.io/badge/Join-TestFlight-blue?style=for-the-badge)](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"> [![Add to AltStore](https://img.shields.io/badge/Add%20to-AltStore-blue?style=for-the-badge)](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"> [![Add to SideStore](https://img.shields.io/badge/Add%20to-SideStore-green?style=for-the-badge)](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 opensource 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:
* **KoFi** `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 builtin content or host media content. Content access is only available through userinstalled 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

View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

0
assets/trakt-favicon.png Normal file
View file

View file

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

View file

@ -0,0 +1,11 @@
{
"colors": [
{
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View file

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

View file

@ -0,0 +1,6 @@
{
"info": {
"author": "xcode",
"version": 1
}
}

View file

@ -0,0 +1,11 @@
{
"colors": [
{
"idiom": "universal"
}
],
"info": {
"author": "xcode",
"version": 1
}
}

View 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
)
}
}
}

View file

@ -0,0 +1,7 @@
import SwiftUI
extension Date {
static func toTimerInterval(miliseconds: Double) -> ClosedRange<Self> {
now ... max(now, Date(timeIntervalSince1970: miliseconds / 1000))
}
}

View 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)
}
}

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

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

View 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

View 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)
}
}

View file

@ -0,0 +1,9 @@
import SwiftUI
import WidgetKit
@main
struct LiveActivityWidgetBundle: WidgetBundle {
var body: some Widget {
LiveActivityWidget()
}
}

View 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
}
}
}

View 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))
}
}
}
}

View 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)
}
}

View file

@ -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 */;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

@ -0,0 +1,379 @@
![expo-live-activity by Software Mansion](https://github.com/user-attachments/assets/9f9be263-84ee-4034-a3ca-39c72c189544)
> [!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
[![swm](https://logo.swmansion.com/logo?color=white&variant=desktop&width=150&tag=typegpu-github '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 -->

View file

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

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

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

View file

@ -207,7 +207,7 @@ const CatalogSection = ({ catalog }: CatalogSectionProps) => {
horizontal
showsHorizontalScrollIndicator={false}
scrollEventThrottle={16}
decelerationRate="fast"
decelerationRate="normal"
scrollEnabled={true}
nestedScrollEnabled={true}
contentContainerStyle={StyleSheet.flatten([

View file

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

View file

@ -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> = {};

View 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 */}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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={[

View file

@ -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(() => {

View file

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

View file

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

View file

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

View 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;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
};
}

View file

@ -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(() => {

View file

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

View file

@ -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": "حاول مرة أخرى",

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

1366
src/i18n/locales/hr.json Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

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