diff --git a/.gitignore b/.gitignore index efd2b6c5..6486d03e 100644 --- a/.gitignore +++ b/.gitignore @@ -105,4 +105,5 @@ LibTorrent/ iTorrent/ simkl-docss downloader.md -server \ No newline at end of file +server +Deliverables 2 \ No newline at end of file diff --git a/App.tsx b/App.tsx index 5bd5ff19..7ce219f0 100644 --- a/App.tsx +++ b/App.tsx @@ -48,6 +48,7 @@ import { ToastProvider } from './src/contexts/ToastContext'; import { mmkvStorage } from './src/services/mmkvStorage'; import { CampaignManager } from './src/components/promotions/CampaignManager'; import { isErrorReportingEnabledSync } from './src/services/telemetryService'; +import { supabaseSyncService } from './src/services/supabaseSyncService'; // Initialize Sentry with privacy-first defaults // Settings are loaded from telemetryService and can be controlled by user @@ -180,6 +181,15 @@ const ThemedApp = () => { const onboardingCompleted = await mmkvStorage.getItem('hasCompletedOnboarding'); setHasCompletedOnboarding(onboardingCompleted === 'true'); + // Initialize Supabase auth/session and start background sync. + // This is intentionally non-blocking for app startup UX. + supabaseSyncService + .initialize() + .then(() => supabaseSyncService.startupSync()) + .catch((error) => { + console.warn('[App] Supabase sync bootstrap failed:', error); + }); + // Initialize update service await UpdateService.initialize(); @@ -314,4 +324,4 @@ const styles = StyleSheet.create({ }, }); -export default Sentry.wrap(App); \ No newline at end of file +export default Sentry.wrap(App); diff --git a/README.md b/README.md index cd7f51b7..671cef36 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ [![License][license-shield]][license-url]

- A modern media hub built with React Native and Expo. + A modern media hub for Android and iOS built with React Native and Expo.
- Stremio Addon ecosystem • Cross-platform • Offline metadata & sync + Stremio Addon ecosystem • Cross-platform

## About -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. +Nuvio Media Hub is a cross-platform app for managing and discovering media, with a playback-focused interface that can integrate with the Stremio addon ecosystem through user-installed extensions. ## Installation @@ -30,9 +30,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio ### iOS -* [TestFlight](https://testflight.apple.com/join/QkKMGRqp) -* [AltStore](https://tinyurl.com/NuvioAltstore) -* [SideStore](https://tinyurl.com/NuvioSidestore) +- [TestFlight](https://testflight.apple.com/join/QkKMGRqp) +- [AltStore](https://tinyurl.com/NuvioAltstore) +- [SideStore](https://tinyurl.com/NuvioSidestore) **Manual source:** `https://raw.githubusercontent.com/tapframe/NuvioStreaming/main/nuvio-source.json` @@ -41,7 +41,8 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio ```bash git clone https://github.com/tapframe/NuvioStreaming.git cd NuvioStreaming -npm install +npm install --legacy-peer-deps +npx expo prebuild npx expo run:android # or npx expo run:ios @@ -49,15 +50,17 @@ npx expo run:ios ## Legal & DMCA -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. +Nuvio functions solely as a client-side interface for browsing metadata and playing media provided by user-installed extensions and/or user-provided sources. It is intended for content the user owns or is otherwise authorized to access. + +Nuvio is not affiliated with any third-party extensions, catalogs, sources, or content providers. 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)**. ## Built With -* React Native -* Expo -* TypeScript +- React Native +- Expo +- TypeScript ## Star History diff --git a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png index 15b512eb..84066f8c 100644 Binary files a/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-hdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png index 8b78e2f9..a1907f1b 100644 Binary files a/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-mdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png index 528962ab..5e73c55f 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png index 0969e6a3..81d907ff 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png index 7de47821..bb8a75f5 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png and b/android/app/src/main/res/drawable-xxxhdpi/splashscreen_logo.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp index 70320a98..409cd825 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp index 46b9e444..fa1a496b 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp index 63e85ffe..9ea4744d 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp index a5a1dfa9..0d22646c 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp index 133272a5..7acd5195 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp index a1156c2f..e152d6b3 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp index 705ab974..fa2054ea 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp index 401543d6..f1994a0d 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp index 111ae461..8d958805 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp index 76fb597c..72ed3e7e 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp index 5e694a8a..e0989481 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp index a06585cb..6f370d8c 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp index 57b30c91..7ec5130e 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp index ab60c474..9ac056b7 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp index acdda655..6fe44f1f 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ diff --git a/app.json b/app.json index d5510500..f1007308 100644 --- a/app.json +++ b/app.json @@ -35,7 +35,7 @@ "LSSupportsOpeningDocumentsInPlace": true, "UIFileSharingEnabled": true }, - "bundleIdentifier": "com.nuvio.app", + "bundleIdentifier": "com.nuvio.hub", "associatedDomains": [], "jsEngine": "hermes", "appleTeamId": "8QBDZ766S3" diff --git a/assets/android/ic_launcher-web.png b/assets/android/ic_launcher-web.png index 493e3b98..130efa79 100644 Binary files a/assets/android/ic_launcher-web.png and b/assets/android/ic_launcher-web.png differ diff --git a/assets/android/mipmap-hdpi/ic_launcher.png b/assets/android/mipmap-hdpi/ic_launcher.png index 558dfbae..c304d0eb 100644 Binary files a/assets/android/mipmap-hdpi/ic_launcher.png and b/assets/android/mipmap-hdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-hdpi/ic_launcher_foreground.png b/assets/android/mipmap-hdpi/ic_launcher_foreground.png index 2f49c5f1..b6d88502 100644 Binary files a/assets/android/mipmap-hdpi/ic_launcher_foreground.png and b/assets/android/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-hdpi/ic_launcher_round.png b/assets/android/mipmap-hdpi/ic_launcher_round.png index 1b48a3ef..c304d0eb 100644 Binary files a/assets/android/mipmap-hdpi/ic_launcher_round.png and b/assets/android/mipmap-hdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-ldpi/ic_launcher.png b/assets/android/mipmap-ldpi/ic_launcher.png index 3524b2fc..fa0c0fdd 100644 Binary files a/assets/android/mipmap-ldpi/ic_launcher.png and b/assets/android/mipmap-ldpi/ic_launcher.png differ diff --git a/assets/android/mipmap-ldpi/ic_launcher_foreground.png b/assets/android/mipmap-ldpi/ic_launcher_foreground.png new file mode 100644 index 00000000..1a8d780b Binary files /dev/null and b/assets/android/mipmap-ldpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-ldpi/ic_launcher_round.png b/assets/android/mipmap-ldpi/ic_launcher_round.png index 02020f6f..fa0c0fdd 100644 Binary files a/assets/android/mipmap-ldpi/ic_launcher_round.png and b/assets/android/mipmap-ldpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher.png b/assets/android/mipmap-mdpi/ic_launcher.png index 6c259c80..6605b555 100644 Binary files a/assets/android/mipmap-mdpi/ic_launcher.png and b/assets/android/mipmap-mdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher_foreground.png b/assets/android/mipmap-mdpi/ic_launcher_foreground.png index 64546171..0fd39bfd 100644 Binary files a/assets/android/mipmap-mdpi/ic_launcher_foreground.png and b/assets/android/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-mdpi/ic_launcher_round.png b/assets/android/mipmap-mdpi/ic_launcher_round.png index f637369f..6605b555 100644 Binary files a/assets/android/mipmap-mdpi/ic_launcher_round.png and b/assets/android/mipmap-mdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher.png b/assets/android/mipmap-xhdpi/ic_launcher.png index 9fb69a54..0587b0f6 100644 Binary files a/assets/android/mipmap-xhdpi/ic_launcher.png and b/assets/android/mipmap-xhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png index f03be67e..98921102 100644 Binary files a/assets/android/mipmap-xhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xhdpi/ic_launcher_round.png b/assets/android/mipmap-xhdpi/ic_launcher_round.png index c34a4836..0587b0f6 100644 Binary files a/assets/android/mipmap-xhdpi/ic_launcher_round.png and b/assets/android/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher.png b/assets/android/mipmap-xxhdpi/ic_launcher.png index d16402dd..df92e92b 100644 Binary files a/assets/android/mipmap-xxhdpi/ic_launcher.png and b/assets/android/mipmap-xxhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png index 8605f5e6..1be0f327 100644 Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxhdpi/ic_launcher_round.png index 749d0724..df92e92b 100644 Binary files a/assets/android/mipmap-xxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher.png b/assets/android/mipmap-xxxhdpi/ic_launcher.png index 2e35f619..947a0bdd 100644 Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher.png and b/assets/android/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png index 6cc901eb..dbb45959 100644 Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png index 06cdb2f5..947a0bdd 100644 Binary files a/assets/android/mipmap-xxxhdpi/ic_launcher_round.png and b/assets/android/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/assets/android/playstore-icon.png b/assets/android/playstore-icon.png index e5dd4a6b..130efa79 100644 Binary files a/assets/android/playstore-icon.png and b/assets/android/playstore-icon.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png index 18d719cf..812a1b4d 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png index 5bd9ac8e..bd953ebc 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png index a526217a..ddf78c6e 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png index e2841b48..8deccd3e 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png index 34e2d79c..6ef6611a 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png index 11953d6c..0a314649 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png index 5bd9ac8e..bd953ebc 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png index 56a3b787..b1c0ba2a 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png index c36efb6e..a18395f6 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png index c36efb6e..a18395f6 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png index d84612d1..262b4f38 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png and b/assets/ios/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png index 53317602..46ea3f49 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png index 082be8c5..2067acf4 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 2218f825..5e270ab1 100644 Binary files a/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/assets/ios/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png index 8ac40dd4..6f5a8d75 100644 Binary files a/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png and b/assets/ios/AppIcon.appiconset/ItunesArtwork@2x.png differ diff --git a/assets/ios/iTunesArtwork@1x.png b/assets/ios/iTunesArtwork@1x.png index 637db4d5..130efa79 100644 Binary files a/assets/ios/iTunesArtwork@1x.png and b/assets/ios/iTunesArtwork@1x.png differ diff --git a/assets/ios/iTunesArtwork@2x.png b/assets/ios/iTunesArtwork@2x.png index 8ac40dd4..6f5a8d75 100644 Binary files a/assets/ios/iTunesArtwork@2x.png and b/assets/ios/iTunesArtwork@2x.png differ diff --git a/assets/ios/iTunesArtwork@3x.png b/assets/ios/iTunesArtwork@3x.png index 18b16be3..9fc0d4ad 100644 Binary files a/assets/ios/iTunesArtwork@3x.png and b/assets/ios/iTunesArtwork@3x.png differ diff --git a/assets/nuvio-sync-icon-og.png b/assets/nuvio-sync-icon-og.png new file mode 100644 index 00000000..28bf19e5 Binary files /dev/null and b/assets/nuvio-sync-icon-og.png differ diff --git a/assets/player-icons/ic_player_aspect_ratio.svg b/assets/player-icons/ic_player_aspect_ratio.svg new file mode 100644 index 00000000..12b2f7b6 --- /dev/null +++ b/assets/player-icons/ic_player_aspect_ratio.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/player-icons/ic_player_audio_filled.svg b/assets/player-icons/ic_player_audio_filled.svg new file mode 100644 index 00000000..2961dd6a --- /dev/null +++ b/assets/player-icons/ic_player_audio_filled.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/player-icons/ic_player_audio_outline.svg b/assets/player-icons/ic_player_audio_outline.svg new file mode 100644 index 00000000..87c64646 --- /dev/null +++ b/assets/player-icons/ic_player_audio_outline.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/player-icons/ic_player_episodes.svg b/assets/player-icons/ic_player_episodes.svg new file mode 100644 index 00000000..c09c7205 --- /dev/null +++ b/assets/player-icons/ic_player_episodes.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/player-icons/ic_player_pause.svg b/assets/player-icons/ic_player_pause.svg new file mode 100644 index 00000000..69f83449 --- /dev/null +++ b/assets/player-icons/ic_player_pause.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/player-icons/ic_player_play.svg b/assets/player-icons/ic_player_play.svg new file mode 100644 index 00000000..d375a176 --- /dev/null +++ b/assets/player-icons/ic_player_play.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/player-icons/ic_player_source.svg b/assets/player-icons/ic_player_source.svg new file mode 100644 index 00000000..1e79c2a3 --- /dev/null +++ b/assets/player-icons/ic_player_source.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/player-icons/ic_player_subtitles.svg b/assets/player-icons/ic_player_subtitles.svg new file mode 100644 index 00000000..bf8041e3 --- /dev/null +++ b/assets/player-icons/ic_player_subtitles.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/text_only_og.png b/assets/text_only_og.png new file mode 100644 index 00000000..35eca9a4 Binary files /dev/null and b/assets/text_only_og.png differ diff --git a/docs/SUPABASE_SYNC.md b/docs/SUPABASE_SYNC.md new file mode 100644 index 00000000..494c2ddc --- /dev/null +++ b/docs/SUPABASE_SYNC.md @@ -0,0 +1,1254 @@ +# NuvioTV Supabase Sync Documentation + +This document describes the complete Supabase backend used by NuvioTV for cross-device data synchronization. It covers database schema, RPC functions, authentication, device linking, and integration patterns. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [Prerequisites](#prerequisites) +3. [Database Schema](#database-schema) +4. [RPC Functions](#rpc-functions) +5. [Integration Guide](#integration-guide) +6. [Data Models](#data-models) +7. [Sync Behavior & Restrictions](#sync-behavior--restrictions) +8. [Error Handling](#error-handling) + +--- + +## Overview + +NuvioTV syncs the following data to Supabase so linked devices share the same state: + +| Data | Description | Trakt Override | +|------|-------------|----------------| +| **Plugins** | JavaScript plugin repository URLs | No (always syncs) | +| **Addons** | Stremio-compatible addon manifest URLs | No (always syncs) | +| **Watch Progress** | Per-movie/episode playback position | Yes (skipped when Trakt connected) | +| **Library** | Saved movies & TV shows | Yes (skipped when Trakt connected) | +| **Watched Items** | Permanent watched history (movies & episodes) | Yes (skipped when Trakt connected) | + +### Authentication Model + +- **Anonymous**: Auto-created account, can generate/claim sync codes +- **Email/Password**: Full account with permanent data storage +- **Linked Device**: A device linked to another account via sync code; reads/writes the owner's data + +### Security Model + +All data operations use **SECURITY DEFINER** RPC functions that call `get_sync_owner()` to resolve the effective user ID. This allows linked devices to transparently access the owner's data without needing direct RLS access. + +--- + +## Prerequisites + +- Supabase project with: + - **Auth** enabled (anonymous sign-in + email/password) + - **pgcrypto** extension enabled (for `crypt()`, `gen_salt()`) +- Environment variables: + - `SUPABASE_URL` — Your Supabase project URL + - `SUPABASE_ANON_KEY` — Your Supabase anonymous/public key + +--- + +## Database Schema + +### Tables + +#### `sync_codes` + +Temporary codes for device linking, protected by a bcrypt-hashed PIN. + +```sql +CREATE TABLE sync_codes ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + code TEXT NOT NULL, + pin_hash TEXT NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ DEFAULT 'infinity'::TIMESTAMPTZ +); + +ALTER TABLE sync_codes ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own sync codes" + ON sync_codes FOR ALL + USING (auth.uid() = owner_id) + WITH CHECK (auth.uid() = owner_id); +``` + +#### `linked_devices` + +Maps a child device's user ID to a parent (owner) user ID. + +```sql +CREATE TABLE linked_devices ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + device_user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + device_name TEXT, + linked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(owner_id, device_user_id) +); + +ALTER TABLE linked_devices ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Owners can read their linked devices" + ON linked_devices FOR SELECT + USING (auth.uid() = owner_id); + +CREATE POLICY "Devices can read their own link" + ON linked_devices FOR SELECT + USING (auth.uid() = device_user_id); +``` + +#### `plugins` + +Plugin repository URLs synced across devices. + +```sql +CREATE TABLE plugins ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + name TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_plugins_user_id ON plugins(user_id); +ALTER TABLE plugins ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own plugins" + ON plugins FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +#### `addons` + +Addon manifest URLs synced across devices. + +```sql +CREATE TABLE addons ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + url TEXT NOT NULL, + name TEXT, + enabled BOOLEAN NOT NULL DEFAULT true, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_addons_user_id ON addons(user_id); +ALTER TABLE addons ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own addons" + ON addons FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +#### `watch_progress` + +Per-movie or per-episode playback progress. + +```sql +CREATE TABLE watch_progress ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + content_id TEXT NOT NULL, + content_type TEXT NOT NULL, + video_id TEXT NOT NULL, + season INTEGER, + episode INTEGER, + position BIGINT NOT NULL DEFAULT 0, + duration BIGINT NOT NULL DEFAULT 0, + last_watched BIGINT NOT NULL DEFAULT 0, + progress_key TEXT NOT NULL +); + +CREATE INDEX idx_watch_progress_user_id ON watch_progress(user_id); +ALTER TABLE watch_progress ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own watch progress" + ON watch_progress FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +#### `library_items` + +Saved movies and TV shows (bookmarks/favorites). + +```sql +CREATE TABLE library_items ( + id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + content_id TEXT NOT NULL, + content_type TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + poster TEXT, + poster_shape TEXT NOT NULL DEFAULT 'POSTER', + background TEXT, + description TEXT, + release_info TEXT, + imdb_rating REAL, + genres TEXT[] DEFAULT '{}', + addon_base_url TEXT, + added_at BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ DEFAULT now(), + updated_at TIMESTAMPTZ DEFAULT now(), + UNIQUE(user_id, content_id, content_type) +); + +CREATE INDEX idx_library_items_user_id ON library_items(user_id); +ALTER TABLE library_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own library items" + ON library_items FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +#### `watched_items` + +Permanent watched history. Unlike `watch_progress` (which is capped and stores playback position), this table is a permanent record of everything the user has watched or marked as watched. Used to determine if a movie or episode should show a "watched" checkmark. + +```sql +CREATE TABLE watched_items ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + content_id TEXT NOT NULL, + content_type TEXT NOT NULL, + title TEXT NOT NULL DEFAULT '', + season INTEGER, + episode INTEGER, + watched_at BIGINT NOT NULL, + created_at TIMESTAMPTZ DEFAULT now() +); + +CREATE UNIQUE INDEX idx_watched_items_unique + ON watched_items (user_id, content_id, COALESCE(season, -1), COALESCE(episode, -1)); + +CREATE INDEX idx_watched_items_user_id ON watched_items(user_id); + +ALTER TABLE watched_items ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can manage own watched items" + ON watched_items FOR ALL + USING (auth.uid() = user_id) + WITH CHECK (auth.uid() = user_id); +``` + +> **Note:** The unique index uses `COALESCE(season, -1)` and `COALESCE(episode, -1)` because PostgreSQL treats NULLs as distinct in unique constraints. Movies have `NULL` season/episode, so without COALESCE, multiple entries for the same movie would be allowed. + +### Triggers + +```sql +-- Auto-update updated_at timestamp +CREATE OR REPLACE FUNCTION set_updated_at() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$; + +-- Apply to tables with updated_at +CREATE TRIGGER set_updated_at BEFORE UPDATE ON plugins FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON addons FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +CREATE TRIGGER set_updated_at BEFORE UPDATE ON sync_codes FOR EACH ROW EXECUTE FUNCTION set_updated_at(); +``` + +--- + +## RPC Functions + +### Core: `get_sync_owner()` + +Resolves the effective user ID. If the current user is a linked device, returns the owner's ID. Otherwise returns the caller's own ID. This is the foundation of the linked-device sync model. + +```sql +CREATE OR REPLACE FUNCTION get_sync_owner() +RETURNS UUID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_owner_id uuid; +BEGIN + SELECT owner_id INTO v_owner_id + FROM linked_devices + WHERE device_user_id = auth.uid() + LIMIT 1; + + RETURN COALESCE(v_owner_id, auth.uid()); +END; +$$; + +GRANT EXECUTE ON FUNCTION get_sync_owner() TO authenticated; +``` + +### Core: `can_access_user_data(p_user_id UUID)` + +Helper to check if the current user can access another user's data (either they are that user, or they are a linked device). + +```sql +CREATE OR REPLACE FUNCTION can_access_user_data(p_user_id UUID) +RETURNS BOOLEAN +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + IF auth.uid() = p_user_id THEN + RETURN true; + END IF; + + IF EXISTS ( + SELECT 1 FROM public.linked_devices + WHERE owner_id = p_user_id + AND device_user_id = auth.uid() + ) THEN + RETURN true; + END IF; + + RETURN false; +END; +$$; + +GRANT EXECUTE ON FUNCTION can_access_user_data(UUID) TO authenticated; +``` + +### Device Linking: `generate_sync_code(p_pin TEXT)` + +Generates a sync code for the current user. If a code already exists, updates the PIN. The code format is `XXXX-XXXX-XXXX-XXXX-XXXX` (uppercase hex). PIN is bcrypt-hashed. + +```sql +CREATE OR REPLACE FUNCTION generate_sync_code(p_pin TEXT) +RETURNS TABLE(code TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id uuid; + v_existing_code text; + v_new_code text; + v_pin_hash text; +BEGIN + v_user_id := auth.uid(); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT sc.code INTO v_existing_code + FROM sync_codes sc + WHERE sc.owner_id = v_user_id + ORDER BY sc.created_at DESC + LIMIT 1; + + IF v_existing_code IS NOT NULL THEN + v_pin_hash := crypt(p_pin, gen_salt('bf')); + UPDATE sync_codes + SET pin_hash = v_pin_hash + WHERE sync_codes.owner_id = v_user_id + AND sync_codes.code = v_existing_code; + RETURN QUERY SELECT v_existing_code; + RETURN; + END IF; + + v_new_code := upper( + substr(md5(random()::text || clock_timestamp()::text), 1, 4) || '-' || + substr(md5(random()::text || clock_timestamp()::text), 5, 4) || '-' || + substr(md5(random()::text || clock_timestamp()::text), 9, 4) || '-' || + substr(md5(random()::text || clock_timestamp()::text), 13, 4) || '-' || + substr(md5(random()::text || clock_timestamp()::text), 17, 4) + ); + + v_pin_hash := crypt(p_pin, gen_salt('bf')); + + INSERT INTO sync_codes (owner_id, code, pin_hash) + VALUES (v_user_id, v_new_code, v_pin_hash); + + RETURN QUERY SELECT v_new_code; +END; +$$; + +GRANT EXECUTE ON FUNCTION generate_sync_code(TEXT) TO authenticated; +``` + +### Device Linking: `get_sync_code(p_pin TEXT)` + +Retrieves the existing sync code for the current user, validated by PIN. + +```sql +CREATE OR REPLACE FUNCTION get_sync_code(p_pin TEXT) +RETURNS TABLE(code TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id uuid; + v_existing_code text; + v_existing_pin_hash text; +BEGIN + v_user_id := auth.uid(); + + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'Not authenticated'; + END IF; + + SELECT sc.code, sc.pin_hash + INTO v_existing_code, v_existing_pin_hash + FROM sync_codes sc + WHERE sc.owner_id = v_user_id + ORDER BY sc.created_at DESC + LIMIT 1; + + IF v_existing_code IS NULL THEN + RAISE EXCEPTION 'No sync code found. Generate one first.'; + END IF; + + IF v_existing_pin_hash != crypt(p_pin, v_existing_pin_hash) THEN + RAISE EXCEPTION 'Incorrect PIN'; + END IF; + + RETURN QUERY SELECT v_existing_code; +END; +$$; + +GRANT EXECUTE ON FUNCTION get_sync_code(TEXT) TO authenticated; +``` + +### Device Linking: `claim_sync_code(p_code TEXT, p_pin TEXT, p_device_name TEXT)` + +Links the current device to the owner of the sync code. Validates the PIN, then creates a `linked_devices` row. + +```sql +CREATE OR REPLACE FUNCTION claim_sync_code(p_code TEXT, p_pin TEXT, p_device_name TEXT DEFAULT NULL) +RETURNS TABLE(result_owner_id UUID, success BOOLEAN, message TEXT) +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_owner_id uuid; + v_pin_hash text; +BEGIN + SELECT sc.owner_id, sc.pin_hash + INTO v_owner_id, v_pin_hash + FROM sync_codes sc + WHERE sc.code = p_code; + + IF v_owner_id IS NULL THEN + RETURN QUERY SELECT NULL::uuid, false, 'Sync code not found'::text; + RETURN; + END IF; + + IF crypt(p_pin, v_pin_hash) != v_pin_hash THEN + RETURN QUERY SELECT NULL::uuid, false, 'Incorrect PIN'::text; + RETURN; + END IF; + + INSERT INTO linked_devices (owner_id, device_user_id, device_name) + VALUES (v_owner_id, auth.uid(), p_device_name) + ON CONFLICT (owner_id, device_user_id) DO UPDATE + SET device_name = EXCLUDED.device_name; + + RETURN QUERY SELECT v_owner_id, true, 'Device linked successfully'::text; +END; +$$; + +GRANT EXECUTE ON FUNCTION claim_sync_code(TEXT, TEXT, TEXT) TO authenticated; +``` + +### Device Linking: `unlink_device(p_device_user_id UUID)` + +Removes a linked device. Only the owner can unlink their devices. + +```sql +CREATE OR REPLACE FUNCTION unlink_device(p_device_user_id UUID) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +BEGIN + DELETE FROM linked_devices + WHERE (owner_id = auth.uid() AND device_user_id = p_device_user_id) + OR (device_user_id = auth.uid() AND device_user_id = p_device_user_id); +END; +$$; + +GRANT EXECUTE ON FUNCTION unlink_device(UUID) TO authenticated; +``` + +### Sync: `sync_push_plugins(p_plugins JSONB)` + +Full-replace push of plugin repository URLs. + +```sql +CREATE OR REPLACE FUNCTION sync_push_plugins(p_plugins JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id uuid; + v_plugin jsonb; +BEGIN + SELECT get_sync_owner() INTO v_effective_user_id; + + DELETE FROM plugins WHERE user_id = v_effective_user_id; + + FOR v_plugin IN SELECT * FROM jsonb_array_elements(p_plugins) + LOOP + INSERT INTO plugins (user_id, url, name, enabled, sort_order) + VALUES ( + v_effective_user_id, + v_plugin->>'url', + v_plugin->>'name', + COALESCE((v_plugin->>'enabled')::boolean, true), + (v_plugin->>'sort_order')::int + ); + END LOOP; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_plugins(JSONB) TO authenticated; +``` + +### Sync: `sync_push_addons(p_addons JSONB)` + +Full-replace push of addon manifest URLs. + +```sql +CREATE OR REPLACE FUNCTION sync_push_addons(p_addons JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id uuid; + v_addon jsonb; +BEGIN + SELECT get_sync_owner() INTO v_effective_user_id; + + DELETE FROM addons WHERE user_id = v_effective_user_id; + + FOR v_addon IN SELECT * FROM jsonb_array_elements(p_addons) + LOOP + INSERT INTO addons (user_id, url, sort_order) + VALUES ( + v_effective_user_id, + v_addon->>'url', + (v_addon->>'sort_order')::int + ); + END LOOP; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_addons(JSONB) TO authenticated; +``` + +### Sync: `sync_push_watch_progress(p_entries JSONB)` + +Full-replace push of watch progress entries. + +```sql +CREATE OR REPLACE FUNCTION sync_push_watch_progress(p_entries JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + + DELETE FROM watch_progress WHERE user_id = v_effective_user_id; + + INSERT INTO watch_progress ( + user_id, content_id, content_type, video_id, + season, episode, position, duration, last_watched, progress_key + ) + SELECT + v_effective_user_id, + (entry->>'content_id'), + (entry->>'content_type'), + (entry->>'video_id'), + (entry->>'season')::INTEGER, + (entry->>'episode')::INTEGER, + (entry->>'position')::BIGINT, + (entry->>'duration')::BIGINT, + (entry->>'last_watched')::BIGINT, + (entry->>'progress_key') + FROM jsonb_array_elements(p_entries) AS entry; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_watch_progress(JSONB) TO authenticated; +``` + +### Sync: `sync_pull_watch_progress()` + +Returns all watch progress for the effective user (owner or linked device's owner). + +```sql +CREATE OR REPLACE FUNCTION sync_pull_watch_progress() +RETURNS SETOF watch_progress +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + RETURN QUERY SELECT * FROM watch_progress WHERE user_id = v_effective_user_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_pull_watch_progress() TO authenticated; +``` + +### Sync: `sync_push_library(p_items JSONB)` + +Full-replace push of library items. + +```sql +CREATE OR REPLACE FUNCTION sync_push_library(p_items JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + + DELETE FROM library_items WHERE user_id = v_effective_user_id; + + INSERT INTO library_items ( + user_id, content_id, content_type, name, poster, poster_shape, + background, description, release_info, imdb_rating, genres, + addon_base_url, added_at + ) + SELECT + v_effective_user_id, + (item->>'content_id'), + (item->>'content_type'), + COALESCE(item->>'name', ''), + (item->>'poster'), + COALESCE(item->>'poster_shape', 'POSTER'), + (item->>'background'), + (item->>'description'), + (item->>'release_info'), + (item->>'imdb_rating')::REAL, + COALESCE( + (SELECT array_agg(g::TEXT) FROM jsonb_array_elements_text(item->'genres') AS g), + '{}' + ), + (item->>'addon_base_url'), + COALESCE((item->>'added_at')::BIGINT, EXTRACT(EPOCH FROM now())::BIGINT * 1000) + FROM jsonb_array_elements(p_items) AS item; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_library(JSONB) TO authenticated; +``` + +### Sync: `sync_pull_library()` + +Returns all library items for the effective user. + +```sql +CREATE OR REPLACE FUNCTION sync_pull_library() +RETURNS SETOF library_items +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + RETURN QUERY SELECT * FROM library_items WHERE user_id = v_effective_user_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_pull_library() TO authenticated; +``` + +### Sync: `sync_push_watched_items(p_items JSONB)` + +Full-replace push of watched items (permanent watched history). + +```sql +CREATE OR REPLACE FUNCTION sync_push_watched_items(p_items JSONB) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + DELETE FROM watched_items WHERE user_id = v_effective_user_id; + INSERT INTO watched_items (user_id, content_id, content_type, title, season, episode, watched_at) + SELECT + v_effective_user_id, + (item->>'content_id'), + (item->>'content_type'), + COALESCE(item->>'title', ''), + (item->>'season')::INTEGER, + (item->>'episode')::INTEGER, + (item->>'watched_at')::BIGINT + FROM jsonb_array_elements(p_items) AS item; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_push_watched_items(JSONB) TO authenticated; +``` + +### Sync: `sync_pull_watched_items()` + +Returns all watched items for the effective user. + +```sql +CREATE OR REPLACE FUNCTION sync_pull_watched_items() +RETURNS SETOF watched_items +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_effective_user_id UUID; +BEGIN + v_effective_user_id := get_sync_owner(); + RETURN QUERY SELECT * FROM watched_items WHERE user_id = v_effective_user_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION sync_pull_watched_items() TO authenticated; +``` + +--- + +## Integration Guide + +### 1. Authentication + +All API calls require a Supabase auth session. Initialize the Supabase client and authenticate: + +``` +POST {SUPABASE_URL}/auth/v1/signup +Headers: apikey: {SUPABASE_ANON_KEY} +Body: { "email": "user@example.com", "password": "..." } +``` + +Or for anonymous sign-in: + +``` +POST {SUPABASE_URL}/auth/v1/signup +Headers: apikey: {SUPABASE_ANON_KEY} +Body: {} +``` + +All subsequent requests include: +``` +Headers: + apikey: {SUPABASE_ANON_KEY} + Authorization: Bearer {ACCESS_TOKEN} +``` + +### 2. Calling RPC Functions + +All RPCs are called via the Supabase PostgREST endpoint: + +``` +POST {SUPABASE_URL}/rest/v1/rpc/{function_name} +Headers: + apikey: {SUPABASE_ANON_KEY} + Authorization: Bearer {ACCESS_TOKEN} + Content-Type: application/json +Body: { ...parameters... } +``` + +### 3. Device Linking Flow + +**Device A (Parent) — Generate Sync Code:** + +```json +// POST /rest/v1/rpc/generate_sync_code +{ "p_pin": "1234" } + +// Response: +[{ "code": "A1B2-C3D4-E5F6-G7H8-I9J0" }] +``` + +**Device B (Child) — Claim Sync Code:** + +```json +// POST /rest/v1/rpc/claim_sync_code +{ + "p_code": "A1B2-C3D4-E5F6-G7H8-I9J0", + "p_pin": "1234", + "p_device_name": "Living Room TV" +} + +// Response: +[{ + "result_owner_id": "uuid-of-device-a-user", + "success": true, + "message": "Device linked successfully" +}] +``` + +After claiming, Device B's `get_sync_owner()` will return Device A's user ID, so all push/pull operations operate on the shared data. + +**Retrieve Existing Code (with PIN):** + +```json +// POST /rest/v1/rpc/get_sync_code +{ "p_pin": "1234" } + +// Response: +[{ "code": "A1B2-C3D4-E5F6-G7H8-I9J0" }] +``` + +**Get Linked Devices:** + +``` +GET {SUPABASE_URL}/rest/v1/linked_devices?select=*&owner_id=eq.{your_user_id} +``` + +**Unlink a Device:** + +```json +// POST /rest/v1/rpc/unlink_device +{ "p_device_user_id": "uuid-of-device-to-unlink" } +``` + +### 4. Pushing Data + +All push RPCs use a **full-replace** strategy: existing data for the effective user is deleted, then the new data is inserted. This means you must always push the **complete** local dataset, not just changes. + +#### Push Plugins + +```json +// POST /rest/v1/rpc/sync_push_plugins +{ + "p_plugins": [ + { + "url": "https://example.com/plugin-repo", + "name": "My Plugin Repo", + "enabled": true, + "sort_order": 0 + } + ] +} +``` + +#### Push Addons + +```json +// POST /rest/v1/rpc/sync_push_addons +{ + "p_addons": [ + { + "url": "https://example.com/addon/manifest.json", + "sort_order": 0 + } + ] +} +``` + +#### Push Watch Progress + +```json +// POST /rest/v1/rpc/sync_push_watch_progress +{ + "p_entries": [ + { + "content_id": "tt1234567", + "content_type": "movie", + "video_id": "tt1234567", + "season": null, + "episode": null, + "position": 3600000, + "duration": 7200000, + "last_watched": 1700000000000, + "progress_key": "tt1234567" + }, + { + "content_id": "tt7654321", + "content_type": "series", + "video_id": "tt7654321:2:5", + "season": 2, + "episode": 5, + "position": 1800000, + "duration": 3600000, + "last_watched": 1700000000000, + "progress_key": "tt7654321_s2e5" + } + ] +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `content_id` | string | IMDB ID or content identifier | +| `content_type` | string | `"movie"` or `"series"` | +| `video_id` | string | Video stream identifier | +| `season` | int/null | Season number (null for movies) | +| `episode` | int/null | Episode number (null for movies) | +| `position` | long | Playback position in milliseconds | +| `duration` | long | Total duration in milliseconds | +| `last_watched` | long | Unix timestamp in milliseconds | +| `progress_key` | string | Unique key: `contentId` for movies, `contentId_s{S}e{E}` for episodes | + +#### Push Library Items + +```json +// POST /rest/v1/rpc/sync_push_library +{ + "p_items": [ + { + "content_id": "tt1234567", + "content_type": "movie", + "name": "Example Movie", + "poster": "https://image.tmdb.org/t/p/w500/poster.jpg", + "poster_shape": "POSTER", + "background": "https://image.tmdb.org/t/p/original/backdrop.jpg", + "description": "A great movie about...", + "release_info": "2024", + "imdb_rating": 8.5, + "genres": ["Action", "Thriller"], + "addon_base_url": "https://example.com/addon" + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `content_id` | string | Yes | IMDB ID or content identifier | +| `content_type` | string | Yes | `"movie"` or `"series"` | +| `name` | string | No | Display name (defaults to `""`) | +| `poster` | string | No | Poster image URL | +| `poster_shape` | string | No | `"POSTER"`, `"LANDSCAPE"`, or `"SQUARE"` (defaults to `"POSTER"`) | +| `background` | string | No | Background/backdrop image URL | +| `description` | string | No | Content description | +| `release_info` | string | No | Release year or date string | +| `imdb_rating` | float | No | IMDB rating (0.0-10.0) | +| `genres` | string[] | No | Genre list (defaults to `[]`) | +| `addon_base_url` | string | No | Source addon base URL | +| `added_at` | long | No | Timestamp in ms (defaults to current time) | + +#### Push Watched Items + +```json +// POST /rest/v1/rpc/sync_push_watched_items +{ + "p_items": [ + { + "content_id": "tt1234567", + "content_type": "movie", + "title": "Example Movie", + "season": null, + "episode": null, + "watched_at": 1700000000000 + }, + { + "content_id": "tt7654321", + "content_type": "series", + "title": "Example Series", + "season": 2, + "episode": 5, + "watched_at": 1700000000000 + } + ] +} +``` + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `content_id` | string | Yes | IMDB ID or content identifier | +| `content_type` | string | Yes | `"movie"` or `"series"` | +| `title` | string | No | Display name (defaults to `""`) | +| `season` | int/null | No | Season number (null for movies) | +| `episode` | int/null | No | Episode number (null for movies) | +| `watched_at` | long | Yes | Unix timestamp in milliseconds | + +### 5. Pulling Data + +#### Pull Watch Progress + +```json +// POST /rest/v1/rpc/sync_pull_watch_progress +{} + +// Response: array of watch_progress rows +[ + { + "id": "uuid", + "user_id": "uuid", + "content_id": "tt1234567", + "content_type": "movie", + "video_id": "tt1234567", + "season": null, + "episode": null, + "position": 3600000, + "duration": 7200000, + "last_watched": 1700000000000, + "progress_key": "tt1234567" + } +] +``` + +#### Pull Library Items + +```json +// POST /rest/v1/rpc/sync_pull_library +{} + +// Response: array of library_items rows +[ + { + "id": "uuid", + "user_id": "uuid", + "content_id": "tt1234567", + "content_type": "movie", + "name": "Example Movie", + "poster": "https://...", + "poster_shape": "POSTER", + "background": "https://...", + "description": "...", + "release_info": "2024", + "imdb_rating": 8.5, + "genres": ["Action", "Thriller"], + "addon_base_url": "https://...", + "added_at": 1700000000000, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } +] +``` + +#### Pull Watched Items + +```json +// POST /rest/v1/rpc/sync_pull_watched_items +{} + +// Response: array of watched_items rows +[ + { + "id": "uuid", + "user_id": "uuid", + "content_id": "tt1234567", + "content_type": "movie", + "title": "Example Movie", + "season": null, + "episode": null, + "watched_at": 1700000000000, + "created_at": "2024-01-01T00:00:00Z" + } +] +``` + +#### Pull Plugins/Addons (Direct Table Query) + +Plugins and addons are pulled via direct table queries using the effective user ID: + +``` +// First, get the effective user ID +POST /rest/v1/rpc/get_sync_owner +{} +// Response: "uuid-of-effective-owner" + +// Then query tables +GET /rest/v1/addons?select=*&user_id=eq.{effective_user_id}&order=sort_order +GET /rest/v1/plugins?select=*&user_id=eq.{effective_user_id}&order=sort_order +``` + +--- + +## Data Models + +### Plugin + +```json +{ + "url": "string (required)", + "name": "string (optional)", + "enabled": "boolean (default: true)", + "sort_order": "integer (default: 0)" +} +``` + +### Addon + +```json +{ + "url": "string (required)", + "sort_order": "integer (default: 0)" +} +``` + +### Watch Progress Entry + +```json +{ + "content_id": "string (required)", + "content_type": "string (required) - 'movie' | 'series'", + "video_id": "string (required)", + "season": "integer (optional, null for movies)", + "episode": "integer (optional, null for movies)", + "position": "long (required) - playback position in ms", + "duration": "long (required) - total duration in ms", + "last_watched": "long (required) - unix timestamp in ms", + "progress_key": "string (required) - unique key per entry" +} +``` + +### Library Item + +```json +{ + "content_id": "string (required)", + "content_type": "string (required) - 'movie' | 'series'", + "name": "string (default: '')", + "poster": "string (optional) - poster image URL", + "poster_shape": "string (default: 'POSTER') - 'POSTER' | 'LANDSCAPE' | 'SQUARE'", + "background": "string (optional) - backdrop image URL", + "description": "string (optional)", + "release_info": "string (optional) - release year/date", + "imdb_rating": "float (optional) - 0.0 to 10.0", + "genres": "string[] (default: []) - list of genre names", + "addon_base_url": "string (optional) - source addon URL", + "added_at": "long (default: current time) - unix timestamp in ms" +} +``` + +### Watched Item + +```json +{ + "content_id": "string (required)", + "content_type": "string (required) - 'movie' | 'series'", + "title": "string (default: '') - display name", + "season": "integer (optional, null for movies)", + "episode": "integer (optional, null for movies)", + "watched_at": "long (required) - unix timestamp in ms" +} +``` + +### Linked Device + +```json +{ + "owner_id": "uuid (required) - parent account user ID", + "device_user_id": "uuid (required) - this device's user ID", + "device_name": "string (optional) - human-readable device name", + "linked_at": "timestamptz (auto-set)" +} +``` + +### Sync Code + +```json +{ + "owner_id": "uuid - user who generated the code", + "code": "string - format: XXXX-XXXX-XXXX-XXXX-XXXX", + "pin_hash": "string - bcrypt hash of the PIN", + "is_active": "boolean (default: true)", + "expires_at": "timestamptz (default: infinity)" +} +``` + +--- + +## Sync Behavior & Restrictions + +### Startup Sync Flow + +When the app starts and the user is authenticated (anonymous or full account): + +1. **Pull plugins** from remote → install any new ones locally +2. **Pull addons** from remote → install any new ones locally +3. If Trakt is **NOT** connected: + - **Pull watch progress** → merge into local (additive) + - **Push watch progress** → so linked devices can pull + - **Pull library items** → merge into local (additive) + - **Push library items** → so linked devices can pull + - **Pull watched items** → merge into local (additive) + - **Push watched items** → so linked devices can pull + +### On-Demand Sync + +- **Plugins/Addons**: Pushed to remote immediately when added or removed +- **Watch Progress**: Pushed with a 2-second debounce after any playback position update +- **Library Items**: Pushed with a 2-second debounce after add or remove +- **Watched Items**: Pushed with a 2-second debounce after mark/unmark as watched + +### Merge Strategy + +- **Push**: Full-replace. The entire local dataset replaces the remote dataset. +- **Pull (merge)**: Additive. Remote items not already present locally are added. Existing local items are preserved. Match keys vary by data type: `content_id` + `content_type` for library, `content_id` + `season` + `episode` for watched items. + +### Trakt Override + +When Trakt is connected: +- **Watch progress**, **library**, and **watched items** sync via Supabase is **completely skipped** +- Trakt becomes the source of truth for these data types +- **Plugins** and **addons** always sync regardless of Trakt status + +### Push on Account Events + +| Event | Action | +|-------|--------| +| Sign up (email) | Push all local data to remote | +| Sign in (email) | Pull all remote data to local | +| Generate sync code | Push all local data to remote, then generate code | +| Claim sync code | Pull all remote data from owner to local | + +--- + +## Error Handling + +### Sync Code Errors + +| Error Message | Cause | +|---------------|-------| +| `Not authenticated` | No auth session | +| `No sync code found. Generate one first.` | Calling `get_sync_code` before generating | +| `Incorrect PIN` | Wrong PIN for `get_sync_code` or `claim_sync_code` | +| `Sync code not found` | Invalid or non-existent code in `claim_sync_code` | +| `Device linked successfully` | Success response from `claim_sync_code` | + +### Auth Errors + +| Error Message | Cause | +|---------------|-------| +| `Invalid login credentials` | Wrong email or password | +| `Email not confirmed` | Email verification pending | +| `User already registered` | Duplicate email signup | +| `Password is too short/weak` | Password policy violation | +| `Signup is disabled` | Admin disabled signups | +| `Rate limit` / `Too many requests` | Too many auth attempts | + +### Network Errors + +| Error Message | Cause | +|---------------|-------| +| `Unable to resolve host` | No internet | +| `Timeout` / `Timed out` | Connection timeout | +| `Connection refused` | Server unreachable | +| `404` | RPC function not found (missing migration) | +| `400` / `Bad request` | Invalid parameters | diff --git a/index.html b/index.html index 749e2f0a..fe298212 100644 --- a/index.html +++ b/index.html @@ -779,9 +779,9 @@
01
-

Stremio Addon Support

-

Full compatibility with Stremio addons. Access your favorite content - providers seamlessly.

+

Stremio Addon Integration

+

Supports user-installed Stremio addons for metadata and source + integration.

@@ -984,9 +984,9 @@

Copyright & DMCA

-

We respect the intellectual property rights of others. Since Nuvio does not host any content, we - cannot remove content from the internet. However, if you believe that the application interface - itself infringes on your rights, please contact us.

+

We respect the intellectual property rights of others. Nuvio does not host media content. + If you believe this project's code, assets, or interface infringes your rights, please submit + a notice through the official project contact channels listed on this site and repository.

@@ -1157,4 +1157,4 @@ - \ No newline at end of file + diff --git a/ios/Nuvio.xcodeproj/project.pbxproj b/ios/Nuvio.xcodeproj/project.pbxproj index 08e51391..63bd1ba6 100644 --- a/ios/Nuvio.xcodeproj/project.pbxproj +++ b/ios/Nuvio.xcodeproj/project.pbxproj @@ -689,7 +689,7 @@ "-lc++", ); OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; - PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; + PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub; PRODUCT_NAME = Nuvio; SUPPORTS_MACCATALYST = YES; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; diff --git a/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png b/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png index 929e1472..28ac68d9 100644 Binary files a/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png and b/ios/Nuvio/Images.xcassets/AppIcon.appiconset/App-Icon-1024x1024@1x.png differ diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png index 8649d708..ef3b0dbb 100644 Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image.png differ diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png index 8649d708..ef3b0dbb 100644 Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@2x.png differ diff --git a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png index 8649d708..ef3b0dbb 100644 Binary files a/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png and b/ios/Nuvio/Images.xcassets/SplashScreenLegacy.imageset/image@3x.png differ diff --git a/nuvio-source.json b/nuvio-source.json index c8919604..e2d48d42 100644 --- a/nuvio-source.json +++ b/nuvio-source.json @@ -13,8 +13,8 @@ "name": "Nuvio", "bundleIdentifier": "com.nuvio.app", "developerName": "Tapframe", - "subtitle": "Streaming app for movies and TV shows", - "localizedDescription": "Nuvio is a comprehensive streaming application that provides access to a vast library of movies and TV shows.", + "subtitle": "Media player and discovery app", + "localizedDescription": "Nuvio is a media player and metadata discovery application for user-provided and user-installed sources.", "iconURL": "https://github.com/tapframe/NuvioStreaming/blob/main/assets/android/playstore-icon.png?raw=true", "tintColor": "#04dcfc", "category": "entertainment", @@ -264,4 +264,4 @@ } ], "news": [] -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index bb70659e..e7c7a877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1540,7 +1540,6 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -2098,7 +2097,6 @@ "resolved": "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz", "integrity": "sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g==", "license": "MIT", - "peer": true, "dependencies": { "anser": "^1.4.9", "pretty-format": "^29.7.0", @@ -2587,7 +2585,6 @@ "resolved": "https://registry.npmjs.org/@jimp/custom/-/custom-0.22.12.tgz", "integrity": "sha512-xcmww1O/JFP2MrlGUMd3Q78S3Qu6W3mYTXYuIqFq33EorgYHV/HqymHfXy9GjiCJ7OI+7lWx6nYFOzU7M4rd1Q==", "license": "MIT", - "peer": true, "dependencies": { "@jimp/core": "^0.22.12" } @@ -2773,7 +2770,6 @@ "resolved": "https://registry.npmjs.org/@lottiefiles/dotlottie-react/-/dotlottie-react-0.13.5.tgz", "integrity": "sha512-4U5okwjRqDPkjB572hfZtLXJ/LGfCo6vDwUB2KIPEUoSgqbIlw+UrbnaqVp3GS+dRvhMD27F2JObpHpYRlpF0Q==", "license": "MIT", - "peer": true, "dependencies": { "@lottiefiles/dotlottie-web": "0.44.0" }, @@ -3129,7 +3125,7 @@ "version": "0.72.8", "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "invariant": "^2.2.4", @@ -3250,7 +3246,6 @@ "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.25.tgz", "integrity": "sha512-zQeWK9txDePWbYfqTs0C6jeRdJTm/7VhQtW/1IbJNDi9/rFIRzZule8bdQPAnf8QWUsNujRmi1J9OG/hhfbalg==", "license": "MIT", - "peer": true, "dependencies": { "@react-navigation/core": "^7.13.6", "escape-string-regexp": "^4.0.0", @@ -3892,7 +3887,6 @@ "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -4113,7 +4107,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -4123,9 +4116,8 @@ "version": "0.72.8", "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", - "devOptional": true, + "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@react-native/virtualized-lists": "^0.72.4", "@types/react": "*" @@ -4661,7 +4653,6 @@ "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", @@ -5065,7 +5056,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6295,7 +6285,6 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.29.tgz", "integrity": "sha512-9C90gyOzV83y2S3XzCbRDCuKYNaiyCzuP9ketv46acHCEZn+QTamPK/DobdghoSiofCmlfoaiD6/SzfxDiHMnw==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.19", @@ -6499,7 +6488,6 @@ "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz", "integrity": "sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA==", "license": "MIT", - "peer": true, "dependencies": { "ua-parser-js": "^0.7.33" }, @@ -6527,7 +6515,6 @@ "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz", "integrity": "sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg==", "license": "MIT", - "peer": true, "peerDependencies": { "expo": "*", "react-native": "*" @@ -6538,7 +6525,6 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.10.tgz", "integrity": "sha512-UqyNaaLKRpj4pKAP4HZSLnuDQqueaO5tB1c/NWu5vh1/LF9ulItyyg2kF/IpeOp0DeOLk0GY0HrIXaKUMrwB+Q==", "license": "MIT", - "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -6634,7 +6620,6 @@ "resolved": "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz", "integrity": "sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g==", "license": "MIT", - "peer": true, "dependencies": { "rtl-detect": "^1.0.2" }, @@ -7694,7 +7679,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.28.4" }, @@ -10601,7 +10585,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -10642,7 +10625,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.26.0" }, @@ -10700,7 +10682,6 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz", "integrity": "sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ==", "license": "MIT", - "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.4", @@ -10789,7 +10770,6 @@ "resolved": "https://registry.npmjs.org/react-native-bottom-tabs/-/react-native-bottom-tabs-1.1.0.tgz", "integrity": "sha512-Uu1gvM3i1Hb4DjVvR/38J1QVQEs0RkPc7K6yon99HgvRWWOyLs7kjPDhUswtb8ije4pKW712skIXWJ0lgKzbyQ==", "license": "MIT", - "peer": true, "dependencies": { "react-freeze": "^1.0.0", "sf-symbols-typescript": "^2.0.0", @@ -10820,7 +10800,6 @@ "resolved": "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.29.1.tgz", "integrity": "sha512-du3qmv0e3Sm7qsd9SfmHps+AggLiylcBBQ8ztz7WUtd8ZjKs5V3kekAbi9R2W9bRLSg47Ntp4GGMYZOhikQdZA==", "license": "MIT", - "peer": true, "dependencies": { "@egjs/hammerjs": "^2.0.17", "hoist-non-react-statics": "^3.3.0", @@ -10919,7 +10898,6 @@ "integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==", "hasInstallScript": true, "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -10985,7 +10963,6 @@ "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.0.tgz", "integrity": "sha512-frhu5b8/m/VvaMWz48V8RxcsXnE3hrlErQ5chr21MzAeDCpY4X14sQjvm+jvu3aOI+7Cz2atdRpyhhIuqxVaXg==", "license": "MIT", - "peer": true, "dependencies": { "react-native-is-edge-to-edge": "1.2.1", "semver": "7.7.3" @@ -11025,7 +11002,6 @@ "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz", "integrity": "sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg==", "license": "MIT", - "peer": true, "peerDependencies": { "react": "*", "react-native": "*" @@ -11036,7 +11012,6 @@ "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.18.0.tgz", "integrity": "sha512-mRTLWL7Uc1p/RFNveEIIrhP22oxHduC2ZnLr/2iHwBeYpGXR0rJZ7Bgc0ktxQSHRjWTPT70qc/7yd4r9960PBQ==", "license": "MIT", - "peer": true, "dependencies": { "react-freeze": "^1.0.0", "warn-once": "^0.1.0" @@ -11051,7 +11026,6 @@ "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.1.tgz", "integrity": "sha512-ZUD1xwc3Hwo4cOmOLumjJVoc7lEf9oQFlHnLmgccLC19fNm6LVEdtB+Cnip6gEi0PG3wfvVzskViEtrySQP8Fw==", "license": "MIT", - "peer": true, "dependencies": { "css-select": "^5.1.0", "css-tree": "^1.1.3", @@ -11280,7 +11254,6 @@ "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz", "integrity": "sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==", "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.18.6", "@react-native/normalize-colors": "^0.74.1", @@ -11537,7 +11510,6 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -13004,24 +12976,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tldts": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.19.tgz", - "integrity": "sha512-8PWx8tvC4jDB39BQw1m4x8y5MH1BcQ5xHeL2n7UVFulMPH/3Q0uiamahFJ3lXA0zO2SUyRXuVVbWSDmstlt9YA==", - "license": "MIT", - "dependencies": { - "tldts-core": "^7.0.19" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "7.0.19", - "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.19.tgz", - "integrity": "sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==", - "license": "MIT" - }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -13076,19 +13030,6 @@ "url": "https://github.com/sponsors/Borewit" } }, - "node_modules/tough-cookie": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", - "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "tldts": "^7.0.5" - }, - "engines": { - "node": ">=16" - } - }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -13163,9 +13104,8 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "devOptional": true, + "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/src/assets/splash-icon-new.png b/src/assets/splash-icon-new.png index 07445d3e..8699fcd1 100644 Binary files a/src/assets/splash-icon-new.png and b/src/assets/splash-icon-new.png differ diff --git a/src/components/home/AppleTVHero.tsx b/src/components/home/AppleTVHero.tsx index 1f257307..7de8e8b0 100644 --- a/src/components/home/AppleTVHero.tsx +++ b/src/components/home/AppleTVHero.tsx @@ -30,6 +30,7 @@ import Animated, { Extrapolation, useAnimatedScrollHandler, SharedValue, + useAnimatedReaction, } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { StreamingContent } from '../../services/catalogService'; @@ -163,10 +164,25 @@ const AppleTVHero: React.FC = ({ const [shouldResume, setShouldResume] = useState(false); const [type, setType] = useState<'movie' | 'series'>('movie'); - // Create internal scrollY if not provided externally + // Shared value for scroll position (for parallax effects) const internalScrollY = useSharedValue(0); const scrollY = externalScrollY || internalScrollY; + const [isOutOfView, setIsOutOfView] = useState(false); + + // Track if hero is in view + useAnimatedReaction( + () => scrollY.value, + (currentScrollY) => { + // If hero is more than 80% scrolled out of view, consider it out of view + const outOfView = currentScrollY > HERO_HEIGHT * 0.8; + if (outOfView !== isOutOfView) { + runOnJS(setIsOutOfView)(outOfView); + } + }, + [isOutOfView] + ); + // Determine items to display const items = useMemo(() => { if (allFeaturedContent && allFeaturedContent.length > 0) { @@ -354,9 +370,9 @@ const AppleTVHero: React.FC = ({ } }, [currentItem, loading, heroOpacity]); - // Stop trailer when screen loses focus + // Stop trailer when screen loses focus or scrolled out of view useEffect(() => { - if (!isFocused) { + if (!isFocused || isOutOfView) { // Pause this screen's trailer setTrailerShouldBePaused(true); setTrailerPlaying(false); @@ -365,20 +381,24 @@ const AppleTVHero: React.FC = ({ trailerOpacity.value = withTiming(0, { duration: 300 }); thumbnailOpacity.value = withTiming(1, { duration: 300 }); - logger.info('[AppleTVHero] Screen lost focus - pausing trailer'); + if (!isFocused) { + logger.info('[AppleTVHero] Screen lost focus - pausing trailer'); + } else { + logger.info('[AppleTVHero] Scrolled out of view - pausing trailer'); + } } else { - // Screen gained focus - allow trailer to resume if it was ready + // Screen gained focus and is in view - allow trailer to resume if it was ready setTrailerShouldBePaused(false); // If trailer was ready and loaded, restore the video opacity if (trailerReady && trailerUrl) { - logger.info('[AppleTVHero] Screen gained focus - restoring trailer'); + logger.info('[AppleTVHero] Screen in focus and in view - restoring trailer'); thumbnailOpacity.value = withTiming(0, { duration: 800 }); trailerOpacity.value = withTiming(1, { duration: 800 }); setTrailerPlaying(true); } } - }, [isFocused, setTrailerPlaying, trailerOpacity, thumbnailOpacity, trailerReady, trailerUrl]); + }, [isFocused, isOutOfView, setTrailerPlaying, trailerOpacity, thumbnailOpacity, trailerReady, trailerUrl]); // Listen to navigation events to stop trailer when navigating to other screens useEffect(() => { @@ -714,7 +734,7 @@ const AppleTVHero: React.FC = ({ updateSetting('trailerMuted', !trailerMuted); }, [trailerMuted, updateSetting]); - // Auto-advance timer - PAUSE when trailer is playing + // Auto-advance timer - PAUSE when trailer is playing or out of view const startAutoPlay = useCallback(() => { if (autoPlayTimerRef.current) { clearTimeout(autoPlayTimerRef.current); @@ -722,16 +742,20 @@ const AppleTVHero: React.FC = ({ if (items.length <= 1) return; - // Don't auto-advance if trailer is playing - if (globalTrailerPlaying && trailerReady) { - logger.info('[AppleTVHero] Auto-rotation paused - trailer is playing'); + // Don't auto-advance if trailer is playing or out of view + if ((globalTrailerPlaying && trailerReady) || isOutOfView) { + if (isOutOfView) { + logger.info('[AppleTVHero] Auto-rotation paused - out of view'); + } else { + logger.info('[AppleTVHero] Auto-rotation paused - trailer is playing'); + } return; } autoPlayTimerRef.current = setTimeout(() => { const timeSinceInteraction = Date.now() - lastInteractionRef.current; // Only auto-advance if user hasn't interacted recently (5 seconds) and no trailer playing - if (timeSinceInteraction >= 5000 && (!globalTrailerPlaying || !trailerReady)) { + if (timeSinceInteraction >= 5000 && (!globalTrailerPlaying || !trailerReady) && !isOutOfView) { // Set next index preview for crossfade const nextIdx = (currentIndex + 1) % items.length; setNextIndex(nextIdx); @@ -757,7 +781,7 @@ const AppleTVHero: React.FC = ({ startAutoPlay(); } }, 25000); // Auto-advance every 25 seconds - }, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress]); + }, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress, isOutOfView]); useEffect(() => { startAutoPlay(); diff --git a/src/components/home/ContinueWatchingSection.tsx b/src/components/home/ContinueWatchingSection.tsx index 104f4d9f..62bdd020 100644 --- a/src/components/home/ContinueWatchingSection.tsx +++ b/src/components/home/ContinueWatchingSection.tsx @@ -119,6 +119,7 @@ const ContinueWatchingSection = React.forwardRef((props, re const [loading, setLoading] = useState(true); const appState = useRef(AppState.currentState); const refreshTimerRef = useRef(null); + const pendingRefreshRef = useRef(false); const [deletingItemId, setDeletingItemId] = useState(null); const longPressTimeoutRef = useRef(null); @@ -326,6 +327,7 @@ const ContinueWatchingSection = React.forwardRef((props, re // Modified loadContinueWatching to render incrementally const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { if (isRefreshingRef.current) { + pendingRefreshRef.current = true; return; } @@ -368,6 +370,20 @@ const ContinueWatchingSection = React.forwardRef((props, re return candidateProgress > existingProgress; }; + const compareCwItems = (a: ContinueWatchingItem, b: ContinueWatchingItem): number => { + const aProgress = a.progress ?? 0; + const bProgress = b.progress ?? 0; + const aIsUpNext = a.type === 'series' && aProgress <= 0; + const bIsUpNext = b.type === 'series' && bProgress <= 0; + + // Keep active in-progress items ahead of "Up Next" placeholders. + if (aIsUpNext !== bIsUpNext) { + return aIsUpNext ? 1 : -1; + } + + return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0); + }; + type LocalProgressEntry = { episodeId?: string; season?: number; @@ -466,7 +482,7 @@ const ContinueWatchingSection = React.forwardRef((props, re } const merged = Array.from(map.values()); - merged.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + merged.sort(compareCwItems); return merged; }); @@ -1272,7 +1288,7 @@ const ContinueWatchingSection = React.forwardRef((props, re }); // Sort by lastUpdated descending and set directly - adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + adjustedItems.sort(compareCwItems); // Debug final order (only if changed) try { @@ -1515,7 +1531,7 @@ const ContinueWatchingSection = React.forwardRef((props, re return it; }); - adjustedItems.sort((a, b) => (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0)); + adjustedItems.sort(compareCwItems); setContinueWatchingItems(adjustedItems); } catch (err) { logger.error('[SimklSync] Error in Simkl merge:', err); @@ -1529,6 +1545,12 @@ const ContinueWatchingSection = React.forwardRef((props, re } finally { setLoading(false); isRefreshingRef.current = false; + if (pendingRefreshRef.current) { + pendingRefreshRef.current = false; + setTimeout(() => { + loadContinueWatching(true); + }, 0); + } } }, [getCachedMetadata]); @@ -1602,6 +1624,13 @@ const ContinueWatchingSection = React.forwardRef((props, re // Initial load useEffect(() => { loadContinueWatching(); + const trailingRefreshId = setTimeout(() => { + loadContinueWatching(true); + }, 4000); + + return () => { + clearTimeout(trailingRefreshId); + }; }, [loadContinueWatching]); // Refresh on screen focus (lightweight, no polling) @@ -1879,7 +1908,8 @@ const ContinueWatchingSection = React.forwardRef((props, re }, [computedPosterWidth]); // Memoized render function for poster-style continue watching items - const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( + const renderPosterStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { + return ( ((props, re )} - ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]); + ); + }, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedPosterWidth, computedPosterHeight, isTV, isLargeTablet, settings.posterBorderRadius]); // Memoized render function for wide-style continue watching items - const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => ( + const renderWideStyleItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { + return ( ((props, re )} - ), [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius]); + ); + }, [currentTheme.colors, handleContentPress, handleLongPress, deletingItemId, computedItemWidth, computedItemHeight, isTV, isLargeTablet, isTablet, settings.posterBorderRadius, t]); // Choose the appropriate render function based on settings const renderContinueWatchingItem = useCallback(({ item }: { item: ContinueWatchingItem }) => { @@ -2190,7 +2223,14 @@ const ContinueWatchingSection = React.forwardRef((props, re (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0))} + data={[...continueWatchingItems].sort((a, b) => { + const aProgress = a.progress ?? 0; + const bProgress = b.progress ?? 0; + const aIsUpNext = a.type === 'series' && aProgress <= 0; + const bIsUpNext = b.type === 'series' && bProgress <= 0; + if (aIsUpNext !== bIsUpNext) return aIsUpNext ? 1 : -1; + return (b.lastUpdated ?? 0) - (a.lastUpdated ?? 0); + })} renderItem={renderContinueWatchingItem} keyExtractor={keyExtractor} horizontal diff --git a/src/components/metadata/.HeroSection.tsx.swp b/src/components/metadata/.HeroSection.tsx.swp deleted file mode 100644 index 6a8c4627..00000000 Binary files a/src/components/metadata/.HeroSection.tsx.swp and /dev/null differ diff --git a/src/components/player/AndroidVideoPlayer.tsx b/src/components/player/AndroidVideoPlayer.tsx index 12a843e3..331937f3 100644 --- a/src/components/player/AndroidVideoPlayer.tsx +++ b/src/components/player/AndroidVideoPlayer.tsx @@ -11,7 +11,8 @@ import { usePlayerModals, useSpeedControl, useOpeningAnimation, - useWatchProgress + useWatchProgress, + useSkipSegments } from './hooks'; // Android-specific hooks @@ -222,6 +223,16 @@ const AndroidVideoPlayer: React.FC = () => { const nextEpisodeHook = useNextEpisode(type, season, episode, groupedEpisodes, (metadataResult as any)?.groupedEpisodes, episodeId); + const { segments: skipIntervals, outroSegment } = useSkipSegments({ + imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), + type, + season, + episode, + malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, + kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + enabled: settings.skipIntroEnabled + }); + const fadeAnim = useRef(new Animated.Value(1)).current; useEffect(() => { @@ -975,6 +986,7 @@ 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} + skipIntervals={skipIntervals} currentTime={playerState.currentTime} onSkip={(endTime) => controlsHook.seekToTime(endTime)} controlsVisible={playerState.showControls} @@ -1002,6 +1014,7 @@ const AndroidVideoPlayer: React.FC = () => { metadata={metadataResult?.metadata ? { poster: metadataResult.metadata.poster, id: metadataResult.metadata.id } : undefined} controlsVisible={playerState.showControls} controlsFixedOffset={100} + outroSegment={outroSegment} /> diff --git a/src/components/player/KSPlayerCore.tsx b/src/components/player/KSPlayerCore.tsx index b6ca3d47..231d71d8 100644 --- a/src/components/player/KSPlayerCore.tsx +++ b/src/components/player/KSPlayerCore.tsx @@ -36,7 +36,8 @@ import { usePlayerControls, usePlayerSetup, useWatchProgress, - useNextEpisode + useNextEpisode, + useSkipSegments } from './hooks'; // Platform-specific hooks @@ -209,6 +210,16 @@ const KSPlayerCore: React.FC = () => { episodeId }); + const { segments: skipIntervals, outroSegment } = useSkipSegments({ + imdbId: imdbId || (id?.startsWith('tt') ? id : undefined), + type, + season, + episode, + malId: (metadata as any)?.mal_id || (metadata as any)?.external_ids?.mal_id, + kitsuId: id?.startsWith('kitsu:') ? id.split(':')[1] : undefined, + enabled: settings.skipIntroEnabled + }); + const controls = usePlayerControls({ playerRef: ksPlayerRef, paused, @@ -945,6 +956,7 @@ 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} + skipIntervals={skipIntervals} currentTime={currentTime} onSkip={(endTime) => controls.seekToTime(endTime)} controlsVisible={showControls} @@ -972,6 +984,7 @@ const KSPlayerCore: React.FC = () => { metadata={metadata ? { poster: metadata.poster, id: metadata.id } : undefined} controlsVisible={showControls} controlsFixedOffset={126} + outroSegment={outroSegment} /> {/* Modals */} @@ -1102,4 +1115,4 @@ const KSPlayerCore: React.FC = () => { ); }; -export default KSPlayerCore; \ No newline at end of file +export default KSPlayerCore; diff --git a/src/components/player/common/UpNextButton.tsx b/src/components/player/common/UpNextButton.tsx index 9958e0c6..77f44416 100644 --- a/src/components/player/common/UpNextButton.tsx +++ b/src/components/player/common/UpNextButton.tsx @@ -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 { SkipInterval } 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; + outroSegment?: SkipInterval | null; } const UpNextButton: React.FC = ({ @@ -49,6 +51,7 @@ const UpNextButton: React.FC = ({ metadata, controlsVisible = false, controlsFixedOffset = 100, + outroSegment, }) => { const [visible, setVisible] = useState(false); const opacity = useRef(new Animated.Value(0)).current; @@ -76,10 +79,20 @@ const UpNextButton: React.FC = ({ const shouldShow = useMemo(() => { if (!nextEpisode || duration <= 0) return false; + + // 1. Determine if we have a valid ending outro (within last 5 mins) + const hasValidEndingOutro = outroSegment && (duration - outroSegment.endTime < 300); + + if (hasValidEndingOutro) { + // If we have a valid outro, ONLY show after it finishes + // This prevents the 60s fallback from "jumping the gun" + return currentTime >= outroSegment.endTime; + } + + // 2. Standard Fallback (only if no valid ending outro was found) 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]); + return timeRemaining < 61 && timeRemaining > 0; + }, [nextEpisode, duration, currentTime, outroSegment]); // Debug logging removed to reduce console noise // The state is computed in shouldShow useMemo above diff --git a/src/components/player/controls/PlayerControls.tsx b/src/components/player/controls/PlayerControls.tsx index 29861710..18084de0 100644 --- a/src/components/player/controls/PlayerControls.tsx +++ b/src/components/player/controls/PlayerControls.tsx @@ -12,6 +12,14 @@ import { useSettings } from '../../../hooks/useSettings'; import { introService } from '../../../services/introService'; import { toastService } from '../../../services/toastService'; +import PlayerAspectRatioIcon from '../../../../assets/player-icons/ic_player_aspect_ratio.svg'; +import PlayerAudioFilledIcon from '../../../../assets/player-icons/ic_player_audio_filled.svg'; +import PlayerAudioOutlineIcon from '../../../../assets/player-icons/ic_player_audio_outline.svg'; +import PlayerEpisodesIcon from '../../../../assets/player-icons/ic_player_episodes.svg'; +import PlayerPauseIcon from '../../../../assets/player-icons/ic_player_pause.svg'; +import PlayerPlayIcon from '../../../../assets/player-icons/ic_player_play.svg'; +import PlayerSourceIcon from '../../../../assets/player-icons/ic_player_source.svg'; +import PlayerSubtitlesIcon from '../../../../assets/player-icons/ic_player_subtitles.svg'; interface PlayerControlsProps { showControls: boolean; @@ -498,11 +506,11 @@ export const PlayerControls: React.FC = ({ {isBuffering ? ( ) : ( - + paused ? ( + + ) : ( + + ) )} @@ -594,7 +602,7 @@ export const PlayerControls: React.FC = ({ {/* Left Side: Aspect Ratio Button */} - + {/* Subtitle Button */} @@ -602,7 +610,7 @@ export const PlayerControls: React.FC = ({ style={styles.iconButton} onPress={() => setShowSubtitleModal(!isSubtitleModalOpen)} > - + {/* Change Source Button */} @@ -611,7 +619,7 @@ export const PlayerControls: React.FC = ({ style={styles.iconButton} onPress={() => setShowSourcesModal(true)} > - + )} @@ -626,11 +634,11 @@ export const PlayerControls: React.FC = ({ onPress={() => setShowAudioModal(true)} disabled={ksAudioTracks.length <= 1} > - + {ksAudioTracks.length <= 1 ? ( + + ) : ( + + )} {/* Submit Intro Button */} @@ -653,7 +661,7 @@ export const PlayerControls: React.FC = ({ style={styles.iconButton} onPress={() => setShowEpisodesModal(true)} > - + )} diff --git a/src/components/player/hooks/index.ts b/src/components/player/hooks/index.ts index 570d3de1..8041cb78 100644 --- a/src/components/player/hooks/index.ts +++ b/src/components/player/hooks/index.ts @@ -20,3 +20,4 @@ export { usePlayerSetup } from './usePlayerSetup'; // Content export { useNextEpisode } from './useNextEpisode'; export { useWatchProgress } from './useWatchProgress'; +export { useSkipSegments } from './useSkipSegments'; diff --git a/src/components/player/hooks/useSkipSegments.ts b/src/components/player/hooks/useSkipSegments.ts new file mode 100644 index 00000000..3a81e86d --- /dev/null +++ b/src/components/player/hooks/useSkipSegments.ts @@ -0,0 +1,100 @@ +import { useState, useEffect, useRef } from 'react'; +import { introService, SkipInterval } from '../../../services/introService'; +import { logger } from '../../../utils/logger'; + +interface UseSkipSegmentsProps { + imdbId?: string; + type?: string; + season?: number; + episode?: number; + malId?: string; + kitsuId?: string; + enabled: boolean; +} + +export const useSkipSegments = ({ + imdbId, + type, + season, + episode, + malId, + kitsuId, + enabled +}: UseSkipSegmentsProps) => { + const [segments, setSegments] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const fetchedRef = useRef(false); + const lastKeyRef = useRef(''); + + useEffect(() => { + const key = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`; + + if (!enabled || type !== 'series' || (!imdbId && !malId && !kitsuId) || !season || !episode) { + setSegments([]); + setIsLoading(false); + fetchedRef.current = false; + lastKeyRef.current = ''; + return; + } + + if (lastKeyRef.current === key && fetchedRef.current) { + return; + } + + // Clear stale intervals while resolving a new episode/key. + if (lastKeyRef.current !== key) { + setSegments([]); + fetchedRef.current = false; + } + + lastKeyRef.current = key; + setIsLoading(true); + let cancelled = false; + + const fetchSegments = async () => { + try { + const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId); + + // Ignore stale responses from old requests. + if (cancelled || lastKeyRef.current !== key) return; + setSegments(intervals); + fetchedRef.current = true; + } catch (error) { + if (cancelled || lastKeyRef.current !== key) return; + logger.error('[useSkipSegments] Error fetching skip data:', error); + setSegments([]); + // Keep this key retryable on transient failures. + fetchedRef.current = false; + } finally { + if (cancelled || lastKeyRef.current !== key) return; + setIsLoading(false); + } + }; + + fetchSegments(); + + return () => { + cancelled = true; + }; + }, [imdbId, type, season, episode, malId, kitsuId, enabled]); + + const getActiveSegment = (currentTime: number) => { + return segments.find( + s => currentTime >= s.startTime && currentTime < (s.endTime - 0.5) + ); + }; + + const outroSegment = segments + .filter(s => ['ed', 'outro', 'mixed-ed'].includes(s.type)) + .reduce((latest, interval) => { + if (!latest || interval.endTime > latest.endTime) return interval; + return latest; + }, null); + + return { + segments, + getActiveSegment, + outroSegment, + isLoading + }; +}; diff --git a/src/components/player/modals/SubmitIntroModal.tsx b/src/components/player/modals/SubmitIntroModal.tsx index 42873ba0..11afaa7c 100644 --- a/src/components/player/modals/SubmitIntroModal.tsx +++ b/src/components/player/modals/SubmitIntroModal.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator } from 'react-native'; +import { View, Text, TouchableOpacity, useWindowDimensions, StyleSheet, TextInput, ActivityIndicator, ScrollView } from 'react-native'; import { Ionicons, MaterialIcons } from '@expo/vector-icons'; import { useTranslation } from 'react-i18next'; import Animated, { @@ -9,7 +9,7 @@ import Animated, { SlideOutDown, } from 'react-native-reanimated'; import { useSettings } from '../../../hooks/useSettings'; -import { introService } from '../../../services/introService'; +import { introService, SkipType } from '../../../services/introService'; import { toastService } from '../../../services/toastService'; interface SubmitIntroModalProps { @@ -67,6 +67,7 @@ export const SubmitIntroModal: React.FC = ({ const [startTimeStr, setStartTimeStr] = useState('00:00'); const [endTimeStr, setEndTimeStr] = useState(formatSecondsToMMSS(currentTime)); + const [segmentType, setSegmentType] = useState('intro'); const [isSubmitting, setIsSubmitting] = useState(false); useEffect(() => { @@ -107,14 +108,15 @@ export const SubmitIntroModal: React.FC = ({ season, episode, startSec, - endSec + endSec, + segmentType ); if (success) { - toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Intro submitted successfully' })); + toastService.success(t('player_ui.intro_submitted', { defaultValue: 'Segment submitted successfully' })); onClose(); } else { - toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit intro' })); + toastService.error(t('player_ui.intro_submit_failed', { defaultValue: 'Failed to submit segment' })); } } catch (error) { toastService.error('Error', 'An unexpected error occurred'); @@ -123,9 +125,11 @@ export const SubmitIntroModal: React.FC = ({ } }; - const startVal = parseTimeToSeconds(startTimeStr); - const endVal = parseTimeToSeconds(endTimeStr); - const durationSec = (startVal !== null && endVal !== null) ? endVal - startVal : 0; + const segmentTypes: { label: string; value: SkipType; icon: any }[] = [ + { label: 'Intro', value: 'intro', icon: 'play-circle-outline' }, + { label: 'Recap', value: 'recap', icon: 'replay' }, + { label: 'Outro', value: 'outro', icon: 'stop-circle' }, + ]; return ( @@ -144,13 +148,42 @@ export const SubmitIntroModal: React.FC = ({ style={[localStyles.modalContainer, { width: Math.min(width * 0.85, 380) }]} > - Submit Intro Timestamp + Submit Timestamps - + + {/* Segment Type Selector */} + + Segment Type + + {segmentTypes.map((type) => ( + setSegmentType(type.value)} + style={[ + localStyles.typeButton, + segmentType === type.value && localStyles.typeButtonActive + ]} + > + + + {type.label} + + + ))} + + + {/* Start Time Input */} @@ -214,7 +247,7 @@ export const SubmitIntroModal: React.FC = ({ )} - + @@ -239,6 +272,7 @@ const localStyles = StyleSheet.create({ shadowOpacity: 0.5, shadowRadius: 15, elevation: 20, + maxHeight: '80%', }, header: { flexDirection: 'row', @@ -257,6 +291,34 @@ const localStyles = StyleSheet.create({ content: { gap: 20, }, + typeRow: { + flexDirection: 'row', + gap: 8, + }, + typeButton: { + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + backgroundColor: 'rgba(255,255,255,0.05)', + borderRadius: 12, + paddingVertical: 10, + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + }, + typeButtonActive: { + backgroundColor: 'white', + borderColor: 'white', + }, + typeButtonText: { + color: 'rgba(255,255,255,0.6)', + fontSize: 13, + fontWeight: '600', + }, + typeButtonTextActive: { + color: 'black', + }, inputRow: { flexDirection: 'row', alignItems: 'flex-end', @@ -295,22 +357,6 @@ const localStyles = StyleSheet.create({ fontSize: 13, fontWeight: '600', }, - summaryBox: { - backgroundColor: 'rgba(255,255,255,0.03)', - borderRadius: 16, - padding: 16, - marginTop: 8, - }, - summaryText: { - color: 'rgba(255,255,255,0.5)', - fontSize: 14, - marginBottom: 4, - }, - hintText: { - color: 'rgba(255,255,255,0.3)', - fontSize: 12, - fontStyle: 'italic', - }, buttonRow: { flexDirection: 'row', gap: 12, @@ -345,3 +391,4 @@ const localStyles = StyleSheet.create({ fontWeight: '700', }, }); + diff --git a/src/components/player/overlays/SkipIntroButton.tsx b/src/components/player/overlays/SkipIntroButton.tsx index b5329c6a..67f72173 100644 --- a/src/components/player/overlays/SkipIntroButton.tsx +++ b/src/components/player/overlays/SkipIntroButton.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; -import { Text, TouchableOpacity, StyleSheet, Platform } from 'react-native'; +import { Text, TouchableOpacity, StyleSheet, Platform, View } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, @@ -10,10 +10,11 @@ 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 { SkipInterval } from '../../../services/introService'; import { useTheme } from '../../../contexts/ThemeContext'; import { logger } from '../../../utils/logger'; import { useSettings } from '../../../hooks/useSettings'; +import { useSkipSegments } from '../hooks/useSkipSegments'; interface SkipIntroButtonProps { imdbId: string | undefined; @@ -22,6 +23,7 @@ interface SkipIntroButtonProps { episode?: number; malId?: string; kitsuId?: string; + skipIntervals?: SkipInterval[] | null; currentTime: number; onSkip: (endTime: number) => void; controlsVisible?: boolean; @@ -35,6 +37,7 @@ export const SkipIntroButton: React.FC = ({ episode, malId, kitsuId, + skipIntervals: externalSkipIntervals, currentTime, onSkip, controlsVisible = false, @@ -46,16 +49,25 @@ export const SkipIntroButton: React.FC = ({ const skipIntroEnabled = settings.skipIntroEnabled; + const { segments: fetchedSkipIntervals } = useSkipSegments({ + imdbId, + type, + season, + episode, + malId, + kitsuId, + // Allow parent components to provide pre-fetched intervals to avoid duplicate requests. + enabled: skipIntroEnabled && !externalSkipIntervals + }); + const skipIntervals = externalSkipIntervals ?? fetchedSkipIntervals; + // State - const [skipIntervals, setSkipIntervals] = useState([]); const [currentInterval, setCurrentInterval] = useState(null); const [isVisible, setIsVisible] = useState(false); const [hasSkippedCurrent, setHasSkippedCurrent] = useState(false); const [autoHidden, setAutoHidden] = useState(false); // Refs - const fetchedRef = useRef(false); - const lastEpisodeRef = useRef(''); const autoHideTimerRef = useRef(null); // Animation values @@ -63,55 +75,11 @@ export const SkipIntroButton: React.FC = ({ const scale = useSharedValue(0.8); const translateY = useSharedValue(0); - // Fetch skip data when episode changes + // Reset skipped state when episode changes useEffect(() => { - const episodeKey = `${imdbId}-${season}-${episode}-${malId}-${kitsuId}`; - - if (!skipIntroEnabled) { - setSkipIntervals([]); - setCurrentInterval(null); - setIsVisible(false); - fetchedRef.current = false; - 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) { - setSkipIntervals([]); - fetchedRef.current = false; - return; - } - - // Skip if already fetched for this episode - if (lastEpisodeRef.current === episodeKey && fetchedRef.current) { - return; - } - - lastEpisodeRef.current = episodeKey; - fetchedRef.current = true; setHasSkippedCurrent(false); setAutoHidden(false); - setSkipIntervals([]); - - const fetchSkipData = async () => { - logger.log(`[SkipIntroButton] Fetching skip data for S${season}E${episode} (IMDB: ${imdbId}, MAL: ${malId}, Kitsu: ${kitsuId})...`); - try { - const intervals = await introService.getSkipTimes(imdbId, season, episode, malId, kitsuId); - setSkipIntervals(intervals); - - if (intervals.length > 0) { - logger.log(`[SkipIntroButton] ✓ Found ${intervals.length} skip intervals:`, intervals); - } else { - logger.log(`[SkipIntroButton] ✗ No skip data available for this episode`); - } - } catch (error) { - logger.error('[SkipIntroButton] Error fetching skip data:', error); - setSkipIntervals([]); - } - }; - - fetchSkipData(); - }, [imdbId, type, season, episode, malId, kitsuId, skipIntroEnabled]); + }, [imdbId, season, episode, malId, kitsuId]); // Determine active interval based on current playback position useEffect(() => { @@ -278,7 +246,7 @@ export const SkipIntroButton: React.FC = ({ style={styles.icon} /> {getButtonText()} - = ({ child user, loading, signIn: async (email: string, password: string) => { - const { error } = await accountService.signInWithEmail(email, password); + const { user: signedInUser, error } = await accountService.signInWithEmail(email, password); + if (!error && signedInUser) { + setUser(signedInUser); + } return error || null; }, signUp: async (email: string, password: string) => { - const { error } = await accountService.signUpWithEmail(email, password); + const { user: signedUpUser, error } = await accountService.signUpWithEmail(email, password); + if (!error && signedUpUser) { + setUser(signedUpUser); + } return error || null; }, signOut: async () => { @@ -107,4 +113,3 @@ export const useAccount = (): AccountContextValue => { }; export default AccountContext; - diff --git a/src/hooks/useMetadata.ts b/src/hooks/useMetadata.ts index b0d1b5c6..48cbdd72 100644 --- a/src/hooks/useMetadata.ts +++ b/src/hooks/useMetadata.ts @@ -1486,10 +1486,10 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat setActiveFetchingScrapers([]); setAddonResponseOrder([]); // Reset response order - // Get TMDB ID for external sources and determine the correct ID for Stremio addons if (__DEV__) console.log('🔍 [loadStreams] Getting TMDB ID for:', id); let tmdbId; - let stremioId = id; // Default to original ID + let stremioId = id; + let effectiveStreamType: string = type; if (id.startsWith('tmdb:')) { tmdbId = id.split(':')[1]; @@ -1544,56 +1544,66 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const allStremioAddons = await stremioService.getInstalledAddons(); const localScrapers = await localScraperService.getInstalledScrapers(); - // Map app-level "tv" type to Stremio "series" for addon capability checks - const stremioType = type === 'tv' ? 'series' : type; + const requestedStreamType = type; - // Filter Stremio addons to only include those that provide streams for this content type - const streamAddons = allStremioAddons.filter(addon => { - if (!addon.resources || !Array.isArray(addon.resources)) { - return false; - } + const pickEligibleStreamAddons = (requestType: string) => + allStremioAddons.filter(addon => { + if (!addon.resources || !Array.isArray(addon.resources)) { + return false; + } - let hasStreamResource = false; - let supportsIdPrefix = false; + let hasStreamResource = false; + let supportsIdPrefix = false; - for (const resource of addon.resources) { - // Check if the current element is a ResourceObject - if (typeof resource === 'object' && resource !== null && 'name' in resource) { - const typedResource = resource as any; - if (typedResource.name === 'stream' && - Array.isArray(typedResource.types) && - typedResource.types.includes(stremioType)) { - hasStreamResource = true; + for (const resource of addon.resources) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as any; + if (typedResource.name === 'stream' && + Array.isArray(typedResource.types) && + typedResource.types.includes(requestType)) { + hasStreamResource = true; - // Check if this addon supports the ID prefix generically: any prefix must match start of id - if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { - supportsIdPrefix = typedResource.idPrefixes.some((p: string) => id.startsWith(p)); - } else { - // If no idPrefixes specified, assume it supports all prefixes - supportsIdPrefix = true; + if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { + supportsIdPrefix = typedResource.idPrefixes.some((p: string) => stremioId.startsWith(p)); + } else { + supportsIdPrefix = true; + } + break; + } + } else if (typeof resource === 'string' && resource === 'stream' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes(requestType)) { + hasStreamResource = true; + if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { + supportsIdPrefix = addon.idPrefixes.some((p: string) => stremioId.startsWith(p)); + } else { + supportsIdPrefix = true; + } + break; } - break; } } - // Check if the element is the simple string "stream" AND the addon has a top-level types array - else if (typeof resource === 'string' && resource === 'stream' && addon.types) { - if (Array.isArray(addon.types) && addon.types.includes(stremioType)) { - hasStreamResource = true; - // For simple string resources, check addon-level idPrefixes generically - if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { - supportsIdPrefix = addon.idPrefixes.some((p: string) => id.startsWith(p)); - } else { - // If no idPrefixes specified, assume it supports all prefixes - supportsIdPrefix = true; - } - break; - } + + return hasStreamResource && supportsIdPrefix; + }); + + effectiveStreamType = requestedStreamType; + let eligibleStreamAddons = pickEligibleStreamAddons(requestedStreamType); + + if (eligibleStreamAddons.length === 0) { + const fallbackTypes = ['series', 'movie'].filter(t => t !== requestedStreamType); + for (const fallbackType of fallbackTypes) { + const fallback = pickEligibleStreamAddons(fallbackType); + if (fallback.length > 0) { + effectiveStreamType = fallbackType; + eligibleStreamAddons = fallback; + if (__DEV__) console.log(`[useMetadata.loadStreams] No addons for '${requestedStreamType}', falling back to '${fallbackType}'`); + break; } } + } - return hasStreamResource && supportsIdPrefix; - }); - if (__DEV__) console.log('[useMetadata.loadStreams] Eligible stream addons:', streamAddons.map(a => a.id)); + const streamAddons = eligibleStreamAddons; + if (__DEV__) console.log('[useMetadata.loadStreams] Eligible stream addons:', streamAddons.map(a => a.id), { requestedStreamType, effectiveStreamType }); // Initialize scraper statuses for tracking const initialStatuses: ScraperStatus[] = []; @@ -1645,9 +1655,9 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Start Stremio request using the converted ID format if (__DEV__) console.log('🎬 [loadStreams] Using ID for Stremio addons:', stremioId); - // Map app-level "tv" type to Stremio "series" when requesting streams - const stremioContentType = type === 'tv' ? 'series' : type; - processStremioSource(stremioContentType, stremioId, false); + // Use the effective type we selected when building the eligible addon list. + // This stays aligned with Stremio manifest filtering rules and avoids hard-mapping non-standard types. + processStremioSource(effectiveStreamType, stremioId, false); // Also extract any embedded streams from metadata (PPV-style addons) extractEmbeddedStreams(); @@ -1707,36 +1717,41 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat const allStremioAddons = await stremioService.getInstalledAddons(); const localScrapers = await localScraperService.getInstalledScrapers(); - // Filter Stremio addons to only include those that provide streams for series content - const streamAddons = allStremioAddons.filter(addon => { - if (!addon.resources || !Array.isArray(addon.resources)) { + // We don't yet know the final episode ID format here (it can be normalized later), + // but we can still pre-filter by stream capability for the most likely types. + const pickStreamCapableAddons = (requestType: string) => + allStremioAddons.filter(addon => { + if (!addon.resources || !Array.isArray(addon.resources)) return false; + + for (const resource of addon.resources) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as any; + if (typedResource.name === 'stream' && Array.isArray(typedResource.types) && typedResource.types.includes(requestType)) { + return true; + } + } else if (typeof resource === 'string' && resource === 'stream' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes(requestType)) { + return true; + } + } + } return false; - } + }); - let hasStreamResource = false; - - for (const resource of addon.resources) { - // Check if the current element is a ResourceObject - if (typeof resource === 'object' && resource !== null && 'name' in resource) { - const typedResource = resource as any; - if (typedResource.name === 'stream' && - Array.isArray(typedResource.types) && - typedResource.types.includes('series')) { - hasStreamResource = true; - break; - } - } - // Check if the element is the simple string "stream" AND the addon has a top-level types array - else if (typeof resource === 'string' && resource === 'stream' && addon.types) { - if (Array.isArray(addon.types) && addon.types.includes('series')) { - hasStreamResource = true; - break; - } + const requestedEpisodeType = type; + let streamAddons = pickStreamCapableAddons(requestedEpisodeType); + + if (streamAddons.length === 0) { + const fallbackTypes = ['series', 'movie'].filter(t => t !== requestedEpisodeType); + for (const fallbackType of fallbackTypes) { + const fallback = pickStreamCapableAddons(fallbackType); + if (fallback.length > 0) { + streamAddons = fallback; + if (__DEV__) console.log(`[useMetadata.loadEpisodeStreams] No addons for '${requestedEpisodeType}', falling back to '${fallbackType}'`); + break; } } - - return hasStreamResource; - }); + } // Initialize scraper statuses for tracking const initialStatuses: ScraperStatus[] = []; @@ -1923,10 +1938,8 @@ export const useMetadata = ({ id, type, addonId }: UseMetadataProps): UseMetadat // Start Stremio request using the converted episode ID format if (__DEV__) console.log('🎬 [loadEpisodeStreams] Using episode ID for Stremio addons:', stremioEpisodeId); - // For collections, treat episodes as individual movies, not series - // For other types (e.g. StreamsPPV), preserve the original type unless it's explicitly 'series' logic we want - // Map app-level "tv" type to Stremio "series" for addon stream endpoint - const contentType = isCollection ? 'movie' : (type === 'tv' ? 'series' : type); + const requestedContentType = isCollection ? 'movie' : type; + const contentType = requestedContentType; if (__DEV__) console.log(`🎬 [loadEpisodeStreams] Using content type: ${contentType} for ${isCollection ? 'collection' : type}`); processStremioSource(contentType, stremioEpisodeId, true); diff --git a/src/i18n/locales/ar.json b/src/i18n/locales/ar.json index d11e8c89..cc29d446 100644 --- a/src/i18n/locales/ar.json +++ b/src/i18n/locales/ar.json @@ -908,8 +908,8 @@ }, "debrid": { "title": "تكامل Debrid", - "description_torbox": "افتح بثوث 4K عالية الجودة وسرعات البرق من خلال دمج Torbox. أدخل مفتاح API الخاص بك أدناه لتطوير تجربة البث فوراً.", - "description_torrentio": "قم بتهيئة Torrentio للحصول على بثوث تورنت للأفلام والبرامج التلفزيونية. مطلوب خدمة debrid لبث المحتوى.", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "متصل", @@ -936,15 +936,15 @@ "enter_api_key": "أدخل مفتاح API الخاص بك", "connect_button": "اتصال وتثبيت", "connecting": "جاري الاتصال...", - "unlock_speeds_title": "افتح سرعات مميزة", - "unlock_speeds_desc": "احصل على اشتراك Torbox للوصول إلى بثوث عالية الجودة مخزنة مؤقتاً بدون تقطيع.", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "احصل على اشتراك", "powered_by": "مدعوم بواسطة", "disclaimer_torbox": "Nuvio ليس منتسباً لـ Torbox بأي شكل من الأشكال.", "disclaimer_torrentio": "Nuvio ليس منتسباً لـ Torrentio بأي شكل من الأشكال.", "installed_badge": "✓ تم التثبيت", "promo_title": "⚡ هل تحتاج إلى خدمة Debrid؟", - "promo_desc": "احصل على TorBox للبث السريع بدقة 4K بدون تقطيع. تورنت مميز مخزن مؤقتاً وتنزيلات فورية.", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "احصل على اشتراك TorBox", "service_label": "خدمة Debrid *", "api_key_label": "مفتاح API *", @@ -1336,7 +1336,7 @@ "user_resp_title": "مسؤولية المستخدم", "user_resp_text": "المستخدمون مسؤولون وحدهم عن الامتدادات التي يقومون بتثبيتها والمحتوى الذي يصلون إليه. باستخدام هذا التطبيق، فإنك توافق على ضمان أن لديك الحق القانوني في الوصول إلى أي محتوى تشاهده باستخدام Nuvio. لا يؤيد مطورو Nuvio أو يشجعون انتهاك حقوق الطبع والنشر.", "dmca_title": "حقوق الطبع والنشر و DMCA", - "dmca_text": "نحن نحترم حقوق الملكية الفكرية للآخرين. نظرًا لأن Nuvio لا يستضيف أي محتوى، فلا يمكننا إزالة المحتوى من الإنترنت. ومع ذلك، إذا كنت تعتقد أن واجهة التطبيق نفسها تنتهك حقوقك، فيرجى الاتصال بنا.", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "لا يوجد ضمان", "warranty_text": "يتم توفير هذا البرنامج \"كما هو\"، دون أي ضمان من أي نوع، صريحًا أو ضمنيًا. لا يتحمل المؤلفون أو أصحاب حقوق الطبع والنشر بأي حال من الأحوال المسؤولية عن أي مطالبة أو أضرار أو مسؤولية أخرى تنشأ عن استخدام هذا البرنامج." }, diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 9f8070f0..c9f70d81 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -908,8 +908,8 @@ }, "debrid": { "title": "Debrid Integration", - "description_torbox": "Entsperren Sie 4K-Streams durch Integration von Torbox.", - "description_torrentio": "Konfigurieren Sie Torrentio um Torrent-Streams zu erhalten.", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "Verbunden", @@ -936,15 +936,15 @@ "enter_api_key": "Geben Sie Ihren API-Schlüssel ein", "connect_button": "Verbinden & Installieren", "connecting": "Verbinde...", - "unlock_speeds_title": "Premium-Geschwindigkeiten entsperren", - "unlock_speeds_desc": "Holen Sie sich ein Torbox-Abonnement.", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "Abonnement holen", "powered_by": "Bereitgestellt von", "disclaimer_torbox": "Nuvio ist nicht mit Torbox verbunden.", "disclaimer_torrentio": "Nuvio ist nicht mit Torrentio verbunden.", "installed_badge": "✓ INSTALLIERT", "promo_title": "⚡ Brauchen Sie einen Debrid-Dienst?", - "promo_desc": "Holen Sie sich TorBox für blitzschnelles 4K-Streaming.", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "TorBox-Abonnement holen", "service_label": "Debrid-Dienst *", "api_key_label": "API-Schlüssel *", @@ -1336,7 +1336,7 @@ "user_resp_title": "Verantwortung des Benutzers", "user_resp_text": "Benutzer sind allein verantwortlich für die installierten Erweiterungen.", "dmca_title": "Urheberrecht & DMCA", - "dmca_text": "Wir respektieren die geistigen Eigentumsrechte anderer.", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "Keine Garantie", "warranty_text": "Diese Software wird ohne Mängelgewähr bereitgestellt." }, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 6d2bf409..099177f0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -774,13 +774,13 @@ "analytics_enabled_title": "Analytics Enabled", "analytics_enabled_message": "Usage data will be collected to help improve the app. You can disable this at any time.", "disable_error_reporting_title": "Disable Error Reporting?", - "disable_error_reporting_message": "Disabling error reporting means we won\u2019t be notified of crashes or issues you experience. This may affect our ability to fix bugs.", + "disable_error_reporting_message": "Disabling error reporting means we won’t be notified of crashes or issues you experience. This may affect our ability to fix bugs.", "enable_session_replay_title": "Enable Session Replay?", "enable_session_replay_message": "Session replay records your screen when errors occur to help us understand what happened. This may capture visible content on your screen.", "enable_pii_title": "Enable PII Collection?", "enable_pii_message": "This allows collection of personally identifiable information like IP address and device details. This data helps diagnose issues but increases privacy exposure.", "disable_all_title": "Disable All Telemetry?", - "disable_all_message": "This will disable all analytics, error reporting, and session replay. We won\u2019t receive any data about app usage or crashes.", + "disable_all_message": "This will disable all analytics, error reporting, and session replay. We won’t receive any data about app usage or crashes.", "disable_all_button": "Disable All", "all_disabled_title": "All Telemetry Disabled", "all_disabled_message": "All data collection has been disabled. Changes take effect on next app restart.", @@ -913,8 +913,8 @@ }, "debrid": { "title": "Debrid Integration", - "description_torbox": "Unlock 4K high-quality streams and lightning-fast speeds by integrating Torbox. Enter your API Key below to instantly upgrade your streaming experience.", - "description_torrentio": "Configure Torrentio to get torrent streams for movies and TV shows. A debrid service is required to stream content.", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "Connected", @@ -941,15 +941,15 @@ "enter_api_key": "Enter your API Key", "connect_button": "Connect & Install", "connecting": "Connecting...", - "unlock_speeds_title": "Unlock Premium Speeds", - "unlock_speeds_desc": "Get a Torbox subscription to access cached high-quality streams with zero buffering.", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "Get Subscription", "powered_by": "Powered by", "disclaimer_torbox": "Nuvio is not affiliated with Torbox in any way.", "disclaimer_torrentio": "Nuvio is not affiliated with Torrentio in any way.", "installed_badge": "✓ INSTALLED", "promo_title": "⚡ Need a Debrid Service?", - "promo_desc": "Get TorBox for lightning-fast 4K streaming with zero buffering. Premium cached torrents and instant downloads.", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "Get TorBox Subscription", "service_label": "Debrid Service *", "api_key_label": "API Key *", @@ -1341,7 +1341,7 @@ "user_resp_title": "User Responsibility", "user_resp_text": "Users are solely responsible for the plugins they install and the content they access. By using this application, you agree to ensure that you have the legal right to access any content you view using Nuvio. The developers of Nuvio do not endorse or encourage copyright infringement.", "dmca_title": "Copyright & DMCA", - "dmca_text": "We respect the intellectual property rights of others. Since Nuvio does not host any content, we cannot remove content from the internet. However, if you believe that the application interface itself infringes on your rights, please contact us.", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "No Warranty", "warranty_text": "This software is provided \"as is\", without warranty of any kind, express or implied. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability arising from the use of this software." }, diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index f9dadb3e..4b523750 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -908,8 +908,8 @@ }, "debrid": { "title": "Integración de Debrid", - "description_torbox": "Desbloquea fuentes 4K de alta calidad y velocidades ultrarrápidas integrando Torbox. Introduce tu clave de API abajo para mejorar instantáneamente tu experiencia de streaming.", - "description_torrentio": "Configura Torrentio para obtener fuentes de torrents para películas y series. Se requiere un servicio de debrid para reproducir el contenido.", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "Conectado", @@ -936,15 +936,15 @@ "enter_api_key": "Introduce tu clave API", "connect_button": "Conectar e instalar", "connecting": "Conectando...", - "unlock_speeds_title": "Desbloquea velocidades premium", - "unlock_speeds_desc": "Consigue una suscripción a Torbox para acceder a fuentes de alta calidad en caché sin buffering.", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "Conseguir suscripción", "powered_by": "Impulsado por", "disclaimer_torbox": "Nuvio no tiene ninguna afiliación con Torbox.", "disclaimer_torrentio": "Nuvio no tiene ninguna afiliación con Torrentio.", "installed_badge": "INSTALADO", "promo_title": "⚡ ¿Necesitas un servicio de Debrid?", - "promo_desc": "Consigue TorBox para streaming 4K ultrarrápido sin buffering. Torrents en caché premium y descargas instantáneas.", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "Conseguir suscripción a TorBox", "service_label": "Servicio de Debrid *", "api_key_label": "Clave API *", @@ -1336,7 +1336,7 @@ "user_resp_title": "Responsabilidad del usuario", "user_resp_text": "Los usuarios son los únicos responsables de las extensiones que instalan y del contenido al que acceden. Al utilizar esta aplicación, aceptas asegurarte de que tienes el derecho legal de acceder a cualquier contenido que veas utilizando Nuvio. Los desarrolladores de Nuvio no respaldan ni fomentan la infracción de derechos de autor.", "dmca_title": "Derechos de autor y DMCA", - "dmca_text": "Respetamos los derechos de propiedad intelectual de otros. Dado que Nuvio no aloja ningún contenido, no podemos eliminar contenido de Internet. Sin embargo, si crees que la interfaz de la aplicación en sí infringe tus derechos, por favor contáctanos.", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "Sin garantía", "warranty_text": "Este software se proporciona \"tal cual\", sin garantía de ningún tipo, expresa o implícita. En ningún caso los autores o titulares de los derechos de autor serán responsables de ninguna reclamación, daños u otra responsabilidad que surja del uso de este software." }, diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c054f96f..f013901a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -908,8 +908,8 @@ }, "debrid": { "title": "Intégration Debrid", - "description_torbox": "Débloquez des flux 4K de haute qualité et des vitesses fulgurantes en intégrant Torbox. Entrez votre clé API ci-dessous pour améliorer instantanément votre expérience de streaming.", - "description_torrentio": "Configurez Torrentio pour obtenir des flux torrent pour les films et les séries TV. Un service debrid est requis pour streamer le contenu.", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "Connecté", @@ -936,15 +936,15 @@ "enter_api_key": "Entrez votre clé API", "connect_button": "Connecter et installer", "connecting": "Connexion...", - "unlock_speeds_title": "Débloquez les vitesses premium", - "unlock_speeds_desc": "Obtenez un abonnement Torbox pour accéder à des flux en cache de haute qualité sans aucun buffering.", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "S'abonner", "powered_by": "Propulsé par", "disclaimer_torbox": "Nuvio n'est affilié à Torbox d'aucune façon.", "disclaimer_torrentio": "Nuvio n'est affilié à Torrentio d'aucune façon.", "installed_badge": "✓ INSTALLÉ", "promo_title": "⚡ Besoin d'un service Debrid ?", - "promo_desc": "Obtenez TorBox pour un streaming 4K ultra-rapide sans buffering. Torrents en cache premium et téléchargements instantanés.", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "Obtenir un abonnement TorBox", "service_label": "Service Debrid *", "api_key_label": "Clé API *", @@ -1336,7 +1336,7 @@ "user_resp_title": "Responsabilité de l'Utilisateur", "user_resp_text": "Les utilisateurs sont seuls responsables des extensions qu'ils installent et du contenu auquel ils accèdent. En utilisant cette application, vous acceptez de vous assurer que vous disposez du droit légal d'accéder à tout contenu que vous visualisez en utilisant Nuvio. Les développeurs de Nuvio ne cautionnent ni n'encouragent la violation du droit d'auteur.", "dmca_title": "Droits d'Auteur et DMCA", - "dmca_text": "Nous respectons les droits de propriété intellectuelle d'autrui. Étant donné que Nuvio n'héberge aucun contenu, nous ne pouvons pas supprimer de contenu d'Internet. Toutefois, si vous pensez que l'interface de l'application elle-même enfreint vos droits, veuillez nous contacter.", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "Aucune Garantie", "warranty_text": "Ce logiciel est fourni \"tel quel\", sans garantie d'aucune sorte, expresse ou implicite. En aucun cas, les auteurs ou titulaires de droits d'auteur ne pourront être tenus responsables de toute réclamation, dommage ou autre responsabilité découlant de l'utilisation de ce logiciel." }, diff --git a/src/i18n/locales/hi.json b/src/i18n/locales/hi.json index 9833649c..15fa5631 100644 --- a/src/i18n/locales/hi.json +++ b/src/i18n/locales/hi.json @@ -908,8 +908,8 @@ }, "debrid": { "title": "Debrid एकीकरण", - "description_torbox": "Torbox को एकीकृत करके 4K उच्च-गुणवत्ता वाले स्ट्रीम और बिजली की तेज़ गति अनलॉक करें। अपने स्ट्रीमिंग अनुभव को तुरंत अपग्रेड करने के लिए नीचे अपनी API कुंजी दर्ज करें।", - "description_torrentio": "फिल्मों और टीवी शो के लिए टोरेंट स्ट्रीम प्राप्त करने के लिए Torrentio कॉन्फ़िगर करें। सामग्री स्ट्रीम करने के लिए एक डेब्रिड सेवा आवश्यक है।", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "जुड़ा हुआ", @@ -936,15 +936,15 @@ "enter_api_key": "अपनी API कुंजी दर्ज करें", "connect_button": "कनेक्ट करें और इंस्टॉल करें", "connecting": "कनेक्ट किया जा रहा है...", - "unlock_speeds_title": "प्रीमियम गति अनलॉक करें", - "unlock_speeds_desc": "शून्य बफरिंग के साथ कैश किए गए उच्च-गुणवत्ता वाले स्ट्रीम तक पहुंचने के लिए Torbox सदस्यता प्राप्त करें।", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "सदस्यता प्राप्त करें", "powered_by": "द्वारा संचालित", "disclaimer_torbox": "Nuvio किसी भी तरह से Torbox से संबद्ध नहीं है।", "disclaimer_torrentio": "Nuvio किसी भी तरह से Torrentio से संबद्ध नहीं है।", "installed_badge": "✓ स्थापित", "promo_title": "⚡ एक Debrid सेवा की आवश्यकता है?", - "promo_desc": "शून्य बफरिंग के साथ बिजली की तेज़ 4K स्ट्रीमिंग के लिए TorBox प्राप्त करें। प्रीमियम कैश किए गए टोरेंट और तत्काल डाउनलोड।", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "TorBox सदस्यता प्राप्त करें", "service_label": "Debrid सेवा *", "api_key_label": "API कुंजी *", @@ -1336,7 +1336,7 @@ "user_resp_title": "उपयोगकर्ता जिम्मेदारी", "user_resp_text": "उपयोगकर्ता पूरी तरह से उन प्लगइन्स के लिए जिम्मेदार हैं जिन्हें वे स्थापित करते हैं और जिस सामग्री तक वे पहुंचते हैं। इस एप्लिकेशन का उपयोग करके, आप यह सुनिश्चित करने के लिए सहमत हैं कि आपके पास Nuvio का उपयोग करके किसी भी सामग्री को देखने का कानूनी अधिकार है। Nuvio के डेवलपर्स कॉपीराइट उल्लंघन का समर्थन या प्रोत्साहन नहीं करते हैं।", "dmca_title": "कॉपीराइट और DMCA", - "dmca_text": "हम दूसरों के बौद्धिक संपदा अधिकारों का सम्मान करते हैं। चूंकि Nuvio किसी भी सामग्री को होस्ट नहीं करता है, इसलिए हम इंटरनेट से सामग्री नहीं हटा सकते हैं। हालाँकि, यदि आपको लगता है कि एप्लिकेशन इंटरफ़ेस स्वयं आपके अधिकारों का उल्लंघन करता है, तो कृपया हमसे संपर्क करें।", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "कोई वारंटी नहीं", "warranty_text": "यह सॉफ़्टवेयर \"जैसा है\" प्रदान किया जाता है, बिना किसी प्रकार की वारंटी, व्यक्त या निहित। किसी भी स्थिति में लेखक या कॉपीराइट धारक इस सॉफ़्टवेयर के उपयोग से उत्पन्न किसी भी दावे, क्षति या अन्य दायित्व के लिए उत्तरदायी नहीं होंगे।" }, diff --git a/src/i18n/locales/hr.json b/src/i18n/locales/hr.json index 09bdfa73..e974081c 100644 --- a/src/i18n/locales/hr.json +++ b/src/i18n/locales/hr.json @@ -1,1414 +1,1413 @@ -{ - "common": { - "loading": "Učitavanje...", - "cancel": "Odustani", - "save": "Spremi", - "delete": "Obriši", - "edit": "Uredi", - "search": "Pretraži", - "error": "Pogreška", - "success": "Uspjeh", - "ok": "U redu", - "unknown": "Nepoznato", - "retry": "Pokušaj ponovno", - "try_again": "Pokušajte ponovno", - "go_back": "Idi natrag", - "settings": "Postavke", - "close": "Zatvori", - "enable": "Omogući", - "disable": "Onemogući", - "show_more": "Prikaži više", - "show_less": "Prikaži manje", - "load_more": "Učitaj više", - "unknown_date": "Nepoznat datum", - "anonymous_user": "Anonimni korisnik", - "time": { - "now": "Upravo sada", - "minutes_ago": "prije {{count}} min", - "hours_ago": "prije {{count}} h", - "days_ago": "prije {{count}} d" - }, - "days_short": { - "sun": "Ned", - "mon": "Pon", - "tue": "Uto", - "wed": "Sri", - "thu": "Čet", - "fri": "Pet", - "sat": "Sub" - }, - "email": "E-pošta", - "status": "Status" - }, - "home": { - "categories": { - "movies": "Filmovi", - "series": "Serije", - "channels": "Kanali" - }, - "movies": "Filmovi", - "tv_shows": "Serije", - "load_more_catalogs": "Učitaj više kataloga", - "no_content": "Sadržaj nije dostupan", - "add_catalogs": "Dodaj kataloge", - "sign_in_available": "Prijava je dostupna", - "sign_in_desc": "Možete se prijaviti bilo kada u Postavke → Račun", - "view_all": "Vidi sve", - "this_week": "Ovaj tjedan", - "upcoming": "Dolazeće", - "recently_released": "Nedavno objavljeno", - "no_scheduled_episodes": "Serije bez zakazanih epizoda", - "check_back_later": "Provjerite kasnije", - "continue_watching": "Nastavi gledati", - "up_next": "Sljedeće", - "up_next_caps": "SLJEDEĆE", - "released": "Objavljeno", - "new": "Novo", - "tba": "Bit će objavljeno", - "new_episodes": "{{count}} nove epizode", - "season_short": "S{{season}}", - "episode_short": "E{{episode}}", - "season": "Sezona {{season}}", - "episode": "Epizoda {{episode}}", - "movie": "Film", - "series": "Serija", - "tv_show": "Serija", - "percent_watched": "{{percent}}% pogledano", - "view_details": "Vidi detalje", - "remove": "Ukloni", - "play": "Reproduciraj", - "play_now": "Pokreni odmah", - "resume": "Nastavi", - "info": "Informacije", - "more_info": "Više informacija", - "my_list": "Moj popis", - "save": "Spremi", - "saved": "Spremljeno", - "retry": "Pokušaj ponovno", - "install_addons": "Instaliraj dodatke", - "settings": "Postavke", - "no_featured_content": "Nema istaknutog sadržaja", - "couldnt_load_featured": "Nije moguće učitati istaknuti sadržaj", - "no_featured_desc": "Instalirajte dodatke s katalozima ili promijenite izvor sadržaja u postavkama.", - "load_error_desc": "Došlo je do problema prilikom dohvaćanja istaknutog sadržaja. Provjerite vezu i pokušajte ponovno.", - "no_featured_available": "Nema dostupnog istaknutog sadržaja", - "no_description": "Opis nije dostupan" - }, - "navigation": { - "home": "Početna", - "library": "Knjižnica", - "search": "Pretraživanje", - "downloads": "Preuzimanja", - "settings": "Postavke" - }, - "search": { - "title": "Pretraživanje", - "recent_searches": "Nedavna pretraživanja", - "discover": "Otkrij", - "movies": "Filmovi", - "tv_shows": "Serije", - "select_catalog": "Odaberi katalog", - "all_genres": "Svi žanrovi", - "discovering": "Otkrivanje sadržaja...", - "show_more": "Prikaži više ({{count}})", - "no_content_found": "Sadržaj nije pronađen", - "try_different": "Pokušajte s drugim žanrom ili katalogom", - "select_catalog_desc": "Odaberite katalog za istraživanje", - "tap_catalog_desc": "Dodirnite karticu kataloga iznad za početak", - "placeholder": "Pretraži filmove, serije...", - "keep_typing": "Nastavite tipkati...", - "type_characters": "Upišite barem 2 znaka za pretraživanje", - "no_results": "Nema rezultata", - "try_keywords": "Pokušajte s drugim ključnim riječima ili provjerite pravopis", - "select_type": "Odaberi vrstu", - "browse_movies": "Pregledaj kataloge filmova", - "browse_tv": "Pregledaj kataloge serija", - "select_genre": "Odaberi žanr", - "show_all_content": "Prikaži sav sadržaj", - "genres_count": "{{count}} žanrova" - }, - "library": { - "title": "Knjižnica", - "watched": "Pogledano", - "continue": "Nastavi", - "watchlist": "Popis za gledanje", - "collection": "Kolekcija", - "rated": "Ocijenjeno", - "items": "stavki", - "trakt_collections": "Trakt kolekcije", - "trakt_collection": "Trakt kolekcija", - "no_trakt": "Nema Trakt kolekcija", - "no_trakt_desc": "Vaše Trakt kolekcije pojavit će se ovdje kada počnete koristiti Trakt", - "load_collections": "Učitaj kolekcije", - "empty_folder": "Nema sadržaja u {{folder}}", - "empty_folder_desc": "Ova kolekcija je prazna", - "refresh": "Osvježi", - "no_movies": "Još nema filmova", - "no_series": "Još nema serija", - "no_content": "Još nema sadržaja", - "add_content_desc": "Dodajte sadržaj u svoju knjižnicu kako biste ga vidjeli ovdje", - "find_something": "Pronađi nešto za gledanje", - "removed_from_library": "Uklonjeno iz knjižnice", - "item_removed": "Stavka je uklonjena iz vaše knjižnice", - "failed_update_library": "Ažuriranje knjižnice nije uspjelo", - "unable_remove": "Nije moguće ukloniti stavku iz knjižnice", - "marked_watched": "Označeno kao pogledano", - "marked_unwatched": "Označeno kao nepogledano", - "item_marked_watched": "Stavka je označena kao pogledana", - "item_marked_unwatched": "Stavka je označena kao nepogledana", - "failed_update_watched": "Ažuriranje statusa gledanja nije uspjelo", - "unable_update_watched": "Nije moguće ažurirati status gledanja", - "added_to_library": "Dodano u knjižnicu", - "item_added": "Dodano u vašu lokalnu knjižnicu", - "add_to_library": "Dodaj u knjižnicu", - "remove_from_library": "Ukloni iz knjižnice", - "mark_watched": "Označi kao pogledano", - "mark_unwatched": "Označi kao nepogledano", - "share": "Podijeli", - "add_to_watchlist": "Dodaj na Trakt popis za gledanje", - "remove_from_watchlist": "Ukloni s Trakt popisa za gledanje", - "added_to_watchlist": "Dodano na popis za gledanje", - "added_to_watchlist_desc": "Dodano na vaš Trakt popis za gledanje", - "removed_from_watchlist": "Uklonjeno s popisa za gledanje", - "removed_from_watchlist_desc": "Uklonjeno s vašeg Trakt popisa za gledanje", - "add_to_collection": "Dodaj u Trakt kolekciju", - "remove_from_collection": "Ukloni iz Trakt kolekcije", - "added_to_collection": "Dodano u kolekciju", - "added_to_collection_desc": "Dodano u vašu Trakt kolekciju", - "removed_from_collection": "Uklonjeno iz kolekcije", - "removed_from_collection_desc": "Uklonjeno iz vaše Trakt kolekcije" - }, - "metadata": { - "unable_to_load": "Nije moguće učitati sadržaj", - "error_code": "Šifra pogreške: {{code}}", - "content_not_found": "Sadržaj nije pronađen", - "content_not_found_desc": "Ovaj sadržaj ne postoji ili je možda uklonjen.", - "server_error": "Pogreška poslužitelja", - "server_error_desc": "Poslužitelj je privremeno nedostupan. Pokušajte ponovno kasnije.", - "bad_gateway": "Loš gateway", - "bad_gateway_desc": "Poslužitelj ima poteškoća. Pokušajte ponovno kasnije.", - "service_unavailable": "Usluga nedostupna", - "service_unavailable_desc": "Usluga je trenutno na održavanju. Pokušajte ponovno kasnije.", - "too_many_requests": "Previše zahtjeva", - "too_many_requests_desc": "Šaljete previše zahtjeva. Pričekajte trenutak i pokušajte ponovno.", - "request_timeout": "Istek vremena zahtjeva", - "request_timeout_desc": "Zahtjev je trajao predugo. Pokušajte ponovno.", - "network_error": "Pogreška mreže", - "network_error_desc": "Provjerite internetsku vezu i pokušajte ponovno.", - "auth_error": "Pogreška autentifikacije", - "auth_error_desc": "Provjerite postavke računa i pokušajte ponovno.", - "access_denied": "Pristup odbijen", - "access_denied_desc": "Nemate dopuštenje za pristup ovom sadržaju.", - "connection_error": "Pogreška veze", - "streams_unavailable": "Streaming izvori nedostupni", - "streams_unavailable_desc": "Izvori za streaming trenutno su nedostupni. Pokušajte ponovno kasnije.", - "unknown_error": "Nepoznata pogreška", - "something_went_wrong": "Nešto je pošlo po zlu. Pokušajte ponovno.", - "cast": "Glumačka postava", - "more_like_this": "Slično ovome", - "collection": "Kolekcija", - "episodes": "Epizode", - "seasons": "Sezone", - "posters": "Posteri", - "banners": "Banneri", - "specials": "Specijali", - "season_number": "Sezona {{number}}", - "episode_count": "{{count}} epizoda", - "episode_count_plural": "{{count}} epizoda", - "no_episodes": "Nema dostupnih epizoda", - "no_episodes_for_season": "Nema dostupnih epizoda za Sezonu {{season}}", - "episodes_not_released": "Epizode možda još nisu objavljene", - "no_description": "Opis nije dostupan", - "episode_label": "EPIZODA {{number}}", - "watch_again": "Gledaj ponovno", - "completed": "Završeno", - "play_episode": "Reproduciraj S{{season}}E{{episode}}", - "play": "Reproduciraj", - "watched": "Pogledano", - "watched_on_trakt": "Pogledano na Traktu", - "synced_with_trakt": "Sinkronizirano s Traktom", - "saved": "Spremljeno", - "director": "Redatelj", - "directors": "Redatelji", - "creator": "Autor", - "creators": "Autori", - "production": "Produkcija", - "network": "Mreža", - "mark_watched": "Označi kao pogledano", - "mark_unwatched": "Označi kao nepogledano", - "marking": "Označavanje...", - "removing": "Uklanjanje...", - "unmark_season": "Odznači Sezonu {{season}}", - "mark_season": "Označi Sezonu {{season}}", - "resume": "Nastavi", - "spoiler_warning": "Upozorenje o spoilerima", - "spoiler_warning_desc": "Ovaj komentar sadrži spoilere. Jeste li sigurni da ga želite otkriti?", - "cancel": "Odustani", - "reveal_spoilers": "Otkrij spoilere", - "movie_details": "Detalji o filmu", - "show_details": "Detalji o seriji", - "tagline": "Slogan", - "status": "Status", - "release_date": "Datum izlaska", - "runtime": "Trajanje", - "budget": "Budžet", - "revenue": "Prihod", - "origin_country": "Zemlja podrijetla", - "original_language": "Izvorni jezik", - "first_air_date": "Datum prve emisije", - "last_air_date": "Datum zadnje emisije", - "total_episodes": "Ukupno epizoda", - "episode_runtime": "Trajanje epizode", - "created_by": "Autor", - "backdrop_gallery": "Galerija pozadina", - "loading_episodes": "Učitavanje epizoda...", - "no_episodes_available": "Nema dostupnih epizoda", - "play_next": "Reproduciraj S{{season}}E{{episode}}", - "play_next_episode": "Reproduciraj sljedeću epizodu", - "save": "Spremi", - "percent_watched": "{{percent}}% pogledano", - "percent_watched_trakt": "{{percent}}% pogledano ({{traktPercent}}% na Traktu)", - "synced_with_trakt_progress": "Sinkronizirano s Traktom", - "using_trakt_progress": "Koristi se Trakt napredak", - "added_to_collection_hero": "Dodano u kolekciju", - "added_to_collection_desc_hero": "Dodano u vašu Trakt kolekciju", - "removed_from_collection_hero": "Uklonjeno iz kolekcije", - "removed_from_collection_desc_hero": "Uklonjeno iz vaše Trakt kolekcije", - "mark_as_watched": "Označi kao pogledano", - "mark_as_unwatched": "Označi kao nepogledano" - }, - "cast": { - "biography": "Biografija", - "known_for": "Poznat po", - "personal_info": "Osobni podaci", - "born_in": "Rođen u {{place}}", - "filmography": "Filmografija", - "also_known_as": "Poznat i kao", - "no_info_available": "Dodatne informacije nisu dostupne", - "as_character": "kao {{character}}", - "loading_details": "Učitavanje detalja...", - "years_old": "{{age}} godina", - "view_filmography": "Vidi filmografiju", - "filter": "Filter", - "sort_by": "Sortiraj po", - "sort_popular": "Popularno", - "sort_latest": "Najnovije", - "sort_upcoming": "Dolazeće", - "upcoming_badge": "DOLAZI", - "coming_soon": "Dolazi uskoro", - "filmography_count": "Filmografija • {{count}} naslova", - "loading_filmography": "Učitavanje filmografije...", - "load_more_remaining": "Učitaj više (preostalo {{count}})", - "alert_error_title": "Pogreška", - "alert_error_message": "Nije moguće učitati \"{{title}}\". Pokušajte ponovno kasnije.", - "alert_ok": "U redu", - "no_upcoming": "Nema dolazećih izdanja za ovog glumca", - "no_content": "Sadržaj nije dostupan za ovog glumca", - "no_movies": "Nema dostupnih filmova za ovog glumca", - "no_tv": "Nema dostupnih serija za ovog glumca" - }, - "comments": { - "title": "Trakt komentari", - "spoiler_warning": "⚠️ Ovaj komentar sadrži spoilere. Dodirni za prikaz.", - "spoiler": "Spoiler", - "contains_spoilers": "Sadrži spoilere", - "reveal": "Otkrij", - "vip": "VIP", - "unavailable": "Komentari nedostupni", - "no_comments": "Još nema komentara na Traktu", - "not_in_database": "Ovaj sadržaj možda još nije u Trakt bazi podataka", - "check_trakt": "Provjeri Trakt" - }, - "trailers": { - "title": "Traileri", - "official_trailers": "Službeni traileri", - "official_trailer": "Službeni trailer", - "teasers": "Teaseri", - "teaser": "Teaser", - "clips_scenes": "Isječci i scene", - "clip": "Isječak", - "featurettes": "Featurettes", - "featurette": "Featurette", - "behind_the_scenes": "Iza kulisa", - "no_trailers": "Nema dostupnih trailera", - "unavailable": "Trailer nedostupan", - "unavailable_desc": "Ovaj trailer trenutno se ne može učitati. Pokušajte ponovno kasnije.", - "unable_to_play": "Nije moguće reproducirati trailer. Pokušajte ponovno.", - "watch_on_youtube": "Gledaj na YouTubeu" - }, - "catalog": { - "no_content_found": "Sadržaj nije pronađen", - "no_content_filters": "Nije pronađen sadržaj za odabrane filtere", - "loading_content": "Učitavanje sadržaja...", - "back": "Natrag", - "in_theaters": "U kinima", - "all": "Sve", - "failed_tmdb": "Učitavanje sadržaja s TMDB-a nije uspjelo", - "movies": "Filmovi", - "tv_shows": "Serije", - "channels": "Kanali" - }, - "streams": { - "back_to_episodes": "Natrag na epizode", - "back_to_info": "Natrag na informacije", - "fetching_from": "Dohvaćanje iz:", - "no_sources_available": "Nema dostupnih izvora za streaming", - "add_sources_desc": "Molimo dodajte izvore za streaming u postavkama", - "add_sources": "Dodaj izvore", - "finding_streams": "Pronalaženje dostupnih streamova...", - "finding_best_stream": "Pronalaženje najboljeg streama za automatsku reprodukciju...", - "still_fetching": "Dohvaćanje streamova i dalje u tijeku...", - "no_streams_available": "Nema dostupnih streamova", - "starting_best_stream": "Pokretanje najboljeg streama...", - "loading_more_sources": "Učitavanje dodatnih izvora..." - }, - "player_ui": { - "via": "putem {{name}}", - "audio_tracks": "Zvučni zapisi", - "no_audio_tracks": "Nema dostupnih zvučnih zapisa", - "playback_speed": "Brzina reprodukcije", - "on_hold": "Na čekanju", - "playback_error": "Pogreška pri reprodukciji", - "unknown_error": "Došlo je do nepoznate pogreške tijekom reprodukcije.", - "copy_error": "Kopiraj detalje pogreške", - "copied_to_clipboard": "Kopirano u međuspremnik", - "dismiss": "Zatvori", - "continue_watching": "Nastavi gledati", - "start_over": "Kreni ispočetka", - "resume": "Nastavi", - "change_source": "Promijeni izvor", - "switching_source": "Promjena izvora...", - "no_sources_found": "Nema pronađenih izvora", - "sources": "Izvori", - "finding_sources": "Pronalaženje izvora...", - "unknown_source": "Nepoznat izvor", - "sources_limited": "Izvori mogu biti ograničeni zbog pogrešaka pružatelja usluga.", - "episodes": "Epizode", - "specials": "Specijali", - "season": "Sezona {{season}}", - "stream": "Stream {{number}}", - "subtitles": "Titlovi", - "built_in": "Ugrađeno", - "addons": "Dodaci", - "style": "Stil", - "none": "Nijedan", - "search_online_subtitles": "Pretraži titlove na mreži", - "preview": "Pretpregled", - "quick_presets": "Brze postavke", - "default": "Zadano", - "yellow": "Žuta", - "high_contrast": "Visoki kontrast", - "large": "Veliko", - "core": "Osnovno", - "font_size": "Veličina fonta", - "show_background": "Prikaži pozadinu", - "advanced": "Napredno", - "position": "Položaj", - "text_color": "Boja teksta", - "align": "Poravnanje", - "bottom_offset": "Pomak od dna", - "background_opacity": "Prozirnost pozadine", - "text_shadow": "Sjena teksta", - "on": "Uključeno", - "off": "Isključeno", - "outline_color": "Boja obruba", - "outline": "Obrub", - "outline_width": "Širina obruba", - "letter_spacing": "Razmak slova", - "line_height": "Visina retka", - "timing_offset": "Pomak vremena (s)", - "visual_sync": "Vizualna sinkronizacija", - "timing_hint": "Pomaknite titlove ranije (-) ili kasnije (+) za sinkronizaciju ako je potrebno.", - "reset_defaults": "Vrati na zadano" - }, - "downloads": { - "title": "Preuzimanja", - "no_downloads": "Još nema preuzimanja", - "no_downloads_desc": "Preuzeti sadržaj pojavit će se ovdje za gledanje izvan mreže", - "explore": "Istraži sadržaj", - "path_copied": "Putanja kopirana", - "path_copied_desc": "Lokalna putanja datoteke kopirana u međuspremnik", - "copied": "Kopirano", - "incomplete": "Preuzimanje nepotpuno", - "incomplete_desc": "Preuzimanje još nije završeno", - "not_available": "Nije dostupno", - "not_available_desc": "Lokalna putanja datoteke dostupna je tek nakon završetka preuzimanja.", - "status_downloading": "Preuzimanje", - "status_completed": "Dovršeno", - "status_paused": "Pauzirano", - "status_error": "Pogreška", - "status_queued": "U redu čekanja", - "status_unknown": "Nepoznato", - "provider": "Pružatelj usluge", - "streaming_playlist_warning": "Možda se neće reproducirati - streaming playlista", - "remaining": "preostalo", - "not_ready": "Preuzimanje nije spremno", - "not_ready_desc": "Molimo pričekajte dok preuzimanje ne završi.", - "filter_all": "Sve", - "filter_active": "Aktivno", - "filter_done": "Gotovo", - "filter_paused": "Pauzirano", - "no_filter_results": "Nema {{filter}} preuzimanja", - "try_different_filter": "Pokušajte odabrati drugi filter", - "limitations_title": "Ograničenja preuzimanja", - "limitations_msg": "• Datoteke manje od 1MB obično su M3U8 streaming liste i ne mogu se preuzeti za gledanje izvan mreže. One rade samo s online streamingom i sadrže poveznice na segmente videa, a ne stvarni video sadržaj.", - "remove_title": "Ukloni preuzimanje", - "remove_confirm": "Ukloniti \"{{title}}\"{{season_episode}}?", - "cancel": "Odustani", - "remove": "Ukloni" - }, - "addons": { - "title": "Dodaci", - "reorder_mode": "Način preslagivanja", - "reorder_info": "Dodaci na vrhu imaju veći prioritet prilikom učitavanja sadržaja", - "add_addon_placeholder": "URL dodatka", - "add_button": "Dodaj dodatak", - "my_addons": "Moji dodaci", - "community_addons": "Dodaci zajednice", - "no_addons": "Nema instaliranih dodataka", - "uninstall_title": "Deinstaliraj dodatak", - "uninstall_message": "Jeste li sigurni da želite deinstalirati {{name}}?", - "uninstall_button": "Deinstaliraj", - "install_success": "Dodatak je uspješno instaliran", - "install_error": "Instalacija dodatka nije uspjela", - "load_error": "Učitavanje dodataka nije uspjelo", - "fetch_error": "Dohvaćanje detalja dodatka nije uspjelo", - "invalid_url": "Molimo unesite URL dodatka", - "configure": "Konfiguriraj", - "version": "Verzija: {{version}}", - "installed_addons": "INSTALIRANI DODACI", - "reorder_drag_title": "POVUCITE DODATKE ZA PRESLAGIVANJE", - "install": "Instaliraj", - "config_unavailable_title": "Konfiguracija nedostupna", - "config_unavailable_msg": "Nije moguće odrediti URL konfiguracije za ovaj dodatak.", - "cannot_open_config_title": "Nije moguće otvoriti konfiguraciju", - "cannot_open_config_msg": "Konfiguracijski URL ({{url}}) ne može se otvoriti. Dodatak možda nema stranicu za konfiguraciju.", - "description": "Opis", - "supported_types": "Podržane vrste", - "catalogs": "Katalozi", - "no_description": "Opis nije dostupan", - "overview": "PREGLED", - "no_categories": "Nema kategorija", - "pre_installed": "PREDINSTRUALIRANO" - }, - "trakt": { - "title": "Trakt postavke", - "settings_title": "Trakt postavke", - "connect_title": "Poveži se s Traktom", - "connect_desc": "Sinkronizirajte povijest gledanja, popis za gledanje i kolekciju s Trakt.tv", - "sign_in": "Prijavi se na Trakt", - "sign_out": "Odjava", - "sign_out_confirm": "Jeste li sigurni da se želite odjaviti s vašeg Trakt računa?", - "joined": "Pridružen {{date}}", - "sync_settings_title": "Postavke sinkronizacije", - "sync_info": "Kada ste povezani s Traktom, cijela povijest se sinkronizira izravno putem API-ja i ne zapisuje se u lokalnu pohranu. Vaš popis 'Nastavi gledati' odražava vaš ukupni Trakt napredak.", - "auto_sync_label": "Automatska sinkronizacija napretka", - "auto_sync_desc": "Automatski sinkroniziraj napredak gledanja na Trakt", - "import_history_label": "Uvezi povijest gledanja", - "import_history_desc": "Koristite 'Sinkroniziraj sada' za uvoz povijesti gledanja i napretka s Trakta", - "sync_now_button": "Sinkroniziraj sada", - "display_settings_title": "Postavke prikaza", - "show_comments_label": "Prikaži Trakt komentare", - "show_comments_desc": "Prikaži Trakt komentare u detaljima sadržaja kada su dostupni", - "maintenance_title": "Održavanje u tijeku", - "maintenance_unavailable": "Trakt nedostupan", - "maintenance_desc": "Integracija s Traktom privremeno je zaustavljena zbog održavanja. Sinkronizacija i autentifikacija su onemogućeni dok se održavanje ne završi.", - "maintenance_button": "Usluga se održava", - "auth_success_title": "Uspješno povezano", - "auth_success_msg": "Vaš Trakt račun je uspješno povezan.", - "auth_error_title": "Pogreška autentifikacije", - "auth_error_msg": "Autentifikacija s Traktom nije uspjela.", - "auth_error_generic": "Došlo je do pogreške tijekom autentifikacije.", - "sign_out_error": "Odjava s Trakta nije uspjela.", - "sync_complete_title": "Sinkronizacija završena", - "sync_success_msg": "Vaš napredak gledanja uspješno je sinkroniziran s Traktom.", - "sync_error_msg": "Sinkronizacija nije uspjela. Molimo pokušajte ponovno." - }, - "simkl": { - "title": "Simkl postavke", - "settings_title": "Simkl postavke", - "connect_title": "Poveži se sa Simklom", - "connect_desc": "Sinkronizirajte povijest gledanja i pratite što gledate", - "sign_in": "Prijavi se na Simkl", - "sign_out": "Odspoji se", - "sign_out_confirm": "Jeste li sigurni da se želite odspojiti sa Simkla?", - "syncing_desc": "Vaše pogledane stavke sinkroniziraju se sa Simklom.", - "auth_success_title": "Uspješno povezano", - "auth_success_msg": "Vaš Simkl račun je uspješno povezan.", - "auth_error_title": "Pogreška autentifikacije", - "auth_error_msg": "Autentifikacija sa Simklom nije uspjela.", - "auth_error_generic": "Došlo je do pogreške tijekom autentifikacije.", - "sign_out_error": "Odspajanje sa Simkla nije uspjelo.", - "config_error_title": "Pogreška konfiguracije", - "config_error_msg": "Simkl Client ID nedostaje u varijablama okruženja.", - "conflict_title": "Sukob", - "conflict_msg": "Ne možete se povezati sa Simklom dok je Trakt povezan. Molimo prvo odspojite Trakt.", - "disclaimer": "Nuvio nije povezan sa Simklom." - }, - "tmdb_settings": { - "title": "TMDb postavke", - "metadata_enrichment": "Obogaćivanje metapodataka", - "metadata_enrichment_desc": "Poboljšajte metapodatke sadržaja s TMDb podacima za bolje detalje i informacije.", - "enable_enrichment": "Omogući obogaćivanje", - "enable_enrichment_desc": "Proširuje metapodatke dodataka s TMDb-a za glumačku postavu, dobne ocjene, logotipe/postere i informacije o produkciji.", - "localized_text": "Lokalizirani tekst", - "localized_text_desc": "Dohvati naslove i opise na vašem željenom jeziku s TMDb-a.", - "language": "Jezik", - "change": "Promijeni", - "logo_preview": "Pretpregled logotipa", - "logo_preview_desc": "Pretpregled pokazuje kako će se lokalizirani logotipi pojaviti na odabranom jeziku.", - "example": "Primjer:", - "no_logo": "Logotip nije dostupan", - "enrichment_options": "Opcije obogaćivanja", - "enrichment_options_desc": "Kontrolirajte koji se podaci dohvaćaju s TMDb-a. Onemogućene opcije koristit će podatke iz dodatka ako su dostupni.", - "cast_crew": "Glumci i ekipa", - "cast_crew_desc": "Glumci, redatelji, scenaristi s profilnim fotografijama", - "title_description": "Naslov i opis", - "title_description_desc": "Koristi TMDb lokalizirani naslov i opis", - "title_logos": "Logotipi naslova", - "title_logos_desc": "Visokokvalitetne slike naslova", - "banners_backdrops": "Banneri i pozadine", - "banners_backdrops_desc": "Slike pozadina visoke rezolucije", - "certification": "Dobna ocjena sadržaja", - "certification_desc": "Dobne preporuke (PG-13, R, TV-MA, itd.)", - "recommendations": "Preporuke", - "recommendations_desc": "Prijedlozi sličnog sadržaja", - "episode_data": "Podaci o epizodama", - "episode_data_desc": "Sličice epizoda, informacije i zamjenski podaci za serije", - "season_posters": "Posteri sezona", - "season_posters_desc": "Slike postera specifične za sezonu", - "production_info": "Informacije o produkciji", - "production_info_desc": "Mreže i produkcijske kuće s logotipima", - "movie_details": "Detalji o filmu", - "movie_details_desc": "Budžet, prihod, trajanje, slogan", - "tv_details": "Detalji o seriji", - "tv_details_desc": "Status, broj sezona, mreže, autori", - "movie_collections": "Kolekcije filmova", - "movie_collections_desc": "Filmske franšize (Marvel, Star Wars, itd.)", - "api_configuration": "API konfiguracija", - "api_configuration_desc": "Konfigurirajte svoj TMDb API pristup za poboljšanu funkcionalnost.", - "custom_api_key": "Prilagođeni API ključ", - "custom_api_key_desc": "Koristite vlastiti TMDb API ključ za bolje performanse i namjenska ograničenja.", - "custom_key_active": "Prilagođeni API ključ je aktivan", - "api_key_required": "Potreban je API ključ", - "api_key_placeholder": "Zalijepite svoj TMDb API ključ (v3)", - "how_to_get_key": "Kako dobiti TMDb API ključ?", - "built_in_key_msg": "Trenutno se koristi ugrađeni API ključ. Razmislite o korištenju vlastitog ključa za bolje performanse.", - "cache_size": "Veličina predmemorije", - "clear_cache": "Očisti predmemoriju", - "cache_days": "TMDb odgovori se spremaju 7 dana radi boljih performansi", - "choose_language": "Odaberi jezik", - "choose_language_desc": "Odaberite željeni jezik za TMDb sadržaj", - "popular": "Popularno", - "all_languages": "Svi jezici", - "search_results": "Rezultati pretraživanja", - "no_languages_found": "Nema pronađenih jezika za \"{{query}}\"", - "clear_search": "Očisti pretragu", - "clear_cache_title": "Očisti TMDb predmemoriju", - "clear_cache_msg": "Ovo će obrisati sve spremljene TMDb podatke ({{size}}). To može privremeno usporiti učitavanje dok se predmemorija ponovno ne izgradi.", - "clear_cache_success": "TMDb predmemorija je uspješno očišćena.", - "clear_cache_error": "Čišćenje predmemorije nije uspjelo.", - "clear_api_key_title": "Ukloni API ključ", - "clear_api_key_msg": "Jeste li sigurni da želite ukloniti svoj prilagođeni API ključ i vratiti se na zadani?", - "clear_api_key_success": "API ključ je uspješno uklonjen", - "clear_api_key_error": "Uklanjanje API ključa nije uspjelo", - "empty_api_key": "API ključ ne može biti prazan.", - "invalid_api_key": "Nevažeći API ključ. Provjerite i pokušajte ponovno.", - "save_error": "Došlo je do pogreške pri spremanju. Pokušajte ponovno.", - "using_builtin_key": "Sada koristite ugrađeni TMDb API ključ.", - "using_custom_key": "Sada koristite svoj prilagođeni TMDb API ključ.", - "enter_custom_key": "Molimo unesite i spremite svoj prilagođeni TMDb API ključ.", - "key_verified": "API ključ je verificiran i uspješno spremljen." - }, - "settings": { - "language": "Jezik", - "select_language": "Odaberi jezik", - "english": "Engleski", - "portuguese": "Portugalski", - "portuguese_br": "Portugalski (Brazil)", - "portuguese_pt": "Portugalski (Portugal)", - "german": "Njemački", - "arabic": "Arapski", - "spanish": "Španjolski", - "french": "Francuski", - "italian": "Talijanski", - "croatian": "Hrvatski", - "chinese": "Kineski (pojednostavljeni)", - "hindi": "Hindski", - "serbian": "Srpski", - "account": "Račun", - "content_discovery": "Sadržaj i otkrivanje", - "appearance": "Izgled", - "integrations": "Integracije", - "playback": "Reprodukcija", - "backup_restore": "Sigurnosna kopija i vraćanje", - "updates": "Ažuriranja", - "about": "O aplikaciji", - "developer": "Razvojni programer", - "cache": "Predmemorija", - "title": "Postavke", - "settings_title": "Postavke", - "sign_in_sync": "Prijavite se za sinkronizaciju", - "add_catalogs_sources": "Dodaci, katalozi i izvori", - "player_trailers_downloads": "Player, traileri, preuzimanja", - "mdblist_tmdb_ai": "MDBList, TMDB, AI", - "check_updates": "Provjeri ažuriranja", - "clear_mdblist_cache": "Očisti MDBList predmemoriju", - "cache_management": "UPRAVLJANJE PREDMEMORIJOM", - "downloads_counter": "preuzimanja i raste", - "made_with_love": "Napravljeno s ❤️ - Tapframe i prijatelji", - "sections": { - "information": "INFORMACIJE", - "account": "RAČUN", - "theme": "TEMA", - "layout": "RASPORED", - "sources": "IZVORI", - "catalogs": "KATALOZI", - "discovery": "OTKRIVANJE", - "metadata": "METAPODACI", - "ai_assistant": "AI ASISTENT", - "video_player": "VIDEO PLAYER", - "audio_subtitles": "AUDIO I TITLOVI", - "media": "MEDIJI", - "notifications": "OBAVIJESTI", - "testing": "TESTIRANJE", - "danger_zone": "ZONA OPASNOSTI" - }, - "items": { - "legal": "Pravne napomene i odricanje odgovornosti", - "privacy_policy": "Pravila privatnosti", - "report_issue": "Prijavi problem", - "version": "Verzija", - "contributors": "Suradnici", - "view_contributors": "Prikaži sve suradnike", - "theme": "Tema", - "episode_layout": "Raspored epizoda", - "streams_backdrop": "Pozadina streamova", - "streams_backdrop_desc": "Prikaži zamućenu pozadinu na streamovima na mobitelu", - "addons": "Dodaci", - "installed": "instalirano", - "debrid_integration": "Debrid integracija", - "debrid_desc": "Poveži Torbox", - "plugins": "Priključci", - "plugins_desc": "Upravljaj priključcima i repozitorijima", - "catalogs": "Katalozi", - "active": "aktivno", - "home_screen": "Početni zaslon", - "home_screen_desc": "Raspored i sadržaj", - "continue_watching": "Nastavi gledati", - "continue_watching_desc": "Predmemorija i ponašanje reprodukcije", - "show_discover": "Prikaži odjeljak 'Otkrij'", - "show_discover_desc": "Prikaži sadržaj za otkrivanje u pretrazi", - "mdblist": "MDBList", - "mdblist_connected": "Povezano", - "mdblist_desc": "Omogući za prikaz ocjena i recenzija", - "simkl": "Simkl", - "simkl_connected": "Povezano", - "simkl_desc": "Prati što gledaš", - "tmdb": "TMDB", - "tmdb_desc": "Pružatelj metapodataka i logotipa", - "openrouter": "OpenRouter API", - "openrouter_connected": "Povezano", - "openrouter_desc": "Dodaj API ključ za AI chat", - "video_player": "Video player", - "built_in": "Ugrađeni", - "external": "Vanjski", - "preferred_audio": "Željeni jezik zvuka", - "preferred_subtitle": "Željeni jezik titlova", - "subtitle_source": "Prioritet izvora titlova", - "auto_select_subs": "Automatski odabir titlova", - "auto_select_subs_desc": "Automatski odaberi titlove koji odgovaraju vašim postavkama", - "show_trailers": "Prikaži najave", - "show_trailers_desc": "Prikaži najave u glavnom odjeljku", - "enable_downloads": "Omogući preuzimanja", - "enable_downloads_desc": "Prikaži karticu Preuzimanja i omogući spremanje streamova", - "notifications": "Obavijesti", - "notifications_desc": "Podsjetnici za epizode", - "developer_tools": "Razvojni alati", - "developer_tools_desc": "Opcije za testiranje i uklanjanje pogrešaka", - "test_onboarding": "Testiraj uvodni ekran", - "reset_onboarding": "Resetiraj uvodni ekran", - "test_announcement": "Testiraj objavu", - "test_announcement_desc": "Prikaži prozor s novostima", - "reset_campaigns": "Resetiraj kampanje", - "reset_campaigns_desc": "Očisti zapise o prikazanim kampanjama", - "clear_all_data": "Očisti sve podatke", - "clear_all_data_desc": "Resetiraj sve postavke i predmemorirane podatke" - }, - "options": { - "horizontal": "Vodoravno", - "vertical": "Okomito", - "internal_first": "Prvo unutarnji", - "internal_first_desc": "Prednost imaju ugrađeni titlovi, zatim vanjski", - "external_first": "Prvo vanjski", - "external_first_desc": "Prednost imaju titlovi iz dodataka, zatim ugrađeni", - "any_available": "Bilo koji dostupni", - "any_available_desc": "Koristi prvi dostupni zapis titlova" - }, - "clear_data_desc": "Ovo će resetirati sve postavke i obrisati sve privremene podatke. Jeste li sigurni?", - "app_updates": "Ažuriranja aplikacije", - "about_nuvio": "O Nuviju" - }, - "privacy": { - "title": "Privatnost i Podaci", - "settings_desc": "Kontrolirajte telemetriju i prikupljanje podataka", - "info_title": "Vaša Privatnost nam je Važna", - "info_description": "Kontrolirajte koje podatke se prikupljaju i dijele. Analitika je podrazumevano onemogućena, a izveštaji o greškama su anonimni po zadanom.", - "analytics_enabled_title": "Analitika Omogućena", - "analytics_enabled_message": "Podaci o korišćenju će se prikupljati kako bi se poboljšala aplikacija. Možete to onemogućiti u bilo kojem trenutku.", - "disable_error_reporting_title": "Onemogućiti Izveštavanje o Greškama?", - "disable_error_reporting_message": "Onemogućavanje izveštavanja o greškama znači da nećemo biti obavesteni o padu ili problemima koje doživljate. Ovo može uticati na našu sposobnost da ispravimo greške.", - "enable_session_replay_title": "Omogućiti Reprodukciju Sesije?", - "enable_session_replay_message": "Reprodukcija sesije snima vaš ekran kada se greške dogode kako bi nam pomogla da razumemo šta se desilo. Ovo može da hvata vidljiv sadržaj na vašoj ekranu.", - "enable_pii_title": "Omogućiti Prikupljanje PII?", - "enable_pii_message": "Ovo omogućava prikupljanje lično identifikabilnih podataka kao što su IP adresa i detalji uređaja. Ovi podaci pomažu u dijagnostici problema, ali povećavaju izloženost privatnosti.", - "disable_all_title": "Onemogućiti Svu Telemetriju?", - "disable_all_message": "Ovo će onemogućiti svu analitiku, izveštavanje o greškama i reprodukciju sesije. Nećemo primati nikakve podatke o korišćenju aplikacije ili padevima.", - "disable_all_button": "Onemogući Sve", - "all_disabled_title": "Sva Telemetrija Onemogućena", - "all_disabled_message": "Svo prikupljanje podataka je onemogućeno. Promene će stupiti na snagu pri sledećem pokretanju aplikacije.", - "reset_title": "Resetuj na Preporučene", - "reset_message": "Postavke privatnosti su resetovane na preporučene zadane vrednosti (izveštavanje o greškama omogućeno, analitika onemogućena).", - "section_analytics": "ANALITIKA", - "analytics_title": "Analitika Korišćenja", - "analytics_description": "Prikupljaj anonimne obrasce korišćenja i prikaze ekrana", - "section_error_reporting": "IZVEŠTAVANJE O GREŠKAMA", - "error_reporting_title": "Izveštaji o Greškama", - "error_reporting_description": "Pošalji anonimne izveštaje o greškama kako bi se poboljšala stabilnost", - "session_replay_title": "Reprodukcija Sesije", - "session_replay_description": "Snimaj ekran kada se greške dogode", - "pii_title": "Uključi Informacije o Uređaju", - "pii_description": "Pošalji IP adresu i detalje uređaja sa izveštajima", - "section_quick_actions": "BRZE AKCIJE", - "disable_all": "Onemogući Svu Telemetriju", - "disable_all_desc": "Isključi svo prikupljanje podataka", - "reset_recommended": "Resetuj na Preporučene", - "reset_recommended_desc": "Zadane vrednosti usmeren na privatnost sa izveštavanjem o greškama", - "section_learn_more": "SAZNAJ VIŠE", - "privacy_policy": "Politika Privatnosti", - "current_settings": "Sažetak Trenutnih Postavki", - "summary_analytics": "Analitika", - "summary_errors": "Izveštaji o Greškama", - "summary_replay": "Reprodukcija Sesije", - "summary_pii": "Informacije o Uređaju", - "restart_note_detailed": "* Promene u analitici i izveštavanju o greškama stupaju na snagu odmah. Reprodukcija sesije i PII postavke zahtevaju ponovni pokretanje aplikacije." - }, - "ai_settings": { - "title": "AI asistent", - "info_title": "Chat pokretan umjetnom inteligencijom", - "info_desc": "Postavljajte pitanja o bilo kojem filmu ili epizodi serije koristeći napredni AI. Saznajte više o radnji, likovima, temama i zanimljivostima - sve temeljeno na TMDB podacima.", - "feature_1": "Kontekst i analiza specifična za epizodu", - "feature_2": "Objašnjenja radnje i uvid u likove", - "feature_3": "Zanimljivosti i činjenice iza kulisa", - "feature_4": "Vlastiti besplatni OpenRouter API ključ", - "api_key_section": "OPENROUTER API KLJUČ", - "api_key_label": "API ključ", - "api_key_desc": "Unesite svoj OpenRouter API ključ kako biste omogućili AI chat", - "save_api_key": "Spremi API ključ", - "saving": "Spremanje...", - "update": "Ažuriraj", - "remove": "Ukloni", - "get_free_key": "Nabavi besplatni API ključ od OpenRoutera", - "enable_chat": "Omogući AI Chat", - "enable_chat_desc": "Kada je omogućeno, gumb 'Pitaj AI' pojavit će se na stranicama sadržaja.", - "chat_enabled": "AI Chat omogućen", - "chat_enabled_desc": "Sada možete postavljati pitanja o filmovima i serijama. Potražite gumb \"Pitaj AI\"!", - "how_it_works": "Kako radi", - "how_it_works_desc": "• OpenRouter omogućuje pristup brojnim AI modelima\n• Vaš API ključ ostaje privatan i siguran\n• Besplatni paket uključuje izdašna ograničenja upotrebe\n• Razgovarajte o specifičnim epizodama ili filmovima\n• Dobijte detaljne analize i objašnjenja", - "error_invalid_key": "Molimo unesite važeći API ključ", - "error_key_format": "OpenRouter API ključevi trebaju počinjati s \"sk-or-\"", - "success_saved": "OpenRouter API ključ je uspješno spremljen!", - "error_save": "Spremanje API ključa nije uspjelo", - "confirm_remove_title": "Ukloni API ključ", - "confirm_remove_msg": "Jeste li sigurni da želite ukloniti svoj OpenRouter API ključ? To će onemogućiti AI chat.", - "success_removed": "API ključ uspješno uklonjen", - "error_remove": "Uklanjanje API ključa nije uspjelo" - }, - "catalog_settings": { - "title": "Katalozi", - "layout_phone": "RASPORED ZASLONA KATALOGA (MOBITEL)", - "posters_per_row": "Postera po retku", - "auto": "Automatski", - "show_titles": "Prikaži naslove postera", - "show_titles_desc": "Prikaži tekst naslova ispod svakog postera", - "phone_only_hint": "Vrijedi samo za mobitele. Tableti zadržavaju prilagodljivi raspored.", - "catalogs_group": "Katalozi", - "enabled_count": "Omogućeno {{enabled}} od {{total}}", - "rename_hint": "Dugo pritisnite katalog za preimenovanje", - "rename_modal_title": "Preimenuj katalog", - "rename_placeholder": "Unesite novi naziv kataloga", - "error_save_name": "Spremanje prilagođenog naziva nije uspjelo." - }, - "continue_watching_settings": { - "title": "Nastavi gledati", - "playback_behavior": "PONAŠANJE REPRODUKCIJE", - "use_cached": "Koristi predmemorirane streamove", - "use_cached_desc": "Kada je omogućeno, klikom na stavke 'Nastavi gledati' player se otvara izravno koristeći prethodne streamove. Kada je isključeno, otvara se zaslon sa sadržajem.", - "open_metadata": "Otvori zaslon s detaljima", - "open_metadata_desc": "Kada su predmemorirani streamovi isključeni, otvara se zaslon s detaljima umjesto popisa streamova. Ovo omogućuje ručni odabir streama.", - "card_appearance": "IZGLED KARTICE", - "card_style": "Stil kartice", - "card_style_desc": "Odaberite kako će se stavke 'Nastavi gledati' pojavljivati na početnom zaslonu", - "wide": "Široko", - "poster": "Poster", - "cache_settings": "POSTAVKE PREDMEMORIJE", - "cache_duration": "Trajanje predmemorije streama", - "cache_duration_desc": "Koliko dugo čuvati poveznice streamova prije nego što isteknu", - "important_note": "Važna napomena", - "important_note_text": "Sve poveznice streamova možda neće ostati aktivne cijelo vrijeme. Duže vrijeme predmemorije može rezultirati neispravnim poveznicama. U tom slučaju, aplikacija će ponovno dohvatiti svježe streamove.", - "how_it_works": "Kako radi", - "how_it_works_cached": "• Streamovi se spremaju na odabrano trajanje nakon gledanja\n• Predmemorirani streamovi se provjeravaju prije upotrebe\n• Ako je zapis nevažeći ili istekao, otvara se zaslon sadržaja\n• Opcija 'Koristi predmemorirane streamove' kontrolira izravni ulaz u player\n• 'Otvori zaslon s detaljima' pojavljuje se samo kad su predmemorirani streamovi isključeni", - "how_it_works_uncached": "• Kada su predmemorirani streamovi isključeni, klik otvara zaslone sadržaja\n• Opcija 'Otvori zaslon s detaljima' određuje koji će se zaslon otvoriti\n• Zaslon s metapodacima prikazuje detalje i ručni odabir\n• Zaslon sa streamovima prikazuje dostupne izvore za trenutnu reprodukciju", - "changes_saved": "Promjene spremljene", - "min": "min", - "hour": "sat", - "hours": "sati" - }, - "contributors": { - "title": "Suradnici", - "special_mentions": "Posebna priznanja", - "tab_contributors": "Suradnici", - "tab_special": "Posebna priznanja", - "tab_donors": "Donatori", - "manager_role": "Voditelj zajednice", - "manager_desc": "Upravlja Discord i Reddit zajednicama za Nuvio", - "sponsor_role": "Sponzor poslužitelja", - "sponsor_desc": "Sponzorirao infrastrukturu poslužitelja za Nuvio", - "mod_role": "Discord moderator", - "mod_desc": "Pomaže u moderiranju Nuvio Discord zajednice", - "loading": "Učitavanje...", - "discord_user": "Discord korisnik", - "contributions": "doprinosa", - "gratitude_title": "Zahvalni smo na svakom doprinosu", - "gratitude_desc": "Svaka linija koda, prijava pogreške i prijedlog pomažu da Nuvio postane bolji za sve", - "special_thanks_title": "Posebna hvala", - "special_thanks_desc": "Ovi nevjerojatni ljudi pomažu u održavanju Nuvio zajednice i poslužitelja", - "donors_desc": "Hvala vam što vjerujete u ono što gradimo. Vaša podrška drži Nuvio besplatnim i stalno ga poboljšava.", - "latest_donations": "Najnovije", - "leaderboard": "Poredak", - "loading_donors": "Učitavanje donatora...", - "no_donors": "Još nema donatora", - "error_rate_limit": "Prekoračeno ograničenje GitHub API-ja. Pokušajte kasnije.", - "error_failed": "Učitavanje suradnika nije uspjelo. Provjerite internetsku vezu.", - "retry": "Pokušaj ponovno", - "no_contributors": "Nisu pronađeni suradnici", - "loading_contributors": "Učitavanje suradnika..." - }, - "debrid": { - "title": "Debrid integracija", - "description_torbox": "Otključajte 4K streamove visoke kvalitete i munjevite brzine integracijom Torboxa. Unesite API ključ ispod za trenutnu nadogradnju iskustva gledanja.", - "description_torrentio": "Konfigurirajte Torrentio za torrent streamove filmova i serija. Debrid usluga je potrebna za gledanje sadržaja.", - "tab_torbox": "TorBox", - "tab_torrentio": "Torrentio", - "status_connected": "Povezano", - "status_disconnected": "Odspojeno", - "enable_addon": "Omogući dodatak", - "disconnect_button": "Odspoji i ukloni", - "disconnect_loading": "Odspajanje...", - "account_info": "Informacije o računu", - "plan": "Paket", - "plan_free": "Besplatno", - "plan_essential": "Essential (3 $/mj)", - "plan_pro": "Pro (10 $/mj)", - "plan_standard": "Standard (5 $/mj)", - "plan_unknown": "Nepoznato", - "expires": "Istječe", - "downloaded": "Preuzeto", - "status_active": "Aktivno", - "connected_title": "✓ Povezano s TorBoxom", - "connected_desc": "Vaš TorBox dodatak je aktivan i pruža premium streamove.", - "configure_title": "Konfiguriraj dodatak", - "configure_desc": "Prilagodite svoje iskustvo. Razvrstajte po kvaliteti, filtrirajte veličine datoteka i upravljajte postavkama.", - "open_settings": "Otvori postavke", - "what_is_debrid": "Što je Debrid usluga?", - "enter_api_key": "Unesite API ključ", - "connect_button": "Poveži i instaliraj", - "connecting": "Povezivanje...", - "unlock_speeds_title": "Otključaj premium brzine", - "unlock_speeds_desc": "Pretplatite se na Torbox za pristup predmemoriranim 4K streamovima bez učitavanja.", - "get_subscription": "Pretplati se", - "powered_by": "Pokreće", - "disclaimer_torbox": "Nuvio nije povezan s Torboxom ni na koji način.", - "disclaimer_torrentio": "Nuvio nije povezan s Torrentio dodatakom ni na koji način.", - "installed_badge": "✓ INSTALIRANO", - "promo_title": "⚡ Trebate Debrid uslugu?", - "promo_desc": "Nabavite TorBox za munjeviti 4K streaming bez trzanja. Premium torrenti i trenutna preuzimanja.", - "promo_button": "Nabavi TorBox pretplatu", - "service_label": "Debrid usluga *", - "api_key_label": "API ključ *", - "sorting_label": "Razvrstavanje", - "exclude_qualities": "Isključi kvalitete", - "priority_languages": "Prioritetni jezici", - "max_results": "Maksimalno rezultata", - "additional_options": "Dodatne opcije", - "no_download_links": "Ne prikazuj poveznice za preuzimanje", - "no_debrid_catalog": "Ne prikazuj debrid katalog", - "install_button": "Instaliraj Torrentio", - "installing": "Instalacija...", - "update_button": "Ažuriraj konfiguraciju", - "updating": "Ažuriranje...", - "remove_button": "Ukloni Torrentio", - "error_api_required": "Potreban API ključ", - "error_api_required_desc": "Unesite API ključ debrid usluge za instalaciju Torrentia.", - "success_installed": "Torrentio dodatak je uspješno instaliran!", - "success_removed": "Torrentio dodatak je uspješno uklonjen", - "alert_disconnect_title": "Odspoji Torbox", - "alert_disconnect_msg": "Jeste li sigurni da želite odspojiti Torbox? Ovo će ukloniti dodatak i obrisati API ključ." - }, - "home_screen": { - "title": "Postavke početnog zaslona", - "changes_applied": "Promjene primijenjene", - "display_options": "OPCIJE PRIKAZA", - "show_hero": "Prikaži istaknuti sadržaj", - "show_hero_desc": "Istaknuti sadržaj na vrhu", - "show_this_week": "Prikaži 'Ovaj tjedan'", - "show_this_week_desc": "Nove epizode iz tekućeg tjedna", - "select_catalogs": "Odaberi kataloge", - "all_catalogs": "Svi katalozi", - "selected": "odabrano", - "hero_layout": "Izgled istaknutog sadržaja", - "layout_legacy": "Klasično", - "layout_carousel": "Vrtuljak", - "layout_appletv": "Apple TV", - "layout_desc": "Banner pune širine, kartice koje se listaju ili Apple TV stil", - "featured_source": "Izvor istaknutog sadržaja", - "using_catalogs": "Koriste se katalozi", - "manage_selected_catalogs": "Upravljaj odabranim katalozima", - "dynamic_bg": "Dinamična pozadina", - "dynamic_bg_desc": "Zamućeni banner iza vrtuljka", - "performance_note": "Može utjecati na performanse na slabijim uređajima.", - "posters": "Posteri", - "show_titles": "Prikaži naslove", - "poster_size": "Veličina postera", - "poster_corners": "Kutovi postera", - "size_small": "Mali", - "size_medium": "Srednji", - "size_large": "Veliki", - "corners_square": "Oštri", - "corners_rounded": "Zaobljeni", - "corners_pill": "Ovalni", - "about_these_settings": "O OVIM POSTAVKAMA", - "about_desc": "Ove postavke kontroliraju kako se sadržaj prikazuje na vašem početnom zaslonu. Promjene se primjenjuju odmah bez potrebe za ponovnim pokretanjem aplikacije.", - "hero_catalogs": { - "title": "Katalozi istaknutog sadržaja", - "select_all": "Odaberi sve", - "clear_all": "Očisti sve", - "info": "Odaberite koji će se katalozi prikazivati u odjeljku s istaknutim sadržajem. Ako ništa nije odabrano, koristit će se svi katalozi. Ne zaboravite pritisnuti 'Spremi' kada završite.", - "settings_saved": "Postavke spremljene", - "error_load": "Učitavanje kataloga nije uspjelo", - "movies": "Filmovi", - "tv_shows": "Serije" - } - }, - "calendar": { - "title": "Kalendar", - "loading": "Učitavanje kalendara...", - "no_scheduled_episodes": "Nema zakazanih epizoda", - "check_back_later": "Provjerite ponovno kasnije", - "showing_episodes_for": "Prikaz epizoda za {{date}}", - "show_all_episodes": "Prikaži sve epizode", - "no_episodes_for": "Nema epizoda za {{date}}", - "no_upcoming_found": "Nisu pronađene nadolazeće epizode", - "add_series_desc": "Dodajte serije u svoju knjižnicu kako biste ovdje vidjeli njihove nadolazeće epizode" - }, - "mdblist": { - "title": "Izvori ocjena", - "status_disabled": "MDBList onemogućen", - "status_active": "API ključ aktivan", - "status_required": "Potreban API ključ", - "status_disabled_desc": "MDBList funkcionalnost je trenutno isključena.", - "status_active_desc": "Ocjene s MDBList-a su omogućene.", - "status_required_desc": "Dodajte svoj ključ ispod kako biste omogućili ocjene.", - "enable_toggle": "Omogući MDBList", - "enable_toggle_desc": "Uključi/isključi sve MDBList funkcionalnosti", - "api_section": "API ključ", - "placeholder": "Zalijepite svoj MDBList API ključ", - "save": "Spremi", - "clear": "Obriši ključ", - "rating_providers": "Pružatelji ocjena", - "rating_providers_desc": "Odaberite čije će se ocjene prikazivati u aplikaciji", - "how_to": "Kako dobiti API ključ", - "step_1": "Prijavite se na", - "step_1_link": "MDBList web stranici", - "step_2": "Idite na odjeljak", - "step_2_settings": "Postavke", - "step_2_api": "API", - "step_2_end": ".", - "step_3": "Generirajte novi ključ i kopirajte ga.", - "go_to_website": "Posjeti MDBList", - "alert_clear_title": "Brisanje API ključa", - "alert_clear_msg": "Jeste li sigurni da želite ukloniti spremljeni API ključ?", - "success_saved": "API ključ uspješno spremljen.", - "error_empty": "API ključ ne može biti prazan.", - "error_save": "Došlo je do pogreške pri spremanju. Pokušajte ponovno.", - "api_key_empty_error": "API ključ ne može biti prazan.", - "success_cleared": "API ključ uspješno obrisan", - "error_clear": "Brisanje API ključa nije uspjelo" - }, - "notification": { - "title": "Postavke obavijesti", - "section_general": "Općenito", - "enable_notifications": "Omogući obavijesti", - "section_types": "Vrste obavijesti", - "new_episodes": "Nove epizode", - "upcoming_shows": "Nadolazeće serije", - "reminders": "Podsjetnici", - "section_timing": "Vrijeme obavijesti", - "timing_desc": "Kada želite primiti obavijest prije emitiranja epizode?", - "hours_1": "1 sat", - "hours_suffix": "sati", - "section_status": "Status obavijesti", - "stats_upcoming": "Nadolazeće", - "stats_this_week": "Ovaj tjedan", - "stats_total": "Ukupno", - "sync_button": "Sinkroniziraj knjižnicu i Trakt", - "syncing": "Sinkronizacija...", - "sync_desc": "Automatski sinkronizira obavijesti za sve serije u vašoj knjižnici i Trakt listama.", - "section_advanced": "Napredno", - "reset_button": "Resetiraj sve obavijesti", - "test_button": "Testiraj obavijest (5 sek)", - "test_notification_in": "Obavijest za {{seconds}}s...", - "test_notification_text": "Obavijest će se pojaviti za {{seconds}} sekundi", - "alert_reset_title": "Resetiraj obavijesti", - "alert_reset_msg": "Ovo će otkazati sve zakazane obavijesti, ali neće ukloniti ništa iz vaše knjižnice. Jeste li sigurni?", - "alert_reset_success": "Sve obavijesti su resetirane", - "alert_sync_complete": "Sinkronizacija dovršena", - "alert_sync_msg": "Uspješno sinkronizirane obavijesti za vašu knjižnicu i Trakt stavke.\n\nZakazano: {{upcoming}} nadolazećih epizoda\nOvaj tjedan: {{thisWeek}} epizoda", - "alert_test_scheduled": "Testna obavijest zakazana za trenutno prikazivanje" - }, - "backup": { - "title": "Sigurnosna kopija i oporavak", - "options_title": "Opcije sigurnosne kopije", - "options_desc": "Odaberite što želite uključiti u sigurnosnu kopiju", - "section_core": "Osnovni podaci", - "section_addons": "Dodaci i integracije", - "section_settings": "Postavke i preferencije", - "library_label": "Knjižnica", - "library_desc": "Vaši spremljeni filmovi i serije", - "watch_progress_label": "Napredak gledanja", - "watch_progress_desc": "Pozicije 'Nastavi gledati'", - "addons_label": "Dodaci", - "addons_desc": "Instalirani Stremio dodaci", - "plugins_label": "Priključci", - "plugins_desc": "Prilagođene konfiguracije strugača", - "trakt_label": "Trakt integracija", - "trakt_desc": "Sinkronizacija podataka i tokeni za prijavu", - "app_settings_label": "Postavke aplikacije", - "app_settings_desc": "Tema, preferencije i konfiguracije", - "user_prefs_label": "Korisničke preferencije", - "user_prefs_desc": "Redoslijed dodataka i postavke sučelja", - "catalog_settings_label": "Postavke kataloga", - "catalog_settings_desc": "Filteri kataloga i preferencije", - "api_keys_label": "API ključevi", - "api_keys_desc": "MDBList i OpenRouter ključevi", - "action_create": "Stvori sigurnosnu kopiju", - "action_restore": "Vrati iz sigurnosne kopije", - "section_info": "O sigurnosnim kopijama", - "info_text": "• Prilagodite što se sprema pomoću prekidača iznad\n• Datoteke se pohranjuju lokalno na vašem uređaju\n• Podijelite sigurnosnu kopiju za prijenos podataka na drugi uređaj\n• Oporavak će prepisati vaše trenutne podatke", - "alert_create_title": "Stvori sigurnosnu kopiju", - "alert_no_content": "Nije odabran sadržaj za kopiju.\n\nMolimo omogućite barem jednu opciju iznad.", - "alert_backup_created_title": "Kopija stvorena", - "alert_backup_created_msg": "Vaša sigurnosna kopija je spremna i možete je podijeliti.", - "alert_backup_failed_title": "Stvaranje kopije nije uspjelo", - "alert_restore_confirm_title": "Potvrdi oporavak", - "alert_restore_confirm_msg": "Ovo će vratiti vaše podatke iz kopije stvorene {{date}}.\n\nOva radnja će prepisati trenutne podatke. Želite li nastaviti?", - "alert_restore_complete_title": "Oporavak dovršen", - "alert_restore_complete_msg": "Vaši podaci su uspješno vraćeni. Ponovno pokrenite aplikaciju za primjenu promjena.", - "alert_restore_failed_title": "Oporavak nije uspio", - "restart_app": "Ponovno pokreni aplikaciju", - "alert_restart_failed_title": "Ponovno pokretanje nije uspjelo", - "alert_restart_failed_msg": "Neuspjelo ponovno pokretanje. Ručno zatvorite i otvorite aplikaciju." - }, - "updates": { - "title": "Ažuriranja aplikacije", - "status_checking": "Provjera ažuriranja...", - "status_available": "Ažuriranje dostupno!", - "status_downloading": "Preuzimanje ažuriranja...", - "status_installing": "Instalacija ažuriranja...", - "status_success": "Ažuriranje uspješno instalirano!", - "status_error": "Ažuriranje nije uspjelo", - "status_ready": "Spremno za provjeru", - "action_check": "Provjeri ažuriranja", - "action_install": "Instaliraj ažuriranje", - "release_notes": "Napomene o izdanju:", - "version": "Verzija:", - "last_checked": "Zadnja provjera:", - "current_version": "Trenutna verzija:", - "current_release_notes": "Trenutne napomene o izdanju:", - "github_release": "GITHUB IZDANJE", - "current": "Trenutna:", - "latest": "Najnovija:", - "notes": "Bilješke:", - "view_release": "Pogledaj izdanje", - "notification_settings": "POSTAVKE OBAVIJESTI", - "ota_alerts_label": "OTA upozorenja o ažuriranju", - "ota_alerts_desc": "Prikaži obavijesti za bežična (OTA) ažuriranja", - "major_alerts_label": "Glavna upozorenja o ažuriranju", - "major_alerts_desc": "Prikaži obavijesti za nove verzije na GitHubu", - "alert_disable_ota_title": "Onemogućiti OTA upozorenja?", - "alert_disable_ota_msg": "Više nećete primati obavijesti o OTA ažuriranjima.\n\n⚠️ Upozorenje: Najnovija verzija je važna za:\n• Ispravke bugova i stabilnost\n• Nove značajke\n• Točne izvještaje o padu aplikacije\n\nI dalje možete ručno provjeriti ažuriranja.", - "alert_disable_major_title": "Onemogućiti glavna upozorenja?", - "alert_disable_major_msg": "Više nećete primati obavijesti o verzijama koje zahtijevaju ponovnu instalaciju.\n\n⚠️ Upozorenje: Glavna ažuriranja sadrže:\n• Kritične sigurnosne zakrpe\n• Velike promjene sustava\n• Važne ispravke kompatibilnosti", - "warning_note": "Omogućena upozorenja osiguravaju da dobijete ispravke i nove mogućnosti.", - "disable": "Onemogući", - "alert_no_update_to_install": "Nema dostupnog ažuriranja za instalaciju", - "alert_install_failed": "Instalacija ažuriranja nije uspjela", - "alert_no_update_title": "Nema ažuriranja", - "alert_update_applied_msg": "Ažuriranje će se primijeniti kod sljedećeg pokretanja" - }, - "player": { - "title": "Video player", - "section_selection": "ODABIR PLAYERA", - "internal_title": "Ugrađeni player", - "internal_desc": "Koristi zadani player aplikacije", - "vlc_title": "VLC", - "vlc_desc": "Otvori streamove u VLC-u", - "infuse_title": "Infuse", - "infuse_desc": "Otvori streamove u Infuse-u", - "outplayer_title": "OutPlayer", - "outplayer_desc": "Otvori streamove u OutPlayeru", - "vidhub_title": "VidHub", - "vidhub_desc": "Otvori streamove u VidHubu", - "infuse_live_title": "Infuse LiveContainer", - "infuse_live_desc": "Otvori streamove u Infuse-u putem LiveContainera", - "external_title": "Vanjski player", - "external_desc": "Otvori streamove u svom željenom playeru", - "section_playback": "OPCIJE REPRODUKCIJE", - "skip_intro_settings_title": "Preskoči uvod", - "powered_by_introdb": "Pokreće IntroDB", - "autoplay_title": "Automatska reprodukcija prvog streama", - "autoplay_desc": "Automatski pokreni prvi stream s popisa.", - "resume_title": "Uvijek nastavi", - "resume_desc": "Preskoči upit i nastavi tamo gdje ste stali (ako je odgledano manje od 85%).", - "engine_title": "Engine video playera", - "engine_desc": "Auto koristi ExoPlayer uz MPV kao rezervu. Neki formati poput Dolby Vision možda nisu podržani na MPV-u.", - "decoder_title": "Način dekodiranja", - "decoder_desc": "Kako se video dekodira. 'Auto' se preporučuje.", - "gpu_title": "GPU renderiranje", - "gpu_desc": "GPU-Next nudi bolje HDR upravljanje bojama.", - "external_downloads_title": "Vanjski player za preuzimanja", - "external_downloads_desc": "Reproduciraj preuzeti sadržaj u vanjskom playeru.", - "restart_required": "Potrebno ponovno pokretanje", - "restart_msg_decoder": "Ponovno pokrenite aplikaciju za primjenu promjena dekodera.", - "restart_msg_gpu": "Ponovno pokrenite aplikaciju za primjenu GPU načina.", - "option_auto": "Auto", - "option_auto_desc_engine": "ExoPlayer + MPV rezerva", - "option_mpv": "MPV", - "option_mpv_desc": "Samo MPV", - "option_auto_desc_decoder": "Najbolji balans", - "option_sw": "SW", - "option_sw_desc": "Softversko", - "option_hw": "HW", - "option_hw_desc": "Hardversko", - "option_hw_plus": "HW+", - "option_hw_plus_desc": "Puni HW", - "option_gpu_desc": "Standardno", - "option_gpu_next_desc": "Napredno" - }, - "plugins": { - "title": "Priključci", - "enable_title": "Omogući priključke", - "enable_desc": "Omogući sustav priključaka za pronalaženje vanjskih izvora medija", - "repo_config_title": "Konfiguracija repozitorija", - "repo_config_desc": "Upravljajte vanjskim repozitorijima priključaka.", - "your_repos": "Repozitoriji", - "your_repos_desc": "Konfigurirajte vanjske izvore za priključke.", - "add_repo_button": "Dodaj repozitorij", - "refresh": "Osvježi", - "remove": "Ukloni", - "enabled": "Omogućeno", - "disabled": "Onemogućeno", - "updating": "Ažuriranje...", - "success": "Uspjeh", - "error": "Pogreška", - "alert_repo_added": "Repozitorij dodan i priključci uspješno učitani", - "alert_repo_saved": "URL repozitorija uspješno spremljen", - "alert_repo_refreshed": "Repozitorij uspješno osvježen", - "alert_invalid_url": "Nevažeći URL format", - "alert_plugins_cleared": "Svi priključci su uklonjeni", - "alert_cache_cleared": "Predmemorija repozitorija uspješno očišćena", - "unknown": "Nepoznato", - "active": "Aktivno", - "available": "Dostupno", - "platform_disabled": "Platforma onemogućena", - "limited": "Ograničeno", - "clear_all": "Ukloni sve priključke", - "clear_all_desc": "Jeste li sigurni da želite ukloniti sve instalirane priključke? To se ne može poništiti.", - "clear_cache": "Očisti predmemoriju repozitorija", - "clear_cache_desc": "Ovo će ukloniti URL i podatke repozitorija. Morat ćete ga ponovno unijeti.", - "add_new_repo": "Dodaj novi repozitorij", - "available_plugins": "Dostupni priključci ({{count}})", - "placeholder": "Pretraži priključke...", - "all": "Svi", - "filter_all": "Sve vrste", - "filter_movies": "Filmovi", - "filter_tv": "Serije", - "enable_all": "Omogući sve", - "disable_all": "Onemogući sve", - "no_plugins_found": "Priključci nisu pronađeni", - "no_plugins_available": "Nema dostupnih priključaka", - "no_match_desc": "Nema rezultata za \"{{query}}\". Pokušajte s drugim pojmom.", - "configure_repo_desc": "Konfigurirajte repozitorij iznad za prikaz priključaka.", - "clear_search": "Očisti pretragu", - "no_external_player": "Nema vanjskog playera", - "showbox_token": "ShowBox UI Token", - "showbox_placeholder": "Zalijepite svoj ShowBox UI token", - "save": "Spremi", - "clear": "Očisti", - "additional_settings": "Dodatne postavke", - "enable_url_validation": "Omogući provjeru URL-a", - "url_validation_desc": "Provjeri ispravnost poveznica (može usporiti rezultate, ali povećava pouzdanost)", - "group_streams": "Grupiraj izvore priključaka", - "group_streams_desc": "Izvori će biti grupirani prema repozitoriju.", - "sort_quality": "Poredaj prvo po kvaliteti", - "sort_quality_desc": "Izvori se prvo razvrstavaju po kvaliteti (samo uz grupiranje).", - "show_logos": "Prikaži logotipe priključaka", - "show_logos_desc": "Prikaži logotipe pored izvora na zaslonu odabira.", - "quality_filtering": "Filtriranje kvalitete", - "quality_filtering_desc": "Isključi određene rezolucije iz rezultata.", - "excluded_qualities": "Isključene kvalitete:", - "language_filtering": "Filtriranje jezika", - "language_filtering_desc": "Isključi određene jezike iz rezultata.", - "note": "Napomena:", - "language_filtering_note": "Ovo se primjenjuje samo na pružatelje koji šalju informaciju o jeziku.", - "excluded_languages": "Isključeni jezici:", - "about_title": "O priključcima", - "about_desc_1": "Priključci su modularne komponente koje prilagođavaju sadržaj s vanjskih protokola. Rade lokalno na vašem uređaju.", - "about_desc_2": "Priključci označeni kao \"Ograničeno\" mogu zahtijevati dodatne konfiguracije.", - "help_title": "Postavljanje priključaka", - "help_step_1": "1. **Omogući priključke** - Uključite glavni prekidač", - "help_step_2": "2. **Dodaj repozitorij** - Unesite ispravan URL", - "help_step_3": "3. **Osvježi** - Povucite popis dostupnih priključaka", - "help_step_4": "4. **Aktiviraj** - Omogućite željene priključke", - "got_it": "Razumijem!", - "repo_format_hint": "Format: https://raw.githubusercontent.com/korisnik/repo/refs/heads/branch", - "cancel": "Odustani", - "add": "Dodaj" - }, - "theme": { - "title": "Teme aplikacije", - "select_theme": "ODABERI TEMU", - "create_custom": "Stvori vlastitu temu", - "options": "OPCIJE", - "use_dominant_color": "Koristi dominantnu boju s postera", - "categories": { - "all": "Sve teme", - "dark": "Tamne teme", - "colorful": "Šarene", - "custom": "Moje teme" - }, - "editor": { - "theme_name_placeholder": "Naziv teme", - "save": "Spremi", - "primary": "Primarna", - "secondary": "Sekundarna", - "background": "Pozadina", - "invalid_name_title": "Nevažeći naziv", - "invalid_name_msg": "Molimo unesite ispravan naziv teme" - }, - "alerts": { - "delete_title": "Obriši temu", - "delete_msg": "Jeste li sigurni da želite obrisati temu \"{{name}}\"?", - "ok": "U redu", - "delete": "Obriši", - "cancel": "Odustani", - "back": "Postavke" - } - }, - "legal": { - "title": "Pravne napomene i odricanje odgovornosti", - "intro_title": "Priroda aplikacije", - "intro_text": "Nuvio je media player i aplikacija za upravljanje metapodacima. Djeluje isključivo kao klijentsko sučelje za pregledavanje javno dostupnih informacija (filmovi, serije) i reprodukciju datoteka koje osigura korisnik ili dodaci trećih strana. Nuvio ne ugošćuje, ne distribuira niti indeksira nikakav medijski sadržaj.", - "extensions_title": "Priključci trećih strana", - "extensions_text": "Nuvio omogućuje instalaciju priključaka koje razvijaju neovisni programeri. Nemamo kontrolu niti odgovornost za sadržaj, zakonitost ili funkcionalnost tih priključaka.", - "user_resp_title": "Odgovornost korisnika", - "user_resp_text": "Korisnici su isključivo odgovorni za priključke koje instaliraju. Korištenjem aplikacije pristajete osigurati da imate zakonsko pravo na pristup sadržaju koji gledate. Autori Nuvia ne potiču kršenje autorskih prava.", - "dmca_title": "Autorska prava i DMCA", - "dmca_text": "Nuvio ne ugošćuje sadržaj pa ga ne može ukloniti s interneta. Ako smatrate da samo sučelje krši vaša prava, kontaktirajte nas.", - "warranty_title": "Bez jamstva", - "warranty_text": "Softver se isporučuje \"kakav jest\", bez ikakvih jamstava. Autori nisu odgovorni za bilo kakvu štetu nastalu korištenjem ovog softvera." - }, - "plugin_tester": { - "title": "Plugin Tester", - "subtitle": "Pokrenite strugače i pratite zapise u stvarnom vremenu", - "tabs": { - "individual": "Pojedinačno", - "repo": "Repo Tester", - "code": "Kod", - "logs": "Zapisi", - "results": "Rezultati" - }, - "common": { - "error": "Pogreška", - "success": "Uspjeh", - "movie": "Film", - "tv": "Serija", - "tmdb_id": "TMDB ID", - "season": "Sezona", - "episode": "Epizoda", - "running": "Pokretanje…", - "run_test": "Pokreni test", - "play": "Reproduciraj", - "done": "Gotovo", - "test": "Test", - "testing": "Testiranje…" - }, - "individual": { - "load_from_url": "Učitaj s URL-a", - "load_from_url_desc": "Zalijepite GitHub URL ili lokalni IP i preuzmite.", - "enter_url_error": "Molimo unesite URL", - "code_loaded": "Kod učitan s URL-a", - "fetch_error": "Dohvaćanje nije uspjelo: {{message}}", - "no_code_error": "Nema koda za pokretanje", - "plugin_code": "Kod priključka", - "focus_editor": "Fokusiraj uređivač koda", - "code_placeholder": "// Zalijepite kod priključka ovdje...", - "test_parameters": "Testni parametri", - "no_logs": "Još nema zapisa. Pokrenite test.", - "no_streams": "Nisu pronađeni streamovi.", - "streams_found": "Pronađen {{count}} stream", - "streams_found_plural": "Pronađeno {{count}} streama", - "tap_play_hint": "Dodirnite 'Reproduciraj' za testiranje streama.", - "unnamed_stream": "Neimenovani stream", - "quality": "Kvaliteta: {{quality}}", - "size": "Veličina: {{size}}", - "url_label": "URL: {{url}}", - "headers_info": "Zaglavlja: {{count}} prilagođenih", - "find_placeholder": "Pronađi u kodu…", - "edit_code_title": "Uredi kod", - "no_url_stream_error": "Nije pronađen URL za ovaj stream" - }, - "repo": { - "title": "Repo Tester", - "description": "Dohvatite repozitorij i testirajte svakog pružatelja.", - "enter_repo_url_error": "Unesite URL repozitorija", - "invalid_url_title": "Nevažeći URL", - "invalid_url_msg": "Koristite GitHub raw URL ili lokalni http(s) URL.", - "manifest_build_error": "Neuspjelo stvaranje URL-a manifesta", - "manifest_fetch_error": "Neuspjelo dohvaćanje manifesta", - "repo_manifest_fetch_error": "Neuspjelo dohvaćanje manifesta repozitorija", - "missing_filename": "Nedostaje naziv datoteke u manifestu", - "scraper_build_error": "Neuspjelo stvaranje URL-a strugača", - "download_scraper_error": "Neuspjelo preuzimanje strugača", - "test_failed": "Test nije uspio", - "test_parameters": "Parametri Repo testa", - "test_parameters_desc": "Ovi se parametri koriste samo za Repo Tester.", - "using_info": "Koristi se: {{mediaType}} • TMDB {{tmdbId}}", - "using_info_tv": "Koristi se: {{mediaType}} • TMDB {{tmdbId}} • S{{season}}E{{episode}}", - "providers_title": "Pružatelji", - "repository_default": "Repozitorij", - "providers_count": "{{count}} pružatelja", - "fetch_hint": "Dohvatite repo za popis pružatelja.", - "test_all": "Testiraj sve", - "status_running": "U TIJEKU", - "status_ok": "OK ({{count}})", - "status_ok_empty": "OK (0)", - "status_failed": "NEUSPJEH", - "status_idle": "ČEKANJE", - "tried_url": "Pokušano: {{url}}", - "provider_logs": "Zapisi pružatelja", - "no_logs_captured": "Nema zapisa." - } - } - -} +{ + "common": { + "loading": "Učitavanje...", + "cancel": "Odustani", + "save": "Spremi", + "delete": "Obriši", + "edit": "Uredi", + "search": "Pretraži", + "error": "Pogreška", + "success": "Uspjeh", + "ok": "U redu", + "unknown": "Nepoznato", + "retry": "Pokušaj ponovno", + "try_again": "Pokušajte ponovno", + "go_back": "Idi natrag", + "settings": "Postavke", + "close": "Zatvori", + "enable": "Omogući", + "disable": "Onemogući", + "show_more": "Prikaži više", + "show_less": "Prikaži manje", + "load_more": "Učitaj više", + "unknown_date": "Nepoznat datum", + "anonymous_user": "Anonimni korisnik", + "time": { + "now": "Upravo sada", + "minutes_ago": "prije {{count}} min", + "hours_ago": "prije {{count}} h", + "days_ago": "prije {{count}} d" + }, + "days_short": { + "sun": "Ned", + "mon": "Pon", + "tue": "Uto", + "wed": "Sri", + "thu": "Čet", + "fri": "Pet", + "sat": "Sub" + }, + "email": "E-pošta", + "status": "Status" + }, + "home": { + "categories": { + "movies": "Filmovi", + "series": "Serije", + "channels": "Kanali" + }, + "movies": "Filmovi", + "tv_shows": "Serije", + "load_more_catalogs": "Učitaj više kataloga", + "no_content": "Sadržaj nije dostupan", + "add_catalogs": "Dodaj kataloge", + "sign_in_available": "Prijava je dostupna", + "sign_in_desc": "Možete se prijaviti bilo kada u Postavke → Račun", + "view_all": "Vidi sve", + "this_week": "Ovaj tjedan", + "upcoming": "Dolazeće", + "recently_released": "Nedavno objavljeno", + "no_scheduled_episodes": "Serije bez zakazanih epizoda", + "check_back_later": "Provjerite kasnije", + "continue_watching": "Nastavi gledati", + "up_next": "Sljedeće", + "up_next_caps": "SLJEDEĆE", + "released": "Objavljeno", + "new": "Novo", + "tba": "Bit će objavljeno", + "new_episodes": "{{count}} nove epizode", + "season_short": "S{{season}}", + "episode_short": "E{{episode}}", + "season": "Sezona {{season}}", + "episode": "Epizoda {{episode}}", + "movie": "Film", + "series": "Serija", + "tv_show": "Serija", + "percent_watched": "{{percent}}% pogledano", + "view_details": "Vidi detalje", + "remove": "Ukloni", + "play": "Reproduciraj", + "play_now": "Pokreni odmah", + "resume": "Nastavi", + "info": "Informacije", + "more_info": "Više informacija", + "my_list": "Moj popis", + "save": "Spremi", + "saved": "Spremljeno", + "retry": "Pokušaj ponovno", + "install_addons": "Instaliraj dodatke", + "settings": "Postavke", + "no_featured_content": "Nema istaknutog sadržaja", + "couldnt_load_featured": "Nije moguće učitati istaknuti sadržaj", + "no_featured_desc": "Instalirajte dodatke s katalozima ili promijenite izvor sadržaja u postavkama.", + "load_error_desc": "Došlo je do problema prilikom dohvaćanja istaknutog sadržaja. Provjerite vezu i pokušajte ponovno.", + "no_featured_available": "Nema dostupnog istaknutog sadržaja", + "no_description": "Opis nije dostupan" + }, + "navigation": { + "home": "Početna", + "library": "Knjižnica", + "search": "Pretraživanje", + "downloads": "Preuzimanja", + "settings": "Postavke" + }, + "search": { + "title": "Pretraživanje", + "recent_searches": "Nedavna pretraživanja", + "discover": "Otkrij", + "movies": "Filmovi", + "tv_shows": "Serije", + "select_catalog": "Odaberi katalog", + "all_genres": "Svi žanrovi", + "discovering": "Otkrivanje sadržaja...", + "show_more": "Prikaži više ({{count}})", + "no_content_found": "Sadržaj nije pronađen", + "try_different": "Pokušajte s drugim žanrom ili katalogom", + "select_catalog_desc": "Odaberite katalog za istraživanje", + "tap_catalog_desc": "Dodirnite karticu kataloga iznad za početak", + "placeholder": "Pretraži filmove, serije...", + "keep_typing": "Nastavite tipkati...", + "type_characters": "Upišite barem 2 znaka za pretraživanje", + "no_results": "Nema rezultata", + "try_keywords": "Pokušajte s drugim ključnim riječima ili provjerite pravopis", + "select_type": "Odaberi vrstu", + "browse_movies": "Pregledaj kataloge filmova", + "browse_tv": "Pregledaj kataloge serija", + "select_genre": "Odaberi žanr", + "show_all_content": "Prikaži sav sadržaj", + "genres_count": "{{count}} žanrova" + }, + "library": { + "title": "Knjižnica", + "watched": "Pogledano", + "continue": "Nastavi", + "watchlist": "Popis za gledanje", + "collection": "Kolekcija", + "rated": "Ocijenjeno", + "items": "stavki", + "trakt_collections": "Trakt kolekcije", + "trakt_collection": "Trakt kolekcija", + "no_trakt": "Nema Trakt kolekcija", + "no_trakt_desc": "Vaše Trakt kolekcije pojavit će se ovdje kada počnete koristiti Trakt", + "load_collections": "Učitaj kolekcije", + "empty_folder": "Nema sadržaja u {{folder}}", + "empty_folder_desc": "Ova kolekcija je prazna", + "refresh": "Osvježi", + "no_movies": "Još nema filmova", + "no_series": "Još nema serija", + "no_content": "Još nema sadržaja", + "add_content_desc": "Dodajte sadržaj u svoju knjižnicu kako biste ga vidjeli ovdje", + "find_something": "Pronađi nešto za gledanje", + "removed_from_library": "Uklonjeno iz knjižnice", + "item_removed": "Stavka je uklonjena iz vaše knjižnice", + "failed_update_library": "Ažuriranje knjižnice nije uspjelo", + "unable_remove": "Nije moguće ukloniti stavku iz knjižnice", + "marked_watched": "Označeno kao pogledano", + "marked_unwatched": "Označeno kao nepogledano", + "item_marked_watched": "Stavka je označena kao pogledana", + "item_marked_unwatched": "Stavka je označena kao nepogledana", + "failed_update_watched": "Ažuriranje statusa gledanja nije uspjelo", + "unable_update_watched": "Nije moguće ažurirati status gledanja", + "added_to_library": "Dodano u knjižnicu", + "item_added": "Dodano u vašu lokalnu knjižnicu", + "add_to_library": "Dodaj u knjižnicu", + "remove_from_library": "Ukloni iz knjižnice", + "mark_watched": "Označi kao pogledano", + "mark_unwatched": "Označi kao nepogledano", + "share": "Podijeli", + "add_to_watchlist": "Dodaj na Trakt popis za gledanje", + "remove_from_watchlist": "Ukloni s Trakt popisa za gledanje", + "added_to_watchlist": "Dodano na popis za gledanje", + "added_to_watchlist_desc": "Dodano na vaš Trakt popis za gledanje", + "removed_from_watchlist": "Uklonjeno s popisa za gledanje", + "removed_from_watchlist_desc": "Uklonjeno s vašeg Trakt popisa za gledanje", + "add_to_collection": "Dodaj u Trakt kolekciju", + "remove_from_collection": "Ukloni iz Trakt kolekcije", + "added_to_collection": "Dodano u kolekciju", + "added_to_collection_desc": "Dodano u vašu Trakt kolekciju", + "removed_from_collection": "Uklonjeno iz kolekcije", + "removed_from_collection_desc": "Uklonjeno iz vaše Trakt kolekcije" + }, + "metadata": { + "unable_to_load": "Nije moguće učitati sadržaj", + "error_code": "Šifra pogreške: {{code}}", + "content_not_found": "Sadržaj nije pronađen", + "content_not_found_desc": "Ovaj sadržaj ne postoji ili je možda uklonjen.", + "server_error": "Pogreška poslužitelja", + "server_error_desc": "Poslužitelj je privremeno nedostupan. Pokušajte ponovno kasnije.", + "bad_gateway": "Loš gateway", + "bad_gateway_desc": "Poslužitelj ima poteškoća. Pokušajte ponovno kasnije.", + "service_unavailable": "Usluga nedostupna", + "service_unavailable_desc": "Usluga je trenutno na održavanju. Pokušajte ponovno kasnije.", + "too_many_requests": "Previše zahtjeva", + "too_many_requests_desc": "Šaljete previše zahtjeva. Pričekajte trenutak i pokušajte ponovno.", + "request_timeout": "Istek vremena zahtjeva", + "request_timeout_desc": "Zahtjev je trajao predugo. Pokušajte ponovno.", + "network_error": "Pogreška mreže", + "network_error_desc": "Provjerite internetsku vezu i pokušajte ponovno.", + "auth_error": "Pogreška autentifikacije", + "auth_error_desc": "Provjerite postavke računa i pokušajte ponovno.", + "access_denied": "Pristup odbijen", + "access_denied_desc": "Nemate dopuštenje za pristup ovom sadržaju.", + "connection_error": "Pogreška veze", + "streams_unavailable": "Streaming izvori nedostupni", + "streams_unavailable_desc": "Izvori za streaming trenutno su nedostupni. Pokušajte ponovno kasnije.", + "unknown_error": "Nepoznata pogreška", + "something_went_wrong": "Nešto je pošlo po zlu. Pokušajte ponovno.", + "cast": "Glumačka postava", + "more_like_this": "Slično ovome", + "collection": "Kolekcija", + "episodes": "Epizode", + "seasons": "Sezone", + "posters": "Posteri", + "banners": "Banneri", + "specials": "Specijali", + "season_number": "Sezona {{number}}", + "episode_count": "{{count}} epizoda", + "episode_count_plural": "{{count}} epizoda", + "no_episodes": "Nema dostupnih epizoda", + "no_episodes_for_season": "Nema dostupnih epizoda za Sezonu {{season}}", + "episodes_not_released": "Epizode možda još nisu objavljene", + "no_description": "Opis nije dostupan", + "episode_label": "EPIZODA {{number}}", + "watch_again": "Gledaj ponovno", + "completed": "Završeno", + "play_episode": "Reproduciraj S{{season}}E{{episode}}", + "play": "Reproduciraj", + "watched": "Pogledano", + "watched_on_trakt": "Pogledano na Traktu", + "synced_with_trakt": "Sinkronizirano s Traktom", + "saved": "Spremljeno", + "director": "Redatelj", + "directors": "Redatelji", + "creator": "Autor", + "creators": "Autori", + "production": "Produkcija", + "network": "Mreža", + "mark_watched": "Označi kao pogledano", + "mark_unwatched": "Označi kao nepogledano", + "marking": "Označavanje...", + "removing": "Uklanjanje...", + "unmark_season": "Odznači Sezonu {{season}}", + "mark_season": "Označi Sezonu {{season}}", + "resume": "Nastavi", + "spoiler_warning": "Upozorenje o spoilerima", + "spoiler_warning_desc": "Ovaj komentar sadrži spoilere. Jeste li sigurni da ga želite otkriti?", + "cancel": "Odustani", + "reveal_spoilers": "Otkrij spoilere", + "movie_details": "Detalji o filmu", + "show_details": "Detalji o seriji", + "tagline": "Slogan", + "status": "Status", + "release_date": "Datum izlaska", + "runtime": "Trajanje", + "budget": "Budžet", + "revenue": "Prihod", + "origin_country": "Zemlja podrijetla", + "original_language": "Izvorni jezik", + "first_air_date": "Datum prve emisije", + "last_air_date": "Datum zadnje emisije", + "total_episodes": "Ukupno epizoda", + "episode_runtime": "Trajanje epizode", + "created_by": "Autor", + "backdrop_gallery": "Galerija pozadina", + "loading_episodes": "Učitavanje epizoda...", + "no_episodes_available": "Nema dostupnih epizoda", + "play_next": "Reproduciraj S{{season}}E{{episode}}", + "play_next_episode": "Reproduciraj sljedeću epizodu", + "save": "Spremi", + "percent_watched": "{{percent}}% pogledano", + "percent_watched_trakt": "{{percent}}% pogledano ({{traktPercent}}% na Traktu)", + "synced_with_trakt_progress": "Sinkronizirano s Traktom", + "using_trakt_progress": "Koristi se Trakt napredak", + "added_to_collection_hero": "Dodano u kolekciju", + "added_to_collection_desc_hero": "Dodano u vašu Trakt kolekciju", + "removed_from_collection_hero": "Uklonjeno iz kolekcije", + "removed_from_collection_desc_hero": "Uklonjeno iz vaše Trakt kolekcije", + "mark_as_watched": "Označi kao pogledano", + "mark_as_unwatched": "Označi kao nepogledano" + }, + "cast": { + "biography": "Biografija", + "known_for": "Poznat po", + "personal_info": "Osobni podaci", + "born_in": "Rođen u {{place}}", + "filmography": "Filmografija", + "also_known_as": "Poznat i kao", + "no_info_available": "Dodatne informacije nisu dostupne", + "as_character": "kao {{character}}", + "loading_details": "Učitavanje detalja...", + "years_old": "{{age}} godina", + "view_filmography": "Vidi filmografiju", + "filter": "Filter", + "sort_by": "Sortiraj po", + "sort_popular": "Popularno", + "sort_latest": "Najnovije", + "sort_upcoming": "Dolazeće", + "upcoming_badge": "DOLAZI", + "coming_soon": "Dolazi uskoro", + "filmography_count": "Filmografija • {{count}} naslova", + "loading_filmography": "Učitavanje filmografije...", + "load_more_remaining": "Učitaj više (preostalo {{count}})", + "alert_error_title": "Pogreška", + "alert_error_message": "Nije moguće učitati \"{{title}}\". Pokušajte ponovno kasnije.", + "alert_ok": "U redu", + "no_upcoming": "Nema dolazećih izdanja za ovog glumca", + "no_content": "Sadržaj nije dostupan za ovog glumca", + "no_movies": "Nema dostupnih filmova za ovog glumca", + "no_tv": "Nema dostupnih serija za ovog glumca" + }, + "comments": { + "title": "Trakt komentari", + "spoiler_warning": "⚠️ Ovaj komentar sadrži spoilere. Dodirni za prikaz.", + "spoiler": "Spoiler", + "contains_spoilers": "Sadrži spoilere", + "reveal": "Otkrij", + "vip": "VIP", + "unavailable": "Komentari nedostupni", + "no_comments": "Još nema komentara na Traktu", + "not_in_database": "Ovaj sadržaj možda još nije u Trakt bazi podataka", + "check_trakt": "Provjeri Trakt" + }, + "trailers": { + "title": "Traileri", + "official_trailers": "Službeni traileri", + "official_trailer": "Službeni trailer", + "teasers": "Teaseri", + "teaser": "Teaser", + "clips_scenes": "Isječci i scene", + "clip": "Isječak", + "featurettes": "Featurettes", + "featurette": "Featurette", + "behind_the_scenes": "Iza kulisa", + "no_trailers": "Nema dostupnih trailera", + "unavailable": "Trailer nedostupan", + "unavailable_desc": "Ovaj trailer trenutno se ne može učitati. Pokušajte ponovno kasnije.", + "unable_to_play": "Nije moguće reproducirati trailer. Pokušajte ponovno.", + "watch_on_youtube": "Gledaj na YouTubeu" + }, + "catalog": { + "no_content_found": "Sadržaj nije pronađen", + "no_content_filters": "Nije pronađen sadržaj za odabrane filtere", + "loading_content": "Učitavanje sadržaja...", + "back": "Natrag", + "in_theaters": "U kinima", + "all": "Sve", + "failed_tmdb": "Učitavanje sadržaja s TMDB-a nije uspjelo", + "movies": "Filmovi", + "tv_shows": "Serije", + "channels": "Kanali" + }, + "streams": { + "back_to_episodes": "Natrag na epizode", + "back_to_info": "Natrag na informacije", + "fetching_from": "Dohvaćanje iz:", + "no_sources_available": "Nema dostupnih izvora za streaming", + "add_sources_desc": "Molimo dodajte izvore za streaming u postavkama", + "add_sources": "Dodaj izvore", + "finding_streams": "Pronalaženje dostupnih streamova...", + "finding_best_stream": "Pronalaženje najboljeg streama za automatsku reprodukciju...", + "still_fetching": "Dohvaćanje streamova i dalje u tijeku...", + "no_streams_available": "Nema dostupnih streamova", + "starting_best_stream": "Pokretanje najboljeg streama...", + "loading_more_sources": "Učitavanje dodatnih izvora..." + }, + "player_ui": { + "via": "putem {{name}}", + "audio_tracks": "Zvučni zapisi", + "no_audio_tracks": "Nema dostupnih zvučnih zapisa", + "playback_speed": "Brzina reprodukcije", + "on_hold": "Na čekanju", + "playback_error": "Pogreška pri reprodukciji", + "unknown_error": "Došlo je do nepoznate pogreške tijekom reprodukcije.", + "copy_error": "Kopiraj detalje pogreške", + "copied_to_clipboard": "Kopirano u međuspremnik", + "dismiss": "Zatvori", + "continue_watching": "Nastavi gledati", + "start_over": "Kreni ispočetka", + "resume": "Nastavi", + "change_source": "Promijeni izvor", + "switching_source": "Promjena izvora...", + "no_sources_found": "Nema pronađenih izvora", + "sources": "Izvori", + "finding_sources": "Pronalaženje izvora...", + "unknown_source": "Nepoznat izvor", + "sources_limited": "Izvori mogu biti ograničeni zbog pogrešaka pružatelja usluga.", + "episodes": "Epizode", + "specials": "Specijali", + "season": "Sezona {{season}}", + "stream": "Stream {{number}}", + "subtitles": "Titlovi", + "built_in": "Ugrađeno", + "addons": "Dodaci", + "style": "Stil", + "none": "Nijedan", + "search_online_subtitles": "Pretraži titlove na mreži", + "preview": "Pretpregled", + "quick_presets": "Brze postavke", + "default": "Zadano", + "yellow": "Žuta", + "high_contrast": "Visoki kontrast", + "large": "Veliko", + "core": "Osnovno", + "font_size": "Veličina fonta", + "show_background": "Prikaži pozadinu", + "advanced": "Napredno", + "position": "Položaj", + "text_color": "Boja teksta", + "align": "Poravnanje", + "bottom_offset": "Pomak od dna", + "background_opacity": "Prozirnost pozadine", + "text_shadow": "Sjena teksta", + "on": "Uključeno", + "off": "Isključeno", + "outline_color": "Boja obruba", + "outline": "Obrub", + "outline_width": "Širina obruba", + "letter_spacing": "Razmak slova", + "line_height": "Visina retka", + "timing_offset": "Pomak vremena (s)", + "visual_sync": "Vizualna sinkronizacija", + "timing_hint": "Pomaknite titlove ranije (-) ili kasnije (+) za sinkronizaciju ako je potrebno.", + "reset_defaults": "Vrati na zadano" + }, + "downloads": { + "title": "Preuzimanja", + "no_downloads": "Još nema preuzimanja", + "no_downloads_desc": "Preuzeti sadržaj pojavit će se ovdje za gledanje izvan mreže", + "explore": "Istraži sadržaj", + "path_copied": "Putanja kopirana", + "path_copied_desc": "Lokalna putanja datoteke kopirana u međuspremnik", + "copied": "Kopirano", + "incomplete": "Preuzimanje nepotpuno", + "incomplete_desc": "Preuzimanje još nije završeno", + "not_available": "Nije dostupno", + "not_available_desc": "Lokalna putanja datoteke dostupna je tek nakon završetka preuzimanja.", + "status_downloading": "Preuzimanje", + "status_completed": "Dovršeno", + "status_paused": "Pauzirano", + "status_error": "Pogreška", + "status_queued": "U redu čekanja", + "status_unknown": "Nepoznato", + "provider": "Pružatelj usluge", + "streaming_playlist_warning": "Možda se neće reproducirati - streaming playlista", + "remaining": "preostalo", + "not_ready": "Preuzimanje nije spremno", + "not_ready_desc": "Molimo pričekajte dok preuzimanje ne završi.", + "filter_all": "Sve", + "filter_active": "Aktivno", + "filter_done": "Gotovo", + "filter_paused": "Pauzirano", + "no_filter_results": "Nema {{filter}} preuzimanja", + "try_different_filter": "Pokušajte odabrati drugi filter", + "limitations_title": "Ograničenja preuzimanja", + "limitations_msg": "• Datoteke manje od 1MB obično su M3U8 streaming liste i ne mogu se preuzeti za gledanje izvan mreže. One rade samo s online streamingom i sadrže poveznice na segmente videa, a ne stvarni video sadržaj.", + "remove_title": "Ukloni preuzimanje", + "remove_confirm": "Ukloniti \"{{title}}\"{{season_episode}}?", + "cancel": "Odustani", + "remove": "Ukloni" + }, + "addons": { + "title": "Dodaci", + "reorder_mode": "Način preslagivanja", + "reorder_info": "Dodaci na vrhu imaju veći prioritet prilikom učitavanja sadržaja", + "add_addon_placeholder": "URL dodatka", + "add_button": "Dodaj dodatak", + "my_addons": "Moji dodaci", + "community_addons": "Dodaci zajednice", + "no_addons": "Nema instaliranih dodataka", + "uninstall_title": "Deinstaliraj dodatak", + "uninstall_message": "Jeste li sigurni da želite deinstalirati {{name}}?", + "uninstall_button": "Deinstaliraj", + "install_success": "Dodatak je uspješno instaliran", + "install_error": "Instalacija dodatka nije uspjela", + "load_error": "Učitavanje dodataka nije uspjelo", + "fetch_error": "Dohvaćanje detalja dodatka nije uspjelo", + "invalid_url": "Molimo unesite URL dodatka", + "configure": "Konfiguriraj", + "version": "Verzija: {{version}}", + "installed_addons": "INSTALIRANI DODACI", + "reorder_drag_title": "POVUCITE DODATKE ZA PRESLAGIVANJE", + "install": "Instaliraj", + "config_unavailable_title": "Konfiguracija nedostupna", + "config_unavailable_msg": "Nije moguće odrediti URL konfiguracije za ovaj dodatak.", + "cannot_open_config_title": "Nije moguće otvoriti konfiguraciju", + "cannot_open_config_msg": "Konfiguracijski URL ({{url}}) ne može se otvoriti. Dodatak možda nema stranicu za konfiguraciju.", + "description": "Opis", + "supported_types": "Podržane vrste", + "catalogs": "Katalozi", + "no_description": "Opis nije dostupan", + "overview": "PREGLED", + "no_categories": "Nema kategorija", + "pre_installed": "PREDINSTRUALIRANO" + }, + "trakt": { + "title": "Trakt postavke", + "settings_title": "Trakt postavke", + "connect_title": "Poveži se s Traktom", + "connect_desc": "Sinkronizirajte povijest gledanja, popis za gledanje i kolekciju s Trakt.tv", + "sign_in": "Prijavi se na Trakt", + "sign_out": "Odjava", + "sign_out_confirm": "Jeste li sigurni da se želite odjaviti s vašeg Trakt računa?", + "joined": "Pridružen {{date}}", + "sync_settings_title": "Postavke sinkronizacije", + "sync_info": "Kada ste povezani s Traktom, cijela povijest se sinkronizira izravno putem API-ja i ne zapisuje se u lokalnu pohranu. Vaš popis 'Nastavi gledati' odražava vaš ukupni Trakt napredak.", + "auto_sync_label": "Automatska sinkronizacija napretka", + "auto_sync_desc": "Automatski sinkroniziraj napredak gledanja na Trakt", + "import_history_label": "Uvezi povijest gledanja", + "import_history_desc": "Koristite 'Sinkroniziraj sada' za uvoz povijesti gledanja i napretka s Trakta", + "sync_now_button": "Sinkroniziraj sada", + "display_settings_title": "Postavke prikaza", + "show_comments_label": "Prikaži Trakt komentare", + "show_comments_desc": "Prikaži Trakt komentare u detaljima sadržaja kada su dostupni", + "maintenance_title": "Održavanje u tijeku", + "maintenance_unavailable": "Trakt nedostupan", + "maintenance_desc": "Integracija s Traktom privremeno je zaustavljena zbog održavanja. Sinkronizacija i autentifikacija su onemogućeni dok se održavanje ne završi.", + "maintenance_button": "Usluga se održava", + "auth_success_title": "Uspješno povezano", + "auth_success_msg": "Vaš Trakt račun je uspješno povezan.", + "auth_error_title": "Pogreška autentifikacije", + "auth_error_msg": "Autentifikacija s Traktom nije uspjela.", + "auth_error_generic": "Došlo je do pogreške tijekom autentifikacije.", + "sign_out_error": "Odjava s Trakta nije uspjela.", + "sync_complete_title": "Sinkronizacija završena", + "sync_success_msg": "Vaš napredak gledanja uspješno je sinkroniziran s Traktom.", + "sync_error_msg": "Sinkronizacija nije uspjela. Molimo pokušajte ponovno." + }, + "simkl": { + "title": "Simkl postavke", + "settings_title": "Simkl postavke", + "connect_title": "Poveži se sa Simklom", + "connect_desc": "Sinkronizirajte povijest gledanja i pratite što gledate", + "sign_in": "Prijavi se na Simkl", + "sign_out": "Odspoji se", + "sign_out_confirm": "Jeste li sigurni da se želite odspojiti sa Simkla?", + "syncing_desc": "Vaše pogledane stavke sinkroniziraju se sa Simklom.", + "auth_success_title": "Uspješno povezano", + "auth_success_msg": "Vaš Simkl račun je uspješno povezan.", + "auth_error_title": "Pogreška autentifikacije", + "auth_error_msg": "Autentifikacija sa Simklom nije uspjela.", + "auth_error_generic": "Došlo je do pogreške tijekom autentifikacije.", + "sign_out_error": "Odspajanje sa Simkla nije uspjelo.", + "config_error_title": "Pogreška konfiguracije", + "config_error_msg": "Simkl Client ID nedostaje u varijablama okruženja.", + "conflict_title": "Sukob", + "conflict_msg": "Ne možete se povezati sa Simklom dok je Trakt povezan. Molimo prvo odspojite Trakt.", + "disclaimer": "Nuvio nije povezan sa Simklom." + }, + "tmdb_settings": { + "title": "TMDb postavke", + "metadata_enrichment": "Obogaćivanje metapodataka", + "metadata_enrichment_desc": "Poboljšajte metapodatke sadržaja s TMDb podacima za bolje detalje i informacije.", + "enable_enrichment": "Omogući obogaćivanje", + "enable_enrichment_desc": "Proširuje metapodatke dodataka s TMDb-a za glumačku postavu, dobne ocjene, logotipe/postere i informacije o produkciji.", + "localized_text": "Lokalizirani tekst", + "localized_text_desc": "Dohvati naslove i opise na vašem željenom jeziku s TMDb-a.", + "language": "Jezik", + "change": "Promijeni", + "logo_preview": "Pretpregled logotipa", + "logo_preview_desc": "Pretpregled pokazuje kako će se lokalizirani logotipi pojaviti na odabranom jeziku.", + "example": "Primjer:", + "no_logo": "Logotip nije dostupan", + "enrichment_options": "Opcije obogaćivanja", + "enrichment_options_desc": "Kontrolirajte koji se podaci dohvaćaju s TMDb-a. Onemogućene opcije koristit će podatke iz dodatka ako su dostupni.", + "cast_crew": "Glumci i ekipa", + "cast_crew_desc": "Glumci, redatelji, scenaristi s profilnim fotografijama", + "title_description": "Naslov i opis", + "title_description_desc": "Koristi TMDb lokalizirani naslov i opis", + "title_logos": "Logotipi naslova", + "title_logos_desc": "Visokokvalitetne slike naslova", + "banners_backdrops": "Banneri i pozadine", + "banners_backdrops_desc": "Slike pozadina visoke rezolucije", + "certification": "Dobna ocjena sadržaja", + "certification_desc": "Dobne preporuke (PG-13, R, TV-MA, itd.)", + "recommendations": "Preporuke", + "recommendations_desc": "Prijedlozi sličnog sadržaja", + "episode_data": "Podaci o epizodama", + "episode_data_desc": "Sličice epizoda, informacije i zamjenski podaci za serije", + "season_posters": "Posteri sezona", + "season_posters_desc": "Slike postera specifične za sezonu", + "production_info": "Informacije o produkciji", + "production_info_desc": "Mreže i produkcijske kuće s logotipima", + "movie_details": "Detalji o filmu", + "movie_details_desc": "Budžet, prihod, trajanje, slogan", + "tv_details": "Detalji o seriji", + "tv_details_desc": "Status, broj sezona, mreže, autori", + "movie_collections": "Kolekcije filmova", + "movie_collections_desc": "Filmske franšize (Marvel, Star Wars, itd.)", + "api_configuration": "API konfiguracija", + "api_configuration_desc": "Konfigurirajte svoj TMDb API pristup za poboljšanu funkcionalnost.", + "custom_api_key": "Prilagođeni API ključ", + "custom_api_key_desc": "Koristite vlastiti TMDb API ključ za bolje performanse i namjenska ograničenja.", + "custom_key_active": "Prilagođeni API ključ je aktivan", + "api_key_required": "Potreban je API ključ", + "api_key_placeholder": "Zalijepite svoj TMDb API ključ (v3)", + "how_to_get_key": "Kako dobiti TMDb API ključ?", + "built_in_key_msg": "Trenutno se koristi ugrađeni API ključ. Razmislite o korištenju vlastitog ključa za bolje performanse.", + "cache_size": "Veličina predmemorije", + "clear_cache": "Očisti predmemoriju", + "cache_days": "TMDb odgovori se spremaju 7 dana radi boljih performansi", + "choose_language": "Odaberi jezik", + "choose_language_desc": "Odaberite željeni jezik za TMDb sadržaj", + "popular": "Popularno", + "all_languages": "Svi jezici", + "search_results": "Rezultati pretraživanja", + "no_languages_found": "Nema pronađenih jezika za \"{{query}}\"", + "clear_search": "Očisti pretragu", + "clear_cache_title": "Očisti TMDb predmemoriju", + "clear_cache_msg": "Ovo će obrisati sve spremljene TMDb podatke ({{size}}). To može privremeno usporiti učitavanje dok se predmemorija ponovno ne izgradi.", + "clear_cache_success": "TMDb predmemorija je uspješno očišćena.", + "clear_cache_error": "Čišćenje predmemorije nije uspjelo.", + "clear_api_key_title": "Ukloni API ključ", + "clear_api_key_msg": "Jeste li sigurni da želite ukloniti svoj prilagođeni API ključ i vratiti se na zadani?", + "clear_api_key_success": "API ključ je uspješno uklonjen", + "clear_api_key_error": "Uklanjanje API ključa nije uspjelo", + "empty_api_key": "API ključ ne može biti prazan.", + "invalid_api_key": "Nevažeći API ključ. Provjerite i pokušajte ponovno.", + "save_error": "Došlo je do pogreške pri spremanju. Pokušajte ponovno.", + "using_builtin_key": "Sada koristite ugrađeni TMDb API ključ.", + "using_custom_key": "Sada koristite svoj prilagođeni TMDb API ključ.", + "enter_custom_key": "Molimo unesite i spremite svoj prilagođeni TMDb API ključ.", + "key_verified": "API ključ je verificiran i uspješno spremljen." + }, + "settings": { + "language": "Jezik", + "select_language": "Odaberi jezik", + "english": "Engleski", + "portuguese": "Portugalski", + "portuguese_br": "Portugalski (Brazil)", + "portuguese_pt": "Portugalski (Portugal)", + "german": "Njemački", + "arabic": "Arapski", + "spanish": "Španjolski", + "french": "Francuski", + "italian": "Talijanski", + "croatian": "Hrvatski", + "chinese": "Kineski (pojednostavljeni)", + "hindi": "Hindski", + "serbian": "Srpski", + "account": "Račun", + "content_discovery": "Sadržaj i otkrivanje", + "appearance": "Izgled", + "integrations": "Integracije", + "playback": "Reprodukcija", + "backup_restore": "Sigurnosna kopija i vraćanje", + "updates": "Ažuriranja", + "about": "O aplikaciji", + "developer": "Razvojni programer", + "cache": "Predmemorija", + "title": "Postavke", + "settings_title": "Postavke", + "sign_in_sync": "Prijavite se za sinkronizaciju", + "add_catalogs_sources": "Dodaci, katalozi i izvori", + "player_trailers_downloads": "Player, traileri, preuzimanja", + "mdblist_tmdb_ai": "MDBList, TMDB, AI", + "check_updates": "Provjeri ažuriranja", + "clear_mdblist_cache": "Očisti MDBList predmemoriju", + "cache_management": "UPRAVLJANJE PREDMEMORIJOM", + "downloads_counter": "preuzimanja i raste", + "made_with_love": "Napravljeno s ❤️ - Tapframe i prijatelji", + "sections": { + "information": "INFORMACIJE", + "account": "RAČUN", + "theme": "TEMA", + "layout": "RASPORED", + "sources": "IZVORI", + "catalogs": "KATALOZI", + "discovery": "OTKRIVANJE", + "metadata": "METAPODACI", + "ai_assistant": "AI ASISTENT", + "video_player": "VIDEO PLAYER", + "audio_subtitles": "AUDIO I TITLOVI", + "media": "MEDIJI", + "notifications": "OBAVIJESTI", + "testing": "TESTIRANJE", + "danger_zone": "ZONA OPASNOSTI" + }, + "items": { + "legal": "Pravne napomene i odricanje odgovornosti", + "privacy_policy": "Pravila privatnosti", + "report_issue": "Prijavi problem", + "version": "Verzija", + "contributors": "Suradnici", + "view_contributors": "Prikaži sve suradnike", + "theme": "Tema", + "episode_layout": "Raspored epizoda", + "streams_backdrop": "Pozadina streamova", + "streams_backdrop_desc": "Prikaži zamućenu pozadinu na streamovima na mobitelu", + "addons": "Dodaci", + "installed": "instalirano", + "debrid_integration": "Debrid integracija", + "debrid_desc": "Poveži Torbox", + "plugins": "Priključci", + "plugins_desc": "Upravljaj priključcima i repozitorijima", + "catalogs": "Katalozi", + "active": "aktivno", + "home_screen": "Početni zaslon", + "home_screen_desc": "Raspored i sadržaj", + "continue_watching": "Nastavi gledati", + "continue_watching_desc": "Predmemorija i ponašanje reprodukcije", + "show_discover": "Prikaži odjeljak 'Otkrij'", + "show_discover_desc": "Prikaži sadržaj za otkrivanje u pretrazi", + "mdblist": "MDBList", + "mdblist_connected": "Povezano", + "mdblist_desc": "Omogući za prikaz ocjena i recenzija", + "simkl": "Simkl", + "simkl_connected": "Povezano", + "simkl_desc": "Prati što gledaš", + "tmdb": "TMDB", + "tmdb_desc": "Pružatelj metapodataka i logotipa", + "openrouter": "OpenRouter API", + "openrouter_connected": "Povezano", + "openrouter_desc": "Dodaj API ključ za AI chat", + "video_player": "Video player", + "built_in": "Ugrađeni", + "external": "Vanjski", + "preferred_audio": "Željeni jezik zvuka", + "preferred_subtitle": "Željeni jezik titlova", + "subtitle_source": "Prioritet izvora titlova", + "auto_select_subs": "Automatski odabir titlova", + "auto_select_subs_desc": "Automatski odaberi titlove koji odgovaraju vašim postavkama", + "show_trailers": "Prikaži najave", + "show_trailers_desc": "Prikaži najave u glavnom odjeljku", + "enable_downloads": "Omogući preuzimanja", + "enable_downloads_desc": "Prikaži karticu Preuzimanja i omogući spremanje streamova", + "notifications": "Obavijesti", + "notifications_desc": "Podsjetnici za epizode", + "developer_tools": "Razvojni alati", + "developer_tools_desc": "Opcije za testiranje i uklanjanje pogrešaka", + "test_onboarding": "Testiraj uvodni ekran", + "reset_onboarding": "Resetiraj uvodni ekran", + "test_announcement": "Testiraj objavu", + "test_announcement_desc": "Prikaži prozor s novostima", + "reset_campaigns": "Resetiraj kampanje", + "reset_campaigns_desc": "Očisti zapise o prikazanim kampanjama", + "clear_all_data": "Očisti sve podatke", + "clear_all_data_desc": "Resetiraj sve postavke i predmemorirane podatke" + }, + "options": { + "horizontal": "Vodoravno", + "vertical": "Okomito", + "internal_first": "Prvo unutarnji", + "internal_first_desc": "Prednost imaju ugrađeni titlovi, zatim vanjski", + "external_first": "Prvo vanjski", + "external_first_desc": "Prednost imaju titlovi iz dodataka, zatim ugrađeni", + "any_available": "Bilo koji dostupni", + "any_available_desc": "Koristi prvi dostupni zapis titlova" + }, + "clear_data_desc": "Ovo će resetirati sve postavke i obrisati sve privremene podatke. Jeste li sigurni?", + "app_updates": "Ažuriranja aplikacije", + "about_nuvio": "O Nuviju" + }, + "privacy": { + "title": "Privatnost i Podaci", + "settings_desc": "Kontrolirajte telemetriju i prikupljanje podataka", + "info_title": "Vaša Privatnost nam je Važna", + "info_description": "Kontrolirajte koje podatke se prikupljaju i dijele. Analitika je podrazumevano onemogućena, a izveštaji o greškama su anonimni po zadanom.", + "analytics_enabled_title": "Analitika Omogućena", + "analytics_enabled_message": "Podaci o korišćenju će se prikupljati kako bi se poboljšala aplikacija. Možete to onemogućiti u bilo kojem trenutku.", + "disable_error_reporting_title": "Onemogućiti Izveštavanje o Greškama?", + "disable_error_reporting_message": "Onemogućavanje izveštavanja o greškama znači da nećemo biti obavesteni o padu ili problemima koje doživljate. Ovo može uticati na našu sposobnost da ispravimo greške.", + "enable_session_replay_title": "Omogućiti Reprodukciju Sesije?", + "enable_session_replay_message": "Reprodukcija sesije snima vaš ekran kada se greške dogode kako bi nam pomogla da razumemo šta se desilo. Ovo može da hvata vidljiv sadržaj na vašoj ekranu.", + "enable_pii_title": "Omogućiti Prikupljanje PII?", + "enable_pii_message": "Ovo omogućava prikupljanje lično identifikabilnih podataka kao što su IP adresa i detalji uređaja. Ovi podaci pomažu u dijagnostici problema, ali povećavaju izloženost privatnosti.", + "disable_all_title": "Onemogućiti Svu Telemetriju?", + "disable_all_message": "Ovo će onemogućiti svu analitiku, izveštavanje o greškama i reprodukciju sesije. Nećemo primati nikakve podatke o korišćenju aplikacije ili padevima.", + "disable_all_button": "Onemogući Sve", + "all_disabled_title": "Sva Telemetrija Onemogućena", + "all_disabled_message": "Svo prikupljanje podataka je onemogućeno. Promene će stupiti na snagu pri sledećem pokretanju aplikacije.", + "reset_title": "Resetuj na Preporučene", + "reset_message": "Postavke privatnosti su resetovane na preporučene zadane vrednosti (izveštavanje o greškama omogućeno, analitika onemogućena).", + "section_analytics": "ANALITIKA", + "analytics_title": "Analitika Korišćenja", + "analytics_description": "Prikupljaj anonimne obrasce korišćenja i prikaze ekrana", + "section_error_reporting": "IZVEŠTAVANJE O GREŠKAMA", + "error_reporting_title": "Izveštaji o Greškama", + "error_reporting_description": "Pošalji anonimne izveštaje o greškama kako bi se poboljšala stabilnost", + "session_replay_title": "Reprodukcija Sesije", + "session_replay_description": "Snimaj ekran kada se greške dogode", + "pii_title": "Uključi Informacije o Uređaju", + "pii_description": "Pošalji IP adresu i detalje uređaja sa izveštajima", + "section_quick_actions": "BRZE AKCIJE", + "disable_all": "Onemogući Svu Telemetriju", + "disable_all_desc": "Isključi svo prikupljanje podataka", + "reset_recommended": "Resetuj na Preporučene", + "reset_recommended_desc": "Zadane vrednosti usmeren na privatnost sa izveštavanjem o greškama", + "section_learn_more": "SAZNAJ VIŠE", + "privacy_policy": "Politika Privatnosti", + "current_settings": "Sažetak Trenutnih Postavki", + "summary_analytics": "Analitika", + "summary_errors": "Izveštaji o Greškama", + "summary_replay": "Reprodukcija Sesije", + "summary_pii": "Informacije o Uređaju", + "restart_note_detailed": "* Promene u analitici i izveštavanju o greškama stupaju na snagu odmah. Reprodukcija sesije i PII postavke zahtevaju ponovni pokretanje aplikacije." + }, + "ai_settings": { + "title": "AI asistent", + "info_title": "Chat pokretan umjetnom inteligencijom", + "info_desc": "Postavljajte pitanja o bilo kojem filmu ili epizodi serije koristeći napredni AI. Saznajte više o radnji, likovima, temama i zanimljivostima - sve temeljeno na TMDB podacima.", + "feature_1": "Kontekst i analiza specifična za epizodu", + "feature_2": "Objašnjenja radnje i uvid u likove", + "feature_3": "Zanimljivosti i činjenice iza kulisa", + "feature_4": "Vlastiti besplatni OpenRouter API ključ", + "api_key_section": "OPENROUTER API KLJUČ", + "api_key_label": "API ključ", + "api_key_desc": "Unesite svoj OpenRouter API ključ kako biste omogućili AI chat", + "save_api_key": "Spremi API ključ", + "saving": "Spremanje...", + "update": "Ažuriraj", + "remove": "Ukloni", + "get_free_key": "Nabavi besplatni API ključ od OpenRoutera", + "enable_chat": "Omogući AI Chat", + "enable_chat_desc": "Kada je omogućeno, gumb 'Pitaj AI' pojavit će se na stranicama sadržaja.", + "chat_enabled": "AI Chat omogućen", + "chat_enabled_desc": "Sada možete postavljati pitanja o filmovima i serijama. Potražite gumb \"Pitaj AI\"!", + "how_it_works": "Kako radi", + "how_it_works_desc": "• OpenRouter omogućuje pristup brojnim AI modelima\n• Vaš API ključ ostaje privatan i siguran\n• Besplatni paket uključuje izdašna ograničenja upotrebe\n• Razgovarajte o specifičnim epizodama ili filmovima\n• Dobijte detaljne analize i objašnjenja", + "error_invalid_key": "Molimo unesite važeći API ključ", + "error_key_format": "OpenRouter API ključevi trebaju počinjati s \"sk-or-\"", + "success_saved": "OpenRouter API ključ je uspješno spremljen!", + "error_save": "Spremanje API ključa nije uspjelo", + "confirm_remove_title": "Ukloni API ključ", + "confirm_remove_msg": "Jeste li sigurni da želite ukloniti svoj OpenRouter API ključ? To će onemogućiti AI chat.", + "success_removed": "API ključ uspješno uklonjen", + "error_remove": "Uklanjanje API ključa nije uspjelo" + }, + "catalog_settings": { + "title": "Katalozi", + "layout_phone": "RASPORED ZASLONA KATALOGA (MOBITEL)", + "posters_per_row": "Postera po retku", + "auto": "Automatski", + "show_titles": "Prikaži naslove postera", + "show_titles_desc": "Prikaži tekst naslova ispod svakog postera", + "phone_only_hint": "Vrijedi samo za mobitele. Tableti zadržavaju prilagodljivi raspored.", + "catalogs_group": "Katalozi", + "enabled_count": "Omogućeno {{enabled}} od {{total}}", + "rename_hint": "Dugo pritisnite katalog za preimenovanje", + "rename_modal_title": "Preimenuj katalog", + "rename_placeholder": "Unesite novi naziv kataloga", + "error_save_name": "Spremanje prilagođenog naziva nije uspjelo." + }, + "continue_watching_settings": { + "title": "Nastavi gledati", + "playback_behavior": "PONAŠANJE REPRODUKCIJE", + "use_cached": "Koristi predmemorirane streamove", + "use_cached_desc": "Kada je omogućeno, klikom na stavke 'Nastavi gledati' player se otvara izravno koristeći prethodne streamove. Kada je isključeno, otvara se zaslon sa sadržajem.", + "open_metadata": "Otvori zaslon s detaljima", + "open_metadata_desc": "Kada su predmemorirani streamovi isključeni, otvara se zaslon s detaljima umjesto popisa streamova. Ovo omogućuje ručni odabir streama.", + "card_appearance": "IZGLED KARTICE", + "card_style": "Stil kartice", + "card_style_desc": "Odaberite kako će se stavke 'Nastavi gledati' pojavljivati na početnom zaslonu", + "wide": "Široko", + "poster": "Poster", + "cache_settings": "POSTAVKE PREDMEMORIJE", + "cache_duration": "Trajanje predmemorije streama", + "cache_duration_desc": "Koliko dugo čuvati poveznice streamova prije nego što isteknu", + "important_note": "Važna napomena", + "important_note_text": "Sve poveznice streamova možda neće ostati aktivne cijelo vrijeme. Duže vrijeme predmemorije može rezultirati neispravnim poveznicama. U tom slučaju, aplikacija će ponovno dohvatiti svježe streamove.", + "how_it_works": "Kako radi", + "how_it_works_cached": "• Streamovi se spremaju na odabrano trajanje nakon gledanja\n• Predmemorirani streamovi se provjeravaju prije upotrebe\n• Ako je zapis nevažeći ili istekao, otvara se zaslon sadržaja\n• Opcija 'Koristi predmemorirane streamove' kontrolira izravni ulaz u player\n• 'Otvori zaslon s detaljima' pojavljuje se samo kad su predmemorirani streamovi isključeni", + "how_it_works_uncached": "• Kada su predmemorirani streamovi isključeni, klik otvara zaslone sadržaja\n• Opcija 'Otvori zaslon s detaljima' određuje koji će se zaslon otvoriti\n• Zaslon s metapodacima prikazuje detalje i ručni odabir\n• Zaslon sa streamovima prikazuje dostupne izvore za trenutnu reprodukciju", + "changes_saved": "Promjene spremljene", + "min": "min", + "hour": "sat", + "hours": "sati" + }, + "contributors": { + "title": "Suradnici", + "special_mentions": "Posebna priznanja", + "tab_contributors": "Suradnici", + "tab_special": "Posebna priznanja", + "tab_donors": "Donatori", + "manager_role": "Voditelj zajednice", + "manager_desc": "Upravlja Discord i Reddit zajednicama za Nuvio", + "sponsor_role": "Sponzor poslužitelja", + "sponsor_desc": "Sponzorirao infrastrukturu poslužitelja za Nuvio", + "mod_role": "Discord moderator", + "mod_desc": "Pomaže u moderiranju Nuvio Discord zajednice", + "loading": "Učitavanje...", + "discord_user": "Discord korisnik", + "contributions": "doprinosa", + "gratitude_title": "Zahvalni smo na svakom doprinosu", + "gratitude_desc": "Svaka linija koda, prijava pogreške i prijedlog pomažu da Nuvio postane bolji za sve", + "special_thanks_title": "Posebna hvala", + "special_thanks_desc": "Ovi nevjerojatni ljudi pomažu u održavanju Nuvio zajednice i poslužitelja", + "donors_desc": "Hvala vam što vjerujete u ono što gradimo. Vaša podrška drži Nuvio besplatnim i stalno ga poboljšava.", + "latest_donations": "Najnovije", + "leaderboard": "Poredak", + "loading_donors": "Učitavanje donatora...", + "no_donors": "Još nema donatora", + "error_rate_limit": "Prekoračeno ograničenje GitHub API-ja. Pokušajte kasnije.", + "error_failed": "Učitavanje suradnika nije uspjelo. Provjerite internetsku vezu.", + "retry": "Pokušaj ponovno", + "no_contributors": "Nisu pronađeni suradnici", + "loading_contributors": "Učitavanje suradnika..." + }, + "debrid": { + "title": "Debrid integracija", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", + "tab_torbox": "TorBox", + "tab_torrentio": "Torrentio", + "status_connected": "Povezano", + "status_disconnected": "Odspojeno", + "enable_addon": "Omogući dodatak", + "disconnect_button": "Odspoji i ukloni", + "disconnect_loading": "Odspajanje...", + "account_info": "Informacije o računu", + "plan": "Paket", + "plan_free": "Besplatno", + "plan_essential": "Essential (3 $/mj)", + "plan_pro": "Pro (10 $/mj)", + "plan_standard": "Standard (5 $/mj)", + "plan_unknown": "Nepoznato", + "expires": "Istječe", + "downloaded": "Preuzeto", + "status_active": "Aktivno", + "connected_title": "✓ Povezano s TorBoxom", + "connected_desc": "Vaš TorBox dodatak je aktivan i pruža premium streamove.", + "configure_title": "Konfiguriraj dodatak", + "configure_desc": "Prilagodite svoje iskustvo. Razvrstajte po kvaliteti, filtrirajte veličine datoteka i upravljajte postavkama.", + "open_settings": "Otvori postavke", + "what_is_debrid": "Što je Debrid usluga?", + "enter_api_key": "Unesite API ključ", + "connect_button": "Poveži i instaliraj", + "connecting": "Povezivanje...", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", + "get_subscription": "Pretplati se", + "powered_by": "Pokreće", + "disclaimer_torbox": "Nuvio nije povezan s Torboxom ni na koji način.", + "disclaimer_torrentio": "Nuvio nije povezan s Torrentio dodatakom ni na koji način.", + "installed_badge": "✓ INSTALIRANO", + "promo_title": "⚡ Trebate Debrid uslugu?", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", + "promo_button": "Nabavi TorBox pretplatu", + "service_label": "Debrid usluga *", + "api_key_label": "API ključ *", + "sorting_label": "Razvrstavanje", + "exclude_qualities": "Isključi kvalitete", + "priority_languages": "Prioritetni jezici", + "max_results": "Maksimalno rezultata", + "additional_options": "Dodatne opcije", + "no_download_links": "Ne prikazuj poveznice za preuzimanje", + "no_debrid_catalog": "Ne prikazuj debrid katalog", + "install_button": "Instaliraj Torrentio", + "installing": "Instalacija...", + "update_button": "Ažuriraj konfiguraciju", + "updating": "Ažuriranje...", + "remove_button": "Ukloni Torrentio", + "error_api_required": "Potreban API ključ", + "error_api_required_desc": "Unesite API ključ debrid usluge za instalaciju Torrentia.", + "success_installed": "Torrentio dodatak je uspješno instaliran!", + "success_removed": "Torrentio dodatak je uspješno uklonjen", + "alert_disconnect_title": "Odspoji Torbox", + "alert_disconnect_msg": "Jeste li sigurni da želite odspojiti Torbox? Ovo će ukloniti dodatak i obrisati API ključ." + }, + "home_screen": { + "title": "Postavke početnog zaslona", + "changes_applied": "Promjene primijenjene", + "display_options": "OPCIJE PRIKAZA", + "show_hero": "Prikaži istaknuti sadržaj", + "show_hero_desc": "Istaknuti sadržaj na vrhu", + "show_this_week": "Prikaži 'Ovaj tjedan'", + "show_this_week_desc": "Nove epizode iz tekućeg tjedna", + "select_catalogs": "Odaberi kataloge", + "all_catalogs": "Svi katalozi", + "selected": "odabrano", + "hero_layout": "Izgled istaknutog sadržaja", + "layout_legacy": "Klasično", + "layout_carousel": "Vrtuljak", + "layout_appletv": "Apple TV", + "layout_desc": "Banner pune širine, kartice koje se listaju ili Apple TV stil", + "featured_source": "Izvor istaknutog sadržaja", + "using_catalogs": "Koriste se katalozi", + "manage_selected_catalogs": "Upravljaj odabranim katalozima", + "dynamic_bg": "Dinamična pozadina", + "dynamic_bg_desc": "Zamućeni banner iza vrtuljka", + "performance_note": "Može utjecati na performanse na slabijim uređajima.", + "posters": "Posteri", + "show_titles": "Prikaži naslove", + "poster_size": "Veličina postera", + "poster_corners": "Kutovi postera", + "size_small": "Mali", + "size_medium": "Srednji", + "size_large": "Veliki", + "corners_square": "Oštri", + "corners_rounded": "Zaobljeni", + "corners_pill": "Ovalni", + "about_these_settings": "O OVIM POSTAVKAMA", + "about_desc": "Ove postavke kontroliraju kako se sadržaj prikazuje na vašem početnom zaslonu. Promjene se primjenjuju odmah bez potrebe za ponovnim pokretanjem aplikacije.", + "hero_catalogs": { + "title": "Katalozi istaknutog sadržaja", + "select_all": "Odaberi sve", + "clear_all": "Očisti sve", + "info": "Odaberite koji će se katalozi prikazivati u odjeljku s istaknutim sadržajem. Ako ništa nije odabrano, koristit će se svi katalozi. Ne zaboravite pritisnuti 'Spremi' kada završite.", + "settings_saved": "Postavke spremljene", + "error_load": "Učitavanje kataloga nije uspjelo", + "movies": "Filmovi", + "tv_shows": "Serije" + } + }, + "calendar": { + "title": "Kalendar", + "loading": "Učitavanje kalendara...", + "no_scheduled_episodes": "Nema zakazanih epizoda", + "check_back_later": "Provjerite ponovno kasnije", + "showing_episodes_for": "Prikaz epizoda za {{date}}", + "show_all_episodes": "Prikaži sve epizode", + "no_episodes_for": "Nema epizoda za {{date}}", + "no_upcoming_found": "Nisu pronađene nadolazeće epizode", + "add_series_desc": "Dodajte serije u svoju knjižnicu kako biste ovdje vidjeli njihove nadolazeće epizode" + }, + "mdblist": { + "title": "Izvori ocjena", + "status_disabled": "MDBList onemogućen", + "status_active": "API ključ aktivan", + "status_required": "Potreban API ključ", + "status_disabled_desc": "MDBList funkcionalnost je trenutno isključena.", + "status_active_desc": "Ocjene s MDBList-a su omogućene.", + "status_required_desc": "Dodajte svoj ključ ispod kako biste omogućili ocjene.", + "enable_toggle": "Omogući MDBList", + "enable_toggle_desc": "Uključi/isključi sve MDBList funkcionalnosti", + "api_section": "API ključ", + "placeholder": "Zalijepite svoj MDBList API ključ", + "save": "Spremi", + "clear": "Obriši ključ", + "rating_providers": "Pružatelji ocjena", + "rating_providers_desc": "Odaberite čije će se ocjene prikazivati u aplikaciji", + "how_to": "Kako dobiti API ključ", + "step_1": "Prijavite se na", + "step_1_link": "MDBList web stranici", + "step_2": "Idite na odjeljak", + "step_2_settings": "Postavke", + "step_2_api": "API", + "step_2_end": ".", + "step_3": "Generirajte novi ključ i kopirajte ga.", + "go_to_website": "Posjeti MDBList", + "alert_clear_title": "Brisanje API ključa", + "alert_clear_msg": "Jeste li sigurni da želite ukloniti spremljeni API ključ?", + "success_saved": "API ključ uspješno spremljen.", + "error_empty": "API ključ ne može biti prazan.", + "error_save": "Došlo je do pogreške pri spremanju. Pokušajte ponovno.", + "api_key_empty_error": "API ključ ne može biti prazan.", + "success_cleared": "API ključ uspješno obrisan", + "error_clear": "Brisanje API ključa nije uspjelo" + }, + "notification": { + "title": "Postavke obavijesti", + "section_general": "Općenito", + "enable_notifications": "Omogući obavijesti", + "section_types": "Vrste obavijesti", + "new_episodes": "Nove epizode", + "upcoming_shows": "Nadolazeće serije", + "reminders": "Podsjetnici", + "section_timing": "Vrijeme obavijesti", + "timing_desc": "Kada želite primiti obavijest prije emitiranja epizode?", + "hours_1": "1 sat", + "hours_suffix": "sati", + "section_status": "Status obavijesti", + "stats_upcoming": "Nadolazeće", + "stats_this_week": "Ovaj tjedan", + "stats_total": "Ukupno", + "sync_button": "Sinkroniziraj knjižnicu i Trakt", + "syncing": "Sinkronizacija...", + "sync_desc": "Automatski sinkronizira obavijesti za sve serije u vašoj knjižnici i Trakt listama.", + "section_advanced": "Napredno", + "reset_button": "Resetiraj sve obavijesti", + "test_button": "Testiraj obavijest (5 sek)", + "test_notification_in": "Obavijest za {{seconds}}s...", + "test_notification_text": "Obavijest će se pojaviti za {{seconds}} sekundi", + "alert_reset_title": "Resetiraj obavijesti", + "alert_reset_msg": "Ovo će otkazati sve zakazane obavijesti, ali neće ukloniti ništa iz vaše knjižnice. Jeste li sigurni?", + "alert_reset_success": "Sve obavijesti su resetirane", + "alert_sync_complete": "Sinkronizacija dovršena", + "alert_sync_msg": "Uspješno sinkronizirane obavijesti za vašu knjižnicu i Trakt stavke.\n\nZakazano: {{upcoming}} nadolazećih epizoda\nOvaj tjedan: {{thisWeek}} epizoda", + "alert_test_scheduled": "Testna obavijest zakazana za trenutno prikazivanje" + }, + "backup": { + "title": "Sigurnosna kopija i oporavak", + "options_title": "Opcije sigurnosne kopije", + "options_desc": "Odaberite što želite uključiti u sigurnosnu kopiju", + "section_core": "Osnovni podaci", + "section_addons": "Dodaci i integracije", + "section_settings": "Postavke i preferencije", + "library_label": "Knjižnica", + "library_desc": "Vaši spremljeni filmovi i serije", + "watch_progress_label": "Napredak gledanja", + "watch_progress_desc": "Pozicije 'Nastavi gledati'", + "addons_label": "Dodaci", + "addons_desc": "Instalirani Stremio dodaci", + "plugins_label": "Priključci", + "plugins_desc": "Prilagođene konfiguracije strugača", + "trakt_label": "Trakt integracija", + "trakt_desc": "Sinkronizacija podataka i tokeni za prijavu", + "app_settings_label": "Postavke aplikacije", + "app_settings_desc": "Tema, preferencije i konfiguracije", + "user_prefs_label": "Korisničke preferencije", + "user_prefs_desc": "Redoslijed dodataka i postavke sučelja", + "catalog_settings_label": "Postavke kataloga", + "catalog_settings_desc": "Filteri kataloga i preferencije", + "api_keys_label": "API ključevi", + "api_keys_desc": "MDBList i OpenRouter ključevi", + "action_create": "Stvori sigurnosnu kopiju", + "action_restore": "Vrati iz sigurnosne kopije", + "section_info": "O sigurnosnim kopijama", + "info_text": "• Prilagodite što se sprema pomoću prekidača iznad\n• Datoteke se pohranjuju lokalno na vašem uređaju\n• Podijelite sigurnosnu kopiju za prijenos podataka na drugi uređaj\n• Oporavak će prepisati vaše trenutne podatke", + "alert_create_title": "Stvori sigurnosnu kopiju", + "alert_no_content": "Nije odabran sadržaj za kopiju.\n\nMolimo omogućite barem jednu opciju iznad.", + "alert_backup_created_title": "Kopija stvorena", + "alert_backup_created_msg": "Vaša sigurnosna kopija je spremna i možete je podijeliti.", + "alert_backup_failed_title": "Stvaranje kopije nije uspjelo", + "alert_restore_confirm_title": "Potvrdi oporavak", + "alert_restore_confirm_msg": "Ovo će vratiti vaše podatke iz kopije stvorene {{date}}.\n\nOva radnja će prepisati trenutne podatke. Želite li nastaviti?", + "alert_restore_complete_title": "Oporavak dovršen", + "alert_restore_complete_msg": "Vaši podaci su uspješno vraćeni. Ponovno pokrenite aplikaciju za primjenu promjena.", + "alert_restore_failed_title": "Oporavak nije uspio", + "restart_app": "Ponovno pokreni aplikaciju", + "alert_restart_failed_title": "Ponovno pokretanje nije uspjelo", + "alert_restart_failed_msg": "Neuspjelo ponovno pokretanje. Ručno zatvorite i otvorite aplikaciju." + }, + "updates": { + "title": "Ažuriranja aplikacije", + "status_checking": "Provjera ažuriranja...", + "status_available": "Ažuriranje dostupno!", + "status_downloading": "Preuzimanje ažuriranja...", + "status_installing": "Instalacija ažuriranja...", + "status_success": "Ažuriranje uspješno instalirano!", + "status_error": "Ažuriranje nije uspjelo", + "status_ready": "Spremno za provjeru", + "action_check": "Provjeri ažuriranja", + "action_install": "Instaliraj ažuriranje", + "release_notes": "Napomene o izdanju:", + "version": "Verzija:", + "last_checked": "Zadnja provjera:", + "current_version": "Trenutna verzija:", + "current_release_notes": "Trenutne napomene o izdanju:", + "github_release": "GITHUB IZDANJE", + "current": "Trenutna:", + "latest": "Najnovija:", + "notes": "Bilješke:", + "view_release": "Pogledaj izdanje", + "notification_settings": "POSTAVKE OBAVIJESTI", + "ota_alerts_label": "OTA upozorenja o ažuriranju", + "ota_alerts_desc": "Prikaži obavijesti za bežična (OTA) ažuriranja", + "major_alerts_label": "Glavna upozorenja o ažuriranju", + "major_alerts_desc": "Prikaži obavijesti za nove verzije na GitHubu", + "alert_disable_ota_title": "Onemogućiti OTA upozorenja?", + "alert_disable_ota_msg": "Više nećete primati obavijesti o OTA ažuriranjima.\n\n⚠️ Upozorenje: Najnovija verzija je važna za:\n• Ispravke bugova i stabilnost\n• Nove značajke\n• Točne izvještaje o padu aplikacije\n\nI dalje možete ručno provjeriti ažuriranja.", + "alert_disable_major_title": "Onemogućiti glavna upozorenja?", + "alert_disable_major_msg": "Više nećete primati obavijesti o verzijama koje zahtijevaju ponovnu instalaciju.\n\n⚠️ Upozorenje: Glavna ažuriranja sadrže:\n• Kritične sigurnosne zakrpe\n• Velike promjene sustava\n• Važne ispravke kompatibilnosti", + "warning_note": "Omogućena upozorenja osiguravaju da dobijete ispravke i nove mogućnosti.", + "disable": "Onemogući", + "alert_no_update_to_install": "Nema dostupnog ažuriranja za instalaciju", + "alert_install_failed": "Instalacija ažuriranja nije uspjela", + "alert_no_update_title": "Nema ažuriranja", + "alert_update_applied_msg": "Ažuriranje će se primijeniti kod sljedećeg pokretanja" + }, + "player": { + "title": "Video player", + "section_selection": "ODABIR PLAYERA", + "internal_title": "Ugrađeni player", + "internal_desc": "Koristi zadani player aplikacije", + "vlc_title": "VLC", + "vlc_desc": "Otvori streamove u VLC-u", + "infuse_title": "Infuse", + "infuse_desc": "Otvori streamove u Infuse-u", + "outplayer_title": "OutPlayer", + "outplayer_desc": "Otvori streamove u OutPlayeru", + "vidhub_title": "VidHub", + "vidhub_desc": "Otvori streamove u VidHubu", + "infuse_live_title": "Infuse LiveContainer", + "infuse_live_desc": "Otvori streamove u Infuse-u putem LiveContainera", + "external_title": "Vanjski player", + "external_desc": "Otvori streamove u svom željenom playeru", + "section_playback": "OPCIJE REPRODUKCIJE", + "skip_intro_settings_title": "Preskoči uvod", + "powered_by_introdb": "Pokreće IntroDB", + "autoplay_title": "Automatska reprodukcija prvog streama", + "autoplay_desc": "Automatski pokreni prvi stream s popisa.", + "resume_title": "Uvijek nastavi", + "resume_desc": "Preskoči upit i nastavi tamo gdje ste stali (ako je odgledano manje od 85%).", + "engine_title": "Engine video playera", + "engine_desc": "Auto koristi ExoPlayer uz MPV kao rezervu. Neki formati poput Dolby Vision možda nisu podržani na MPV-u.", + "decoder_title": "Način dekodiranja", + "decoder_desc": "Kako se video dekodira. 'Auto' se preporučuje.", + "gpu_title": "GPU renderiranje", + "gpu_desc": "GPU-Next nudi bolje HDR upravljanje bojama.", + "external_downloads_title": "Vanjski player za preuzimanja", + "external_downloads_desc": "Reproduciraj preuzeti sadržaj u vanjskom playeru.", + "restart_required": "Potrebno ponovno pokretanje", + "restart_msg_decoder": "Ponovno pokrenite aplikaciju za primjenu promjena dekodera.", + "restart_msg_gpu": "Ponovno pokrenite aplikaciju za primjenu GPU načina.", + "option_auto": "Auto", + "option_auto_desc_engine": "ExoPlayer + MPV rezerva", + "option_mpv": "MPV", + "option_mpv_desc": "Samo MPV", + "option_auto_desc_decoder": "Najbolji balans", + "option_sw": "SW", + "option_sw_desc": "Softversko", + "option_hw": "HW", + "option_hw_desc": "Hardversko", + "option_hw_plus": "HW+", + "option_hw_plus_desc": "Puni HW", + "option_gpu_desc": "Standardno", + "option_gpu_next_desc": "Napredno" + }, + "plugins": { + "title": "Priključci", + "enable_title": "Omogući priključke", + "enable_desc": "Omogući sustav priključaka za pronalaženje vanjskih izvora medija", + "repo_config_title": "Konfiguracija repozitorija", + "repo_config_desc": "Upravljajte vanjskim repozitorijima priključaka.", + "your_repos": "Repozitoriji", + "your_repos_desc": "Konfigurirajte vanjske izvore za priključke.", + "add_repo_button": "Dodaj repozitorij", + "refresh": "Osvježi", + "remove": "Ukloni", + "enabled": "Omogućeno", + "disabled": "Onemogućeno", + "updating": "Ažuriranje...", + "success": "Uspjeh", + "error": "Pogreška", + "alert_repo_added": "Repozitorij dodan i priključci uspješno učitani", + "alert_repo_saved": "URL repozitorija uspješno spremljen", + "alert_repo_refreshed": "Repozitorij uspješno osvježen", + "alert_invalid_url": "Nevažeći URL format", + "alert_plugins_cleared": "Svi priključci su uklonjeni", + "alert_cache_cleared": "Predmemorija repozitorija uspješno očišćena", + "unknown": "Nepoznato", + "active": "Aktivno", + "available": "Dostupno", + "platform_disabled": "Platforma onemogućena", + "limited": "Ograničeno", + "clear_all": "Ukloni sve priključke", + "clear_all_desc": "Jeste li sigurni da želite ukloniti sve instalirane priključke? To se ne može poništiti.", + "clear_cache": "Očisti predmemoriju repozitorija", + "clear_cache_desc": "Ovo će ukloniti URL i podatke repozitorija. Morat ćete ga ponovno unijeti.", + "add_new_repo": "Dodaj novi repozitorij", + "available_plugins": "Dostupni priključci ({{count}})", + "placeholder": "Pretraži priključke...", + "all": "Svi", + "filter_all": "Sve vrste", + "filter_movies": "Filmovi", + "filter_tv": "Serije", + "enable_all": "Omogući sve", + "disable_all": "Onemogući sve", + "no_plugins_found": "Priključci nisu pronađeni", + "no_plugins_available": "Nema dostupnih priključaka", + "no_match_desc": "Nema rezultata za \"{{query}}\". Pokušajte s drugim pojmom.", + "configure_repo_desc": "Konfigurirajte repozitorij iznad za prikaz priključaka.", + "clear_search": "Očisti pretragu", + "no_external_player": "Nema vanjskog playera", + "showbox_token": "ShowBox UI Token", + "showbox_placeholder": "Zalijepite svoj ShowBox UI token", + "save": "Spremi", + "clear": "Očisti", + "additional_settings": "Dodatne postavke", + "enable_url_validation": "Omogući provjeru URL-a", + "url_validation_desc": "Provjeri ispravnost poveznica (može usporiti rezultate, ali povećava pouzdanost)", + "group_streams": "Grupiraj izvore priključaka", + "group_streams_desc": "Izvori će biti grupirani prema repozitoriju.", + "sort_quality": "Poredaj prvo po kvaliteti", + "sort_quality_desc": "Izvori se prvo razvrstavaju po kvaliteti (samo uz grupiranje).", + "show_logos": "Prikaži logotipe priključaka", + "show_logos_desc": "Prikaži logotipe pored izvora na zaslonu odabira.", + "quality_filtering": "Filtriranje kvalitete", + "quality_filtering_desc": "Isključi određene rezolucije iz rezultata.", + "excluded_qualities": "Isključene kvalitete:", + "language_filtering": "Filtriranje jezika", + "language_filtering_desc": "Isključi određene jezike iz rezultata.", + "note": "Napomena:", + "language_filtering_note": "Ovo se primjenjuje samo na pružatelje koji šalju informaciju o jeziku.", + "excluded_languages": "Isključeni jezici:", + "about_title": "O priključcima", + "about_desc_1": "Priključci su modularne komponente koje prilagođavaju sadržaj s vanjskih protokola. Rade lokalno na vašem uređaju.", + "about_desc_2": "Priključci označeni kao \"Ograničeno\" mogu zahtijevati dodatne konfiguracije.", + "help_title": "Postavljanje priključaka", + "help_step_1": "1. **Omogući priključke** - Uključite glavni prekidač", + "help_step_2": "2. **Dodaj repozitorij** - Unesite ispravan URL", + "help_step_3": "3. **Osvježi** - Povucite popis dostupnih priključaka", + "help_step_4": "4. **Aktiviraj** - Omogućite željene priključke", + "got_it": "Razumijem!", + "repo_format_hint": "Format: https://raw.githubusercontent.com/korisnik/repo/refs/heads/branch", + "cancel": "Odustani", + "add": "Dodaj" + }, + "theme": { + "title": "Teme aplikacije", + "select_theme": "ODABERI TEMU", + "create_custom": "Stvori vlastitu temu", + "options": "OPCIJE", + "use_dominant_color": "Koristi dominantnu boju s postera", + "categories": { + "all": "Sve teme", + "dark": "Tamne teme", + "colorful": "Šarene", + "custom": "Moje teme" + }, + "editor": { + "theme_name_placeholder": "Naziv teme", + "save": "Spremi", + "primary": "Primarna", + "secondary": "Sekundarna", + "background": "Pozadina", + "invalid_name_title": "Nevažeći naziv", + "invalid_name_msg": "Molimo unesite ispravan naziv teme" + }, + "alerts": { + "delete_title": "Obriši temu", + "delete_msg": "Jeste li sigurni da želite obrisati temu \"{{name}}\"?", + "ok": "U redu", + "delete": "Obriši", + "cancel": "Odustani", + "back": "Postavke" + } + }, + "legal": { + "title": "Pravne napomene i odricanje odgovornosti", + "intro_title": "Priroda aplikacije", + "intro_text": "Nuvio je media player i aplikacija za upravljanje metapodacima. Djeluje isključivo kao klijentsko sučelje za pregledavanje javno dostupnih informacija (filmovi, serije) i reprodukciju datoteka koje osigura korisnik ili dodaci trećih strana. Nuvio ne ugošćuje, ne distribuira niti indeksira nikakav medijski sadržaj.", + "extensions_title": "Priključci trećih strana", + "extensions_text": "Nuvio omogućuje instalaciju priključaka koje razvijaju neovisni programeri. Nemamo kontrolu niti odgovornost za sadržaj, zakonitost ili funkcionalnost tih priključaka.", + "user_resp_title": "Odgovornost korisnika", + "user_resp_text": "Korisnici su isključivo odgovorni za priključke koje instaliraju. Korištenjem aplikacije pristajete osigurati da imate zakonsko pravo na pristup sadržaju koji gledate. Autori Nuvia ne potiču kršenje autorskih prava.", + "dmca_title": "Autorska prava i DMCA", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", + "warranty_title": "Bez jamstva", + "warranty_text": "Softver se isporučuje \"kakav jest\", bez ikakvih jamstava. Autori nisu odgovorni za bilo kakvu štetu nastalu korištenjem ovog softvera." + }, + "plugin_tester": { + "title": "Plugin Tester", + "subtitle": "Pokrenite strugače i pratite zapise u stvarnom vremenu", + "tabs": { + "individual": "Pojedinačno", + "repo": "Repo Tester", + "code": "Kod", + "logs": "Zapisi", + "results": "Rezultati" + }, + "common": { + "error": "Pogreška", + "success": "Uspjeh", + "movie": "Film", + "tv": "Serija", + "tmdb_id": "TMDB ID", + "season": "Sezona", + "episode": "Epizoda", + "running": "Pokretanje…", + "run_test": "Pokreni test", + "play": "Reproduciraj", + "done": "Gotovo", + "test": "Test", + "testing": "Testiranje…" + }, + "individual": { + "load_from_url": "Učitaj s URL-a", + "load_from_url_desc": "Zalijepite GitHub URL ili lokalni IP i preuzmite.", + "enter_url_error": "Molimo unesite URL", + "code_loaded": "Kod učitan s URL-a", + "fetch_error": "Dohvaćanje nije uspjelo: {{message}}", + "no_code_error": "Nema koda za pokretanje", + "plugin_code": "Kod priključka", + "focus_editor": "Fokusiraj uređivač koda", + "code_placeholder": "// Zalijepite kod priključka ovdje...", + "test_parameters": "Testni parametri", + "no_logs": "Još nema zapisa. Pokrenite test.", + "no_streams": "Nisu pronađeni streamovi.", + "streams_found": "Pronađen {{count}} stream", + "streams_found_plural": "Pronađeno {{count}} streama", + "tap_play_hint": "Dodirnite 'Reproduciraj' za testiranje streama.", + "unnamed_stream": "Neimenovani stream", + "quality": "Kvaliteta: {{quality}}", + "size": "Veličina: {{size}}", + "url_label": "URL: {{url}}", + "headers_info": "Zaglavlja: {{count}} prilagođenih", + "find_placeholder": "Pronađi u kodu…", + "edit_code_title": "Uredi kod", + "no_url_stream_error": "Nije pronađen URL za ovaj stream" + }, + "repo": { + "title": "Repo Tester", + "description": "Dohvatite repozitorij i testirajte svakog pružatelja.", + "enter_repo_url_error": "Unesite URL repozitorija", + "invalid_url_title": "Nevažeći URL", + "invalid_url_msg": "Koristite GitHub raw URL ili lokalni http(s) URL.", + "manifest_build_error": "Neuspjelo stvaranje URL-a manifesta", + "manifest_fetch_error": "Neuspjelo dohvaćanje manifesta", + "repo_manifest_fetch_error": "Neuspjelo dohvaćanje manifesta repozitorija", + "missing_filename": "Nedostaje naziv datoteke u manifestu", + "scraper_build_error": "Neuspjelo stvaranje URL-a strugača", + "download_scraper_error": "Neuspjelo preuzimanje strugača", + "test_failed": "Test nije uspio", + "test_parameters": "Parametri Repo testa", + "test_parameters_desc": "Ovi se parametri koriste samo za Repo Tester.", + "using_info": "Koristi se: {{mediaType}} • TMDB {{tmdbId}}", + "using_info_tv": "Koristi se: {{mediaType}} • TMDB {{tmdbId}} • S{{season}}E{{episode}}", + "providers_title": "Pružatelji", + "repository_default": "Repozitorij", + "providers_count": "{{count}} pružatelja", + "fetch_hint": "Dohvatite repo za popis pružatelja.", + "test_all": "Testiraj sve", + "status_running": "U TIJEKU", + "status_ok": "OK ({{count}})", + "status_ok_empty": "OK (0)", + "status_failed": "NEUSPJEH", + "status_idle": "ČEKANJE", + "tried_url": "Pokušano: {{url}}", + "provider_logs": "Zapisi pružatelja", + "no_logs_captured": "Nema zapisa." + } + } +} diff --git a/src/i18n/locales/pt-BR.json b/src/i18n/locales/pt-BR.json index aa3983db..8ac6a0bb 100644 --- a/src/i18n/locales/pt-BR.json +++ b/src/i18n/locales/pt-BR.json @@ -922,8 +922,8 @@ }, "debrid": { "title": "Integração Debrid", - "description_torbox": "Desbloqueie streams 4K de alta qualidade e velocidades ultra-rápidas integrando o Torbox. Insira sua chave API abaixo para atualizar instantaneamente sua experiência de streaming.", - "description_torrentio": "Configure o Torrentio para obter streams de torrent para filmes e séries. Um serviço debrid é necessário para transmitir conteúdo.", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "Conectado", @@ -950,15 +950,15 @@ "enter_api_key": "Insira sua Chave API", "connect_button": "Conectar e Instalar", "connecting": "Conectando...", - "unlock_speeds_title": "Velocidades Premium", - "unlock_speeds_desc": "Assine o Torbox para acessar streams em cache de alta qualidade com zero buffering.", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "Obter Assinatura", "powered_by": "Desenvolvido por", "disclaimer_torbox": "O Nuvio não é afiliado ao Torbox de nenhuma forma.", "disclaimer_torrentio": "O Nuvio não é afiliado ao Torrentio de nenhuma forma.", "installed_badge": "✓ INSTALADO", "promo_title": "⚡ Precisa de um Serviço Debrid?", - "promo_desc": "Obtenha o TorBox para streaming 4K ultra-rápido com zero buffering. Torrents em cache premium e downloads instantâneos.", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "Assinar TorBox", "service_label": "Serviço Debrid *", "api_key_label": "Chave API *", @@ -1350,7 +1350,7 @@ "user_resp_title": "Responsabilidade do Usuário", "user_resp_text": "Os usuários são os únicos responsáveis pelas extensões que instalam e pelo conteúdo que acessam. Ao usar este aplicativo, você concorda em garantir que tem o direito legal de acessar qualquer conteúdo que visualizar usando o Nuvio. Os desenvolvedores do Nuvio não endossam ou incentivam a violação de direitos autorais.", "dmca_title": "Direitos Autorais e DMCA", - "dmca_text": "Respeitamos os direitos de propriedade intelectual de terceiros. Como o Nuvio não hospeda nenhum conteúdo, não podemos remover conteúdo da internet. No entanto, se você acredita que a interface do aplicativo em si infringe seus direitos, entre em contato conosco.", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "Sem Garantia", "warranty_text": "Este software é fornecido \"como está\", sem garantia de qualquer tipo, expressa ou implícita. Em nenhum caso os autores ou detentores de direitos autorais serão responsáveis por qualquer reclamação, danos ou outra responsabilidade decorrente do uso deste software." }, diff --git a/src/i18n/locales/pt-PT.json b/src/i18n/locales/pt-PT.json index b70e7815..0bdb7ce3 100644 --- a/src/i18n/locales/pt-PT.json +++ b/src/i18n/locales/pt-PT.json @@ -920,8 +920,8 @@ }, "debrid": { "title": "Integração Debrid", - "description_torbox": "Desbloqueia streams 4K de alta qualidade e velocidades ultra-rápidas integrando o Torbox. Insere a tua chave API abaixo para atualizar instantaneamente a tua experiência de streaming.", - "description_torrentio": "Configura o Torrentio para obter streams de torrent para filmes e séries. Um serviço debrid é necessário para transmitir conteúdo.", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "Conectado", @@ -948,15 +948,15 @@ "enter_api_key": "Insere a tua Chave API", "connect_button": "Conectar e Instalar", "connecting": "A conectar...", - "unlock_speeds_title": "Velocidades Premium", - "unlock_speeds_desc": "Subscreve o Torbox para acessar streams em cache de alta qualidade com zero buffering.", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "Obter Subscrição", "powered_by": "Desenvolvido por", "disclaimer_torbox": "O Nuvio não é afiliado ao Torbox de nenhuma forma.", "disclaimer_torrentio": "O Nuvio não é afiliado ao Torrentio de nenhuma forma.", "installed_badge": "✓ INSTALADO", "promo_title": "⚡ Precisas de um Serviço Debrid?", - "promo_desc": "Obtém o TorBox para streaming 4K ultra-rápido com zero buffering. Torrents em cache premium e downloads instantâneos.", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "Subscrever TorBox", "service_label": "Serviço Debrid *", "api_key_label": "Chave API *", @@ -1348,7 +1348,7 @@ "user_resp_title": "Responsabilidade do Usuário", "user_resp_text": "Os usuários são os únicos responsáveis pelas extensões que instalam e pelo conteúdo que acessam. Ao usar este aplicativo, você concorda em garantir que tem o direito legal de acessar qualquer conteúdo que visualizar usando o Nuvio. Os desenvolvedores do Nuvio não endossam ou incentivam a violação de direitos autorais.", "dmca_title": "Direitos Autorais e DMCA", - "dmca_text": "Respeitamos os direitos de propriedade intelectual de terceiros. Como o Nuvio não hospeda nenhum conteúdo, não podemos remover conteúdo da internet. No entanto, se você acredita que a interface do aplicativo em si infringe seus direitos, entre em contato conosco.", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "Sem Garantia", "warranty_text": "Este software é fornecido \"como está\", sem garantia de qualquer tipo, expressa ou implícita. Em nenhum caso os autores ou detentores de direitos autorais serão responsáveis por qualquer reclamação, danos ou outra responsabilidade decorrente do uso deste software." }, diff --git a/src/i18n/locales/zh-CN.json b/src/i18n/locales/zh-CN.json index b7283dc9..01420a2d 100644 --- a/src/i18n/locales/zh-CN.json +++ b/src/i18n/locales/zh-CN.json @@ -908,8 +908,8 @@ }, "debrid": { "title": "Debrid 集成", - "description_torbox": "通过集成 Torbox 解锁 4K 高质量流媒体和闪电般的速度。在下方输入您的 API 密钥以立即升级您的流媒体体验。", - "description_torrentio": "配置 Torrentio 以获取电影和电视节目的 Torrent 流。需要 Debrid 服务才能流式传输内容。", + "description_torbox": "Connect Torbox to use your account-based source preferences. Enter your API key below to configure the integration.", + "description_torrentio": "Configure Torrentio as an external source integration. A compatible debrid account may be required depending on your setup.", "tab_torbox": "TorBox", "tab_torrentio": "Torrentio", "status_connected": "已连接", @@ -936,15 +936,15 @@ "enter_api_key": "输入您的 API 密钥", "connect_button": "连接并安装", "connecting": "正在连接...", - "unlock_speeds_title": "解锁高级速度", - "unlock_speeds_desc": "获取 Torbox 订阅以访问零缓冲的缓存高质量流媒体。", + "unlock_speeds_title": "Optional Torbox Subscription", + "unlock_speeds_desc": "Torbox offers account tiers with enhanced performance and availability features.", "get_subscription": "获取订阅", "powered_by": "技术支持", "disclaimer_torbox": "Nuvio 与 Torbox 没有任何关联。", "disclaimer_torrentio": "Nuvio 与 Torrentio 没有任何关联。", "installed_badge": "✓ 已安装", "promo_title": "⚡ 需要 Debrid 服务?", - "promo_desc": "获取 TorBox 以获得零缓冲的闪电般 4K 流媒体。高级缓存 Torrent 和即时下载。", + "promo_desc": "Use TorBox if you want account-managed performance features for supported integrations.", "promo_button": "获取 TorBox 订阅", "service_label": "Debrid 服务 *", "api_key_label": "API 密钥 *", @@ -1336,7 +1336,7 @@ "user_resp_title": "用户责任", "user_resp_text": "用户对其安装的插件和访问的内容负全责。使用本应用程序即表示您同意确保您拥有使用 Nuvio 访问任何内容的合法权利。Nuvio 的开发者不认可或鼓励侵犯版权。", "dmca_title": "版权与 DMCA", - "dmca_text": "我们尊重他人的知识产权。由于 Nuvio 不托管任何内容,我们无法从互联网上移除内容。但是,如果您认为应用程序界面本身侵犯了您的权利,请联系我们。", + "dmca_text": "We respect the intellectual property rights of others. Nuvio does not host media content. If you believe this project's code, assets, or interface infringes your rights, submit a notice through the official project contact channels listed on the website and repository.", "warranty_title": "无担保", "warranty_text": "本软件“按原样”提供,不提供任何明示或暗示的担保。在任何情况下,作者或版权持有人均不对因使用本软件而引起的任何索赔、损害或其他责任负责。" }, diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index ec219b70..a818fcf2 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -39,6 +39,7 @@ if (Platform.OS === 'ios') { import HomeScreen from '../screens/HomeScreen'; import LibraryScreen from '../screens/LibraryScreen'; import SettingsScreen from '../screens/SettingsScreen'; +import SyncSettingsScreen from '../screens/SyncSettingsScreen'; import DownloadsScreen from '../screens/DownloadsScreen'; import MetadataScreen from '../screens/MetadataScreen'; import KSPlayerCore from '../components/player/KSPlayerCore'; @@ -105,6 +106,7 @@ export type RootStackParamList = { Home: undefined; Library: undefined; Settings: undefined; + SyncSettings: undefined; Update: undefined; Search: undefined; Calendar: undefined; @@ -1854,7 +1856,12 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta }, }} /> - + + @@ -1924,7 +1931,6 @@ const ConditionalPostHogProvider: React.FC<{ children: React.ReactNode }> = ({ c apiKey="phc_sk6THCtV3thEAn6cTaA9kL2cHuKDBnlYiSL40ywdS6C" options={{ host: "https://us.i.posthog.com", - autocapture: analyticsEnabled, // Start opted out if analytics is disabled defaultOptIn: analyticsEnabled, }} diff --git a/src/screens/AuthScreen.tsx b/src/screens/AuthScreen.tsx index c890208f..373bb512 100644 --- a/src/screens/AuthScreen.tsx +++ b/src/screens/AuthScreen.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import { View, TextInput, Text, TouchableOpacity, StyleSheet, ActivityIndicator, SafeAreaView, KeyboardAvoidingView, Platform, Dimensions, Animated, Easing, Keyboard } from 'react-native'; +import { View, TextInput, Text, TouchableOpacity, StyleSheet, ActivityIndicator, SafeAreaView, KeyboardAvoidingView, Platform, Animated, Easing, Keyboard, StatusBar, useWindowDimensions } from 'react-native'; import { mmkvStorage } from '../services/mmkvStorage'; import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; @@ -10,15 +10,46 @@ import * as Haptics from 'expo-haptics'; import { useToast } from '../contexts/ToastContext'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -const { width, height } = Dimensions.get('window'); +const EMAIL_CONFIRMATION_REQUIRED_PREFIX = '__EMAIL_CONFIRMATION__'; +const AUTH_BG_GRADIENT = ['#07090F', '#0D1020', '#140B24']; + +const normalizeAuthErrorMessage = (input: string): string => { + const raw = (input || '').trim(); + if (!raw) return 'Authentication failed'; + + let parsed: any = null; + if (raw.startsWith('{') && raw.endsWith('}')) { + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + } + + const code = (parsed?.error_code || parsed?.code || '').toString().toLowerCase(); + const message = (parsed?.msg || parsed?.message || raw).toString(); + + if (code === 'invalid_credentials' || /invalid login credentials/i.test(message)) { + return 'Invalid email or password'; + } + if (code === 'email_not_confirmed' || /email not confirmed/i.test(message)) { + return 'Email not confirmed. Check your inbox or Spam/Junk folder, verify your account, then sign in.'; + } + + return message; +}; const AuthScreen: React.FC = () => { + const { width, height } = useWindowDimensions(); + const isTablet = width >= 768; const { currentTheme } = useTheme(); const { signIn, signUp } = useAccount(); const navigation = useNavigation(); const route = useRoute(); const fromOnboarding = !!route?.params?.fromOnboarding; const insets = useSafeAreaInsets(); + const safeTopInset = Math.max(insets.top, Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : 0); + const backButtonTop = safeTopInset + 8; const { showError, showSuccess } = useToast(); const [email, setEmail] = useState(''); @@ -27,11 +58,8 @@ const AuthScreen: React.FC = () => { const [showPassword, setShowPassword] = useState(false); const [showConfirm, setShowConfirm] = useState(false); const [mode, setMode] = useState<'signin' | 'signup'>('signin'); - const signupDisabled = true; // Signup disabled due to upcoming system replacement const [error, setError] = useState(null); const [loading, setLoading] = useState(false); - const [showWarningDetails, setShowWarningDetails] = useState(false); - const authCardOpacity = useRef(new Animated.Value(1)).current; // Subtle, performant animations const introOpacity = useRef(new Animated.Value(0)).current; @@ -46,8 +74,6 @@ const AuthScreen: React.FC = () => { const modeAnim = useRef(new Animated.Value(0)).current; // 0 = signin, 1 = signup const [switchWidth, setSwitchWidth] = useState(0); // Legacy local toast state removed in favor of global toast - const [headerHeight, setHeaderHeight] = useState(0); - const headerHideAnim = useRef(new Animated.Value(0)).current; // 0 visible, 1 hidden const [keyboardHeight, setKeyboardHeight] = useState(0); useEffect(() => { @@ -113,21 +139,9 @@ const AuthScreen: React.FC = () => { const onShow = (e: any) => { const kh = e?.endCoordinates?.height ?? 0; setKeyboardHeight(kh); - Animated.timing(headerHideAnim, { - toValue: 1, - duration: 180, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }).start(); }; const onHide = () => { setKeyboardHeight(0); - Animated.timing(headerHideAnim, { - toValue: 0, - duration: 180, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }).start(); }; const subShow = Keyboard.addListener(showEvt, onShow as any); const subHide = Keyboard.addListener(hideEvt, onHide as any); @@ -135,7 +149,7 @@ const AuthScreen: React.FC = () => { subShow.remove(); subHide.remove(); }; - }, [headerHideAnim]); + }, []); const isEmailValid = useMemo(() => /\S+@\S+\.\S+/.test(email.trim()), [email]); const isPasswordValid = useMemo(() => password.length >= 6, [password]); @@ -145,16 +159,7 @@ const AuthScreen: React.FC = () => { const handleSubmit = async () => { if (loading) return; - - // Prevent signup if disabled - if (mode === 'signup' && signupDisabled) { - const msg = 'Sign up is currently disabled due to upcoming system changes'; - setError(msg); - showError('Sign Up Disabled', 'Sign up is currently disabled due to upcoming system changes'); - Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); - return; - } - + if (!isEmailValid) { const msg = 'Enter a valid email address'; setError(msg); @@ -180,8 +185,19 @@ const AuthScreen: React.FC = () => { setError(null); const err = mode === 'signin' ? await signIn(email.trim(), password) : await signUp(email.trim(), password); if (err) { - setError(err); - showError('Authentication Failed', err); + if (mode === 'signup' && err.startsWith(EMAIL_CONFIRMATION_REQUIRED_PREFIX)) { + setError(null); + setMode('signin'); + setPassword(''); + setConfirmPassword(''); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {}); + setLoading(false); + return; + } + + const cleanError = normalizeAuthErrorMessage(err); + setError(cleanError); + showError('Authentication Failed', cleanError); Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error).catch(() => {}); } else { const msg = mode === 'signin' ? 'Logged in successfully' : 'Sign up successful'; @@ -201,39 +217,14 @@ const AuthScreen: React.FC = () => { navigation.reset({ index: 0, routes: [{ name: 'MainTabs' as never }] } as any); }; - const toggleWarningDetails = () => { - if (showWarningDetails) { - // Fade in auth card - Animated.timing(authCardOpacity, { - toValue: 1, - duration: 300, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }).start(); - } else { - // Fade out auth card - Animated.timing(authCardOpacity, { - toValue: 0, - duration: 300, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }).start(); - } - setShowWarningDetails(!showWarningDetails); - }; - // showToast helper replaced with direct calls to toast.* API return ( - {Platform.OS !== 'android' ? ( - - ) : ( - - )} + {/* Background Pattern (iOS only) */} {Platform.OS !== 'android' && ( @@ -255,107 +246,55 @@ const AuthScreen: React.FC = () => { )} - {/* Header outside KeyboardAvoidingView to avoid being overlapped */} - setHeaderHeight(e.nativeEvent.layout.height)} - style={[ - styles.header, - { - opacity: Animated.multiply( - introOpacity, - headerHideAnim.interpolate({ inputRange: [0, 1], outputRange: [1, 0] }) - ), - transform: [ - { - translateY: Animated.add( - introTranslateY, - headerHideAnim.interpolate({ inputRange: [0, 1], outputRange: [0, -12] }) - ), - }, - ], - }, - ]} - > - {navigation.canGoBack() && ( - navigation.goBack()} style={[styles.backButton, Platform.OS === 'android' ? { top: Math.max(insets.top + 6, 18) } : null]} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}> - - - )} - - {mode === 'signin' ? 'Welcome back' : 'Create your account'} - - - Sync your addons, progress and settings across devices - - - - {/* Important Warning Message */} - + {navigation.canGoBack() && ( navigation.goBack()} + style={[styles.backButton, { top: backButtonTop }]} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > - - - - Important Notice - - - This authentication system will be completely replaced by local backup/restore functionality by October 8th. Please create backup files as your cloud data will be permanently destroyed. - - - Read more {showWarningDetails ? '▼' : '▶'} - - + - - {/* Expanded Details */} - {showWarningDetails && ( - - - - Why is this system being discontinued? - - - • Lack of real-time support for addon synchronization{'\n'} - • Database synchronization issues with addons and settings{'\n'} - • Unreliable cloud data management{'\n'} - • Performance problems with remote data access - - - - Benefits of Local Backup System: - - - • Instant addon synchronization across devices{'\n'} - • Reliable offline access to all your data{'\n'} - • Complete control over your backup files{'\n'} - • Faster performance with local data storage{'\n'} - • No dependency on external servers{'\n'} - • Easy migration between devices - - - - )} - + )} - {/* Main Card - Hide when warning details are expanded */} - - 0 + ? { + justifyContent: 'flex-start', + paddingTop: Platform.OS === 'ios' ? 12 : safeTopInset + 8, + } + : null, + ]} + > + 0 ? styles.centerHeaderCompact : null, + { + opacity: introOpacity, + transform: [{ translateY: introTranslateY }], + }, + ]} + > + + {mode === 'signin' ? 'Welcome back' : 'Create your account'} + + {keyboardHeight === 0 && ( + + Sync your addons, progress and settings across devices + + )} + + + 0 ? styles.cardCompact : null, { backgroundColor: Platform.OS === 'android' ? '#121212' : 'rgba(255,255,255,0.02)', borderColor: Platform.OS === 'android' ? '#1f1f1f' : 'rgba(255,255,255,0.06)', ...(Platform.OS !== 'android' ? { @@ -404,21 +343,17 @@ const AuthScreen: React.FC = () => { !signupDisabled && setMode('signup')} - activeOpacity={signupDisabled ? 1 : 0.8} - disabled={signupDisabled} + style={styles.switchButton} + onPress={() => setMode('signup')} + activeOpacity={0.8} > - Sign Up {signupDisabled && '(Disabled)'} + Sign Up @@ -583,29 +518,18 @@ const AuthScreen: React.FC = () => { {/* Switch Mode */} - {!signupDisabled && ( - setMode(mode === 'signin' ? 'signup' : 'signin')} - activeOpacity={0.7} - style={{ marginTop: 16 }} - > - - {mode === 'signin' ? "Don't have an account? " : 'Already have an account? '} - - {mode === 'signin' ? 'Sign up' : 'Sign in'} - + setMode(mode === 'signin' ? 'signup' : 'signin')} + activeOpacity={0.7} + style={{ marginTop: 16 }} + > + + {mode === 'signin' ? "Don't have an account? " : 'Already have an account? '} + + {mode === 'signin' ? 'Sign up' : 'Sign in'} - - )} - - {/* Signup disabled message */} - {signupDisabled && mode === 'signin' && ( - - - New account creation is temporarily disabled - - - )} + + {/* Skip sign in - more prominent when coming from onboarding */} { if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { openAlert( t('plugins.alert_invalid_url'), - 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nor include manifest.json:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch/manifest.json\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master' + 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nor include manifest.json:\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch/manifest.json\n\nExample:\nhttps://raw.githubusercontent.com/your-username/your-repo/refs/heads/main' ); return; } @@ -1291,7 +1291,7 @@ const PluginsScreen: React.FC = () => { if (!url.startsWith('https://raw.githubusercontent.com/') && !url.startsWith('http://')) { openAlert( 'Invalid URL Format', - 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/master' + 'Please use a valid GitHub raw URL format:\n\nhttps://raw.githubusercontent.com/username/repo/refs/heads/branch\n\nExample:\nhttps://raw.githubusercontent.com/your-username/your-repo/refs/heads/main' ); return; } @@ -1413,11 +1413,6 @@ const PluginsScreen: React.FC = () => { ); }; - const handleUseDefaultRepo = () => { - const defaultUrl = 'https://raw.githubusercontent.com/tapframe/nuvio-providers/refs/heads/main'; - setRepositoryUrl(defaultUrl); - }; - const handleToggleLocalScrapers = async (enabled: boolean) => { await updateSetting('enableLocalScrapers', enabled); @@ -2250,4 +2245,4 @@ const PluginsScreen: React.FC = () => { ); }; -export default PluginsScreen; \ No newline at end of file +export default PluginsScreen; diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 3b217ce9..cd545c8f 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -361,6 +361,9 @@ const SettingsScreen: React.FC = () => { if (item && item.visible === false) return false; return true; }; + const showTraktItem = isItemVisible('trakt'); + const showSimklItem = isItemVisible('simkl'); + const showCloudSyncItem = isItemVisible('cloud_sync'); // Filter categories based on conditions const visibleCategories = SETTINGS_CATEGORIES.filter(category => { @@ -376,22 +379,39 @@ const SettingsScreen: React.FC = () => { case 'account': return ( - {isItemVisible('trakt') && ( - } + {showCloudSyncItem && ( + + } renderControl={() => } - onPress={() => navigation.navigate('TraktSettings')} - isLast={!isItemVisible('simkl')} + onPress={() => (navigation as any).navigate('SyncSettings')} + isLast={!showTraktItem && !showSimklItem} isTablet={isTablet} /> )} - {isItemVisible('simkl') && ( - } + {showTraktItem && ( + } + renderControl={() => } + onPress={() => navigation.navigate('TraktSettings')} + isLast={!showSimklItem} + isTablet={isTablet} + /> + )} + {showSimklItem && ( + } renderControl={() => } onPress={() => navigation.navigate('SimklSettings')} isLast={true} @@ -682,19 +702,35 @@ const SettingsScreen: React.FC = () => { contentContainerStyle={styles.scrollContent} > {/* Account */} - {(settingsConfig?.categories?.['account']?.visible !== false) && (isItemVisible('trakt') || isItemVisible('simkl')) && ( + {(settingsConfig?.categories?.['account']?.visible !== false) && (showTraktItem || showSimklItem || showCloudSyncItem) && ( - {isItemVisible('trakt') && ( + {showCloudSyncItem && ( + + } + renderControl={() => } + onPress={() => (navigation as any).navigate('SyncSettings')} + isLast={!showTraktItem && !showSimklItem} + /> + )} + {showTraktItem && ( } renderControl={() => } onPress={() => navigation.navigate('TraktSettings')} - isLast={!isItemVisible('simkl')} + isLast={!showSimklItem} /> )} - {isItemVisible('simkl') && ( + {showSimklItem && ( { @@ -1198,6 +1234,14 @@ const styles = StyleSheet.create({ width: 180, height: 180, }, + syncLogoIcon: { + width: 20, + height: 20, + }, + syncLogoIconTablet: { + width: 24, + height: 24, + }, brandLogoContainer: { alignItems: 'center', justifyContent: 'center', @@ -1211,4 +1255,4 @@ const styles = StyleSheet.create({ }, }); -export default SettingsScreen; \ No newline at end of file +export default SettingsScreen; diff --git a/src/screens/SyncSettingsScreen.tsx b/src/screens/SyncSettingsScreen.tsx new file mode 100644 index 00000000..6be0070f --- /dev/null +++ b/src/screens/SyncSettingsScreen.tsx @@ -0,0 +1,493 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + SafeAreaView, + ScrollView, + StatusBar, + StyleSheet, + Text, + TouchableOpacity, + useWindowDimensions, + View, +} from 'react-native'; +import { NavigationProp, useFocusEffect, useNavigation } from '@react-navigation/native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MaterialIcons } from '@expo/vector-icons'; +import { RootStackParamList } from '../navigation/AppNavigator'; +import { useTheme } from '../contexts/ThemeContext'; +import CustomAlert from '../components/CustomAlert'; +import { supabaseSyncService, SupabaseUser, RemoteSyncStats } from '../services/supabaseSyncService'; +import { useAccount } from '../contexts/AccountContext'; +import { useTraktContext } from '../contexts/TraktContext'; +import { useSimklContext } from '../contexts/SimklContext'; +import { useTranslation } from 'react-i18next'; + +const SyncSettingsScreen: React.FC = () => { + const { currentTheme } = useTheme(); + const { t } = useTranslation(); + const { width } = useWindowDimensions(); + const isTablet = width >= 768; + const navigation = useNavigation>(); + const insets = useSafeAreaInsets(); + const { user, signOut } = useAccount(); + const { isAuthenticated: traktAuthenticated } = useTraktContext(); + const { isAuthenticated: simklAuthenticated } = useSimklContext(); + + const [loading, setLoading] = useState(false); + const [syncCodeLoading, setSyncCodeLoading] = useState(false); + const [sessionUser, setSessionUser] = useState(null); + const [ownerId, setOwnerId] = useState(null); + const [remoteStats, setRemoteStats] = useState(null); + + const [alertVisible, setAlertVisible] = useState(false); + const [alertTitle, setAlertTitle] = useState(''); + const [alertMessage, setAlertMessage] = useState(''); + const [alertActions, setAlertActions] = useState void; style?: object }>>([]); + + const openAlert = useCallback( + (title: string, message: string, actions?: Array<{ label: string; onPress: () => void; style?: object }>) => { + setAlertTitle(title); + setAlertMessage(message); + setAlertActions(actions && actions.length > 0 ? actions : [{ label: 'OK', onPress: () => {} }]); + setAlertVisible(true); + }, + [] + ); + + const loadSyncState = useCallback(async () => { + setLoading(true); + try { + await supabaseSyncService.initialize(); + setSessionUser(supabaseSyncService.getCurrentSessionUser()); + const owner = await supabaseSyncService.getEffectiveOwnerId(); + setOwnerId(owner); + const stats = await supabaseSyncService.getRemoteStats(); + setRemoteStats(stats); + } catch (error: any) { + openAlert('Sync Error', error?.message || 'Failed to load sync state'); + } finally { + setLoading(false); + } + }, [openAlert]); + + useFocusEffect( + useCallback(() => { + loadSyncState(); + }, [loadSyncState]) + ); + + const authLabel = useMemo(() => { + if (!supabaseSyncService.isConfigured()) return 'Supabase not configured'; + if (!sessionUser) return 'Not authenticated'; + return `Email session${sessionUser.email ? ` (${sessionUser.email})` : ''}`; + }, [sessionUser]); + + const statItems = useMemo(() => { + if (!remoteStats) return []; + return [ + { label: 'Plugins', value: remoteStats.plugins }, + { label: 'Addons', value: remoteStats.addons }, + { label: 'Watch Progress', value: remoteStats.watchProgress }, + { label: 'Library Items', value: remoteStats.libraryItems }, + { label: 'Watched Items', value: remoteStats.watchedItems }, + ]; + }, [remoteStats]); + const isSignedIn = Boolean(user); + const externalSyncServices = useMemo( + () => [ + traktAuthenticated ? 'Trakt' : null, + simklAuthenticated ? 'Simkl' : null, + ].filter(Boolean) as string[], + [traktAuthenticated, simklAuthenticated] + ); + const externalSyncActive = externalSyncServices.length > 0; + + const handleManualSync = async () => { + setSyncCodeLoading(true); + try { + await supabaseSyncService.pullAllToLocal(); + openAlert('Cloud Data Pulled', 'Latest cloud data was pulled to this device.'); + await loadSyncState(); + } catch (error: any) { + openAlert('Pull Failed', error?.message || 'Failed to pull cloud data'); + } finally { + setSyncCodeLoading(false); + } + }; + + const handleUploadLocalData = async () => { + setSyncCodeLoading(true); + try { + await supabaseSyncService.pushAllLocalData(); + openAlert('Upload Complete', 'This device data has been uploaded to cloud.'); + await loadSyncState(); + } catch (error: any) { + openAlert('Upload Failed', error?.message || 'Failed to upload local data'); + } finally { + setSyncCodeLoading(false); + } + }; + + const handleSignOut = async () => { + setSyncCodeLoading(true); + try { + await signOut(); + await loadSyncState(); + } catch (error: any) { + openAlert('Sign Out Failed', error?.message || 'Failed to sign out'); + } finally { + setSyncCodeLoading(false); + } + }; + + return ( + + + + navigation.goBack()} style={styles.backButton}> + + {t('settings.title')} + + + + Nuvio Sync + + {loading ? ( + + + + ) : ( + <> + + + + + + Cloud Sync + + Keep your addons, progress and library aligned across devices. + + + + + + + + + External Sync Priority + + + {externalSyncActive + ? `${externalSyncServices.join(' + ')} is active. Watch progress and library updates are managed by these services instead of Nuvio cloud database.` + : 'If Trakt or Simkl sync is enabled, watch progress and library updates will use those services instead of Nuvio cloud database.'} + + + + + + + Account + + + {user?.email ? `Signed in as ${user.email}` : 'Not signed in'} + + + {!isSignedIn ? ( + navigation.navigate('Account')} + > + Sign In / Sign Up + + ) : ( + <> + navigation.navigate('AccountManage')} + > + Manage Account + + + Sign Out + + + )} + + + + {!isSignedIn ? ( + + + + Before You Sync + + + Sign in to start cloud sync and keep your data consistent across devices. + + + • Addons and plugin settings + • Watch progress and library + + {!supabaseSyncService.isConfigured() && ( + + Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to enable sync. + + )} + + ) : ( + <> + + + + Connection + + {authLabel} + + Effective owner: {ownerId || 'Unavailable'} + + {!supabaseSyncService.isConfigured() && ( + + Set EXPO_PUBLIC_SUPABASE_URL and EXPO_PUBLIC_SUPABASE_ANON_KEY to enable sync. + + )} + + + + + + Database Stats + + {!remoteStats ? ( + + Sign in to load remote data counts. + + ) : ( + + {statItems.map((item) => ( + + {item.value} + {item.label} + + ))} + + )} + + + + + + Actions + + + Pull to refresh this device from cloud, or upload this device as the latest source. + + + + {syncCodeLoading ? ( + + ) : ( + Pull From Cloud + )} + + + Upload This Device + + + + + )} + + + )} + + setAlertVisible(false)} + /> + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + loadingContainer: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingTop: 8, + }, + backButton: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: 8, + }, + backText: { + marginLeft: 8, + fontSize: 16, + fontWeight: '600', + }, + headerActions: { + minWidth: 32, + }, + screenTitle: { + fontSize: 32, + fontWeight: '800', + paddingHorizontal: 16, + marginTop: 4, + marginBottom: 10, + }, + content: { + padding: 16, + gap: 14, + }, + contentTablet: { + alignSelf: 'center', + width: '100%', + maxWidth: 980, + }, + heroCard: { + borderWidth: 1, + borderRadius: 16, + padding: 16, + }, + heroTopRow: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + }, + heroTitleWrap: { + flex: 1, + }, + heroTitle: { + fontSize: 20, + fontWeight: '800', + marginBottom: 4, + }, + heroSubtitle: { + fontSize: 13, + lineHeight: 18, + }, + card: { + borderWidth: 1, + borderRadius: 14, + padding: 14, + gap: 10, + }, + noteCard: { + borderWidth: 1, + borderRadius: 14, + padding: 14, + gap: 8, + }, + preAuthCard: { + gap: 12, + }, + preAuthList: { + gap: 6, + marginTop: 2, + }, + preAuthItem: { + fontSize: 13, + lineHeight: 18, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + cardTitle: { + fontSize: 15, + fontWeight: '700', + }, + cardText: { + fontSize: 13, + lineHeight: 18, + }, + warning: { + fontSize: 12, + marginTop: 4, + }, + statsGrid: { + marginTop: 2, + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + statTile: { + width: '48%', + borderWidth: 1, + borderRadius: 10, + paddingHorizontal: 10, + paddingVertical: 8, + }, + statValue: { + fontSize: 18, + fontWeight: '800', + marginBottom: 2, + }, + statLabel: { + fontSize: 11, + fontWeight: '600', + }, + buttonRow: { + flexDirection: 'row', + gap: 10, + }, + button: { + flex: 1, + borderRadius: 10, + minHeight: 42, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 12, + }, + primaryButton: { + borderWidth: 0, + }, + secondaryButton: { + borderWidth: 1, + }, + buttonDisabled: { + opacity: 0.55, + }, + buttonText: { + color: '#fff', + fontWeight: '700', + fontSize: 13, + }, +}); + +export default SyncSettingsScreen; diff --git a/src/screens/settings/AboutSettingsScreen.tsx b/src/screens/settings/AboutSettingsScreen.tsx index 04e3d3d4..18f39bb1 100644 --- a/src/screens/settings/AboutSettingsScreen.tsx +++ b/src/screens/settings/AboutSettingsScreen.tsx @@ -391,7 +391,7 @@ export const AboutFooter: React.FC<{ displayDownloads: number | null }> = ({ dis diff --git a/src/services/AccountService.ts b/src/services/AccountService.ts index 32dc943f..3d3d4440 100644 --- a/src/services/AccountService.ts +++ b/src/services/AccountService.ts @@ -1,4 +1,6 @@ import { mmkvStorage } from './mmkvStorage'; +import { supabaseSyncService, SupabaseUser } from './supabaseSyncService'; +import { logger } from '../utils/logger'; export type AuthUser = { id: string; @@ -9,6 +11,7 @@ export type AuthUser = { const USER_DATA_KEY = '@user:data'; const USER_SCOPE_KEY = '@user:current'; +const EMAIL_CONFIRMATION_REQUIRED_PREFIX = '__EMAIL_CONFIRMATION__'; class AccountService { private static instance: AccountService; @@ -19,23 +22,79 @@ class AccountService { return AccountService.instance; } + private mapSupabaseUser(user: SupabaseUser): AuthUser { + return { + id: user.id, + email: user.email, + displayName: user.user_metadata?.display_name as string | undefined, + avatarUrl: user.user_metadata?.avatar_url as string | undefined, + }; + } + + private async persistUser(user: AuthUser): Promise { + await mmkvStorage.setItem(USER_DATA_KEY, JSON.stringify(user)); + await mmkvStorage.setItem(USER_SCOPE_KEY, 'local'); + } + async signUpWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> { - // Since signup is disabled, always return error - return { error: 'Sign up is currently disabled due to upcoming system changes' }; + const result = await supabaseSyncService.signUpWithEmail(email, password); + if (result.error) { + return { error: result.error }; + } + + const sessionUser = supabaseSyncService.getCurrentSessionUser(); + if (!sessionUser) { + return { + error: `${EMAIL_CONFIRMATION_REQUIRED_PREFIX}Account created. Check your email to verify, then sign in.`, + }; + } + + const mapped = this.mapSupabaseUser(sessionUser); + await this.persistUser(mapped); + + try { + await supabaseSyncService.onSignUpPushAll(); + } catch (error) { + logger.error('[AccountService] Sign-up push-all failed:', error); + } + + return { user: mapped }; } async signInWithEmail(email: string, password: string): Promise<{ user?: AuthUser; error?: string }> { - // Since signin is disabled, always return error - return { error: 'Authentication is currently disabled' }; + const result = await supabaseSyncService.signInWithEmail(email, password); + if (result.error || !result.user) { + return { error: result.error || 'Sign in failed' }; + } + + const mapped = this.mapSupabaseUser(result.user); + await this.persistUser(mapped); + + try { + await supabaseSyncService.onSignInPullAll(); + } catch (error) { + logger.error('[AccountService] Sign-in pull-all failed:', error); + } + + return { user: mapped }; } async signOut(): Promise { + await supabaseSyncService.signOut(); await mmkvStorage.removeItem(USER_DATA_KEY); await mmkvStorage.setItem(USER_SCOPE_KEY, 'local'); } async getCurrentUser(): Promise { try { + await supabaseSyncService.initialize(); + const sessionUser = supabaseSyncService.getCurrentSessionUser(); + if (sessionUser) { + const mapped = this.mapSupabaseUser(sessionUser); + await this.persistUser(mapped); + return mapped; + } + const userData = await mmkvStorage.getItem(USER_DATA_KEY); if (!userData) return null; return JSON.parse(userData); @@ -69,4 +128,3 @@ class AccountService { export const accountService = AccountService.getInstance(); export default accountService; - diff --git a/src/services/introService.ts b/src/services/introService.ts index de9a1c78..9d96b43c 100644 --- a/src/services/introService.ts +++ b/src/services/introService.ts @@ -26,11 +26,21 @@ export interface IntroTimestamps { imdb_id: string; season: number; episode: number; - start_sec: number; - end_sec: number; - start_ms: number; - end_ms: number; - confidence: number; + intro?: { + start_sec: number; + end_sec: number; + confidence: number; + }; + recap?: { + start_sec: number; + end_sec: number; + confidence: number; + }; + outro?: { + start_sec: number; + end_sec: number; + confidence: number; + }; } async function getMalIdFromArm(imdbId: string): Promise { @@ -154,7 +164,7 @@ async function fetchFromAniSkip(malId: string, episode: number): Promise { try { - const response = await axios.get(`${INTRODB_API_URL}/intro`, { + const response = await axios.get(`${INTRODB_API_URL}/segments`, { params: { imdb_id: imdbId, season, @@ -163,26 +173,48 @@ async function fetchFromIntroDb(imdbId: string, season: number, episode: number) timeout: 5000, }); - logger.log(`[IntroService] Found intro for ${imdbId} S${season}E${episode}:`, { - start: response.data.start_sec, - end: response.data.end_sec, - confidence: response.data.confidence, - }); + const intervals: SkipInterval[] = []; - return [{ - startTime: response.data.start_sec, - endTime: response.data.end_sec, - type: 'intro', - provider: 'introdb' - }]; + if (response.data.intro) { + intervals.push({ + startTime: response.data.intro.start_sec, + endTime: response.data.intro.end_sec, + type: 'intro', + provider: 'introdb' + }); + } + + if (response.data.recap) { + intervals.push({ + startTime: response.data.recap.start_sec, + endTime: response.data.recap.end_sec, + type: 'recap', + provider: 'introdb' + }); + } + + if (response.data.outro) { + intervals.push({ + startTime: response.data.outro.start_sec, + endTime: response.data.outro.end_sec, + type: 'outro', + provider: 'introdb' + }); + } + + if (intervals.length > 0) { + logger.log(`[IntroService] Found ${intervals.length} segments for ${imdbId} S${season}E${episode}`); + } + + return intervals; } catch (error: any) { if (axios.isAxiosError(error) && error.response?.status === 404) { // No intro data available for this episode - this is expected - logger.log(`[IntroService] No intro data for ${imdbId} S${season}E${episode}`); + logger.log(`[IntroService] No segment data for ${imdbId} S${season}E${episode}`); return []; } - logger.error('[IntroService] Error fetching intro timestamps:', error?.message || error); + logger.error('[IntroService] Error fetching segments from IntroDB:', error?.message || error); return []; } } @@ -230,7 +262,8 @@ export async function submitIntro( season: number, episode: number, startTime: number, // in seconds - endTime: number // in seconds + endTime: number, // in seconds + segmentType: SkipType = 'intro' ): Promise { try { if (!apiKey) { @@ -240,8 +273,12 @@ export async function submitIntro( const response = await axios.post(`${INTRODB_API_URL}/submit`, { imdb_id: imdbId, + segment_type: segmentType === 'op' ? 'intro' : (segmentType === 'ed' ? 'outro' : segmentType), season, episode, + start_sec: startTime, + end_sec: endTime, + // Keep start_ms/end_ms for backward compatibility if the server still expects it start_ms: Math.round(startTime * 1000), end_ms: Math.round(endTime * 1000), }, { @@ -319,18 +356,24 @@ export async function getIntroTimestamps( imdbId: string, season: number, episode: number -): Promise { +): Promise { const intervals = await fetchFromIntroDb(imdbId, season, episode); - if (intervals.length > 0) { + const intro = intervals.find(i => i.type === 'intro'); + if (intro) { return { imdb_id: imdbId, season, episode, - start_sec: intervals[0].startTime, - end_sec: intervals[0].endTime, - start_ms: intervals[0].startTime * 1000, - end_ms: intervals[0].endTime * 1000, - confidence: 1.0 + start_sec: intro.startTime, + end_sec: intro.endTime, + start_ms: intro.startTime * 1000, + end_ms: intro.endTime * 1000, + confidence: 1.0, + intro: { + start_sec: intro.startTime, + end_sec: intro.endTime, + confidence: 1.0 + } }; } return null; diff --git a/src/services/pluginService.ts b/src/services/pluginService.ts index 8c7e2b25..a84437f4 100644 --- a/src/services/pluginService.ts +++ b/src/services/pluginService.ts @@ -6,6 +6,7 @@ import { Stream } from '../types/streams'; import { cacheService } from './cacheService'; import CryptoJS from 'crypto-js'; import { safeAxiosConfig, createSafeAxiosConfig } from '../utils/axiosConfig'; +import EventEmitter from 'eventemitter3'; const MAX_CONCURRENT_SCRAPERS = 5; const MAX_INFLIGHT_KEYS = 30; @@ -24,6 +25,12 @@ const VIDEO_CONTENT_TYPES = [ const MAX_PREFLIGHT_SIZE = 50 * 1024 * 1024; +export const PLUGIN_SYNC_EVENTS = { + CHANGED: 'changed', +} as const; + +const pluginSyncEmitter = new EventEmitter(); + // Types for local scrapers export interface ScraperManifest { name: string; @@ -176,6 +183,10 @@ class LocalScraperService { return LocalScraperService.instance; } + public getPluginSyncEventEmitter(): EventEmitter { + return pluginSyncEmitter; + } + private async initialize(): Promise { if (this.initialized) return; @@ -367,6 +378,7 @@ class LocalScraperService { }; this.repositories.set(id, newRepo); await this.saveRepositories(); + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'add_repository', id: newRepo.id }); logger.log('[LocalScraperService] Added repository:', newRepo.name); return id; } @@ -386,6 +398,7 @@ class LocalScraperService { this.repositoryUrl = updatedRepo.url; this.repositoryName = updatedRepo.name; } + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'update_repository', id }); logger.log('[LocalScraperService] Updated repository:', updatedRepo.name); } @@ -424,6 +437,7 @@ class LocalScraperService { this.repositories.delete(id); await this.saveRepositories(); await this.saveInstalledScrapers(); + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'remove_repository', id }); logger.log('[LocalScraperService] Removed repository:', id); } @@ -450,6 +464,7 @@ class LocalScraperService { } logger.log('[LocalScraperService] Switched to repository:', repo.name); + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'set_current_repository', id }); } getCurrentRepositoryId(): string { @@ -553,6 +568,7 @@ class LocalScraperService { this.repositories.set(id, repo); await this.saveRepositories(); + pluginSyncEmitter.emit(PLUGIN_SYNC_EVENTS.CHANGED, { action: 'toggle_repository_enabled', id, enabled }); logger.log('[LocalScraperService] Toggled repository', repo.name, 'to', enabled ? 'enabled' : 'disabled'); } @@ -1777,4 +1793,4 @@ class LocalScraperService { export const localScraperService = LocalScraperService.getInstance(); export const pluginService = localScraperService; // Alias for UI consistency -export default localScraperService; \ No newline at end of file +export default localScraperService; diff --git a/src/services/stremioService.ts b/src/services/stremioService.ts index e155ad26..ec886e95 100644 --- a/src/services/stremioService.ts +++ b/src/services/stremioService.ts @@ -1250,6 +1250,49 @@ class StremioService { const addons = this.getInstalledAddons(); + // Some addons use non-standard meta types (e.g. "anime") but expect streams under the "series" endpoint. + // We'll try the requested type first, then (if no addons match) fall back to "series". + const pickStreamAddons = (requestType: string) => + addons.filter(addon => { + if (!addon.resources || !Array.isArray(addon.resources)) { + logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`); + return false; + } + + let hasStreamResource = false; + let supportsIdPrefix = false; + + for (const resource of addon.resources) { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + const typedResource = resource as ResourceObject; + if (typedResource.name === 'stream' && + Array.isArray(typedResource.types) && + typedResource.types.includes(requestType)) { + hasStreamResource = true; + + if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { + supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p)); + } else { + supportsIdPrefix = true; + } + break; + } + } else if (typeof resource === 'string' && resource === 'stream' && addon.types) { + if (Array.isArray(addon.types) && addon.types.includes(requestType)) { + hasStreamResource = true; + if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { + supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p)); + } else { + supportsIdPrefix = true; + } + break; + } + } + } + + return hasStreamResource && supportsIdPrefix; + }); + // Check if local scrapers are enabled and execute them first try { // Load settings from AsyncStorage directly (scoped with fallback) @@ -1396,64 +1439,109 @@ class StremioService { // TMDB Embed addon not found } - // Find addons that provide streams and sort them by installation order - const streamAddons = addons - .filter(addon => { - if (!addon.resources || !Array.isArray(addon.resources)) { - logger.log(`⚠️ [getStreams] Addon ${addon.id} has no valid resources array`); - return false; + let effectiveType = type; + let streamAddons = pickStreamAddons(type); + + logger.log(`🧭 [getStreams] Resolving stream addons for type='${type}' id='${id}' (matched=${streamAddons.length})`); + + if (streamAddons.length === 0) { + const fallbackTypes = ['series', 'movie', 'tv', 'channel'].filter(t => t !== type); + for (const fallbackType of fallbackTypes) { + const fallbackAddons = pickStreamAddons(fallbackType); + if (fallbackAddons.length > 0) { + effectiveType = fallbackType; + streamAddons = fallbackAddons; + logger.log(`🔁 [getStreams] No stream addons for type '${type}', falling back to '${effectiveType}' for id '${id}'`); + break; } + } + } - let hasStreamResource = false; - let supportsIdPrefix = false; - - // Iterate through the resources array, checking each element - for (const resource of addon.resources) { - // Check if the current element is a ResourceObject - if (typeof resource === 'object' && resource !== null && 'name' in resource) { - const typedResource = resource as ResourceObject; - if (typedResource.name === 'stream' && - Array.isArray(typedResource.types) && - typedResource.types.includes(type)) { - hasStreamResource = true; - - // Check if this addon supports the ID prefix (generic: any prefix that matches start of id) - if (Array.isArray(typedResource.idPrefixes) && typedResource.idPrefixes.length > 0) { - supportsIdPrefix = typedResource.idPrefixes.some(p => id.startsWith(p)); - } else { - // If no idPrefixes specified, assume it supports all prefixes - supportsIdPrefix = true; - } - break; // Found the stream resource object, no need to check further - } - } - // Check if the element is the simple string "stream" AND the addon has a top-level types array - else if (typeof resource === 'string' && resource === 'stream' && addon.types) { - if (Array.isArray(addon.types) && addon.types.includes(type)) { - hasStreamResource = true; - // For simple string resources, check addon-level idPrefixes (generic) - if (addon.idPrefixes && Array.isArray(addon.idPrefixes) && addon.idPrefixes.length > 0) { - supportsIdPrefix = addon.idPrefixes.some(p => id.startsWith(p)); - } else { - // If no idPrefixes specified, assume it supports all prefixes - supportsIdPrefix = true; - } - break; // Found the simple stream resource string and type support - } - } - } - - const canHandleRequest = hasStreamResource && supportsIdPrefix; - - return canHandleRequest; - }); - - + if (effectiveType !== type) { + logger.log(`🧭 [getStreams] Using effectiveType='${effectiveType}' (requested='${type}') for id='${id}'`); + } if (streamAddons.length === 0) { logger.warn('⚠️ [getStreams] No addons found that can provide streams'); - // Optionally call callback with an empty result or specific status? - // For now, just return if no addons. + + // Log what the URL would have been for debugging + const encodedId = encodeURIComponent(id); + const exampleUrl = `/stream/${effectiveType}/${encodedId}.json`; + logger.log(`🚫 [getStreams] No stream addons matched. Would have requested: ${exampleUrl}`); + logger.log(`🚫 [getStreams] Details: requestedType='${type}' effectiveType='${effectiveType}' id='${id}'`); + + // Show which addons have stream capability but didn't match + const streamCapableAddons = addons.filter(addon => { + if (!addon.resources || !Array.isArray(addon.resources)) return false; + return addon.resources.some(resource => { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + return (resource as ResourceObject).name === 'stream'; + } + return typeof resource === 'string' && resource === 'stream'; + }); + }); + + if (streamCapableAddons.length > 0) { + logger.log(`🚫 [getStreams] Found ${streamCapableAddons.length} stream-capable addon(s) that didn't match:`); + + for (const addon of streamCapableAddons) { + const streamResources = addon.resources!.filter(resource => { + if (typeof resource === 'object' && resource !== null && 'name' in resource) { + return (resource as ResourceObject).name === 'stream'; + } + return typeof resource === 'string' && resource === 'stream'; + }); + + for (const resource of streamResources) { + if (typeof resource === 'object' && resource !== null) { + const typedResource = resource as ResourceObject; + const types = typedResource.types || []; + const prefixes = typedResource.idPrefixes || []; + const typeMatch = types.includes(effectiveType); + const prefixMatch = prefixes.length === 0 || prefixes.some(p => id.startsWith(p)); + + if (addon.url) { + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); + const wouldBeUrl = queryParams + ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` + : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; + + console.log( + ` ❌ ${addon.name} (${addon.id}):\n` + + ` types=[${types.join(',')}] typeMatch=${typeMatch}\n` + + ` prefixes=[${prefixes.join(',')}] prefixMatch=${prefixMatch}\n` + + ` url=${wouldBeUrl}` + ); + } else { + console.log(` ❌ ${addon.name} (${addon.id}): no URL configured`); + } + } else if (typeof resource === 'string' && resource === 'stream') { + // String resource - check addon-level types and prefixes + const addonTypes = addon.types || []; + const addonPrefixes = addon.idPrefixes || []; + const typeMatch = addonTypes.includes(effectiveType); + const prefixMatch = addonPrefixes.length === 0 || addonPrefixes.some(p => id.startsWith(p)); + + if (addon.url) { + const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); + const wouldBeUrl = queryParams + ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` + : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; + + console.log( + ` ❌ ${addon.name} (${addon.id}) [addon-level]:\n` + + ` types=[${addonTypes.join(',')}] typeMatch=${typeMatch}\n` + + ` prefixes=[${addonPrefixes.join(',')}] prefixMatch=${prefixMatch}\n` + + ` url=${wouldBeUrl}` + ); + } + } + } + } + } else { + logger.log(`🚫 [getStreams] No stream-capable addons installed`); + } + return; } @@ -1470,9 +1558,11 @@ class StremioService { const { baseUrl, queryParams } = this.getAddonBaseURL(addon.url); const encodedId = encodeURIComponent(id); - const url = queryParams ? `${baseUrl}/stream/${type}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${type}/${encodedId}.json`; + const url = queryParams ? `${baseUrl}/stream/${effectiveType}/${encodedId}.json?${queryParams}` : `${baseUrl}/stream/${effectiveType}/${encodedId}.json`; - logger.log(`🔗 [getStreams] Requesting streams from ${addon.name} (${addon.id}) [${addon.installationId}]: ${url}`); + logger.log( + `🔗 [getStreams] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' requestedType='${type}' effectiveType='${effectiveType}' rawId='${id}')` + ); const response = await this.retryRequest(async () => { return await axios.get(url, safeAxiosConfig); @@ -1517,14 +1607,16 @@ class StremioService { const streamPath = `/stream/${type}/${encodedId}.json`; const url = queryParams ? `${baseUrl}${streamPath}?${queryParams}` : `${baseUrl}${streamPath}`; - logger.log(`Fetching streams from URL: ${url}`); + logger.log( + `🔗 [fetchStreamsFromAddon] GET ${url} (addon='${addon.name}' id='${addon.id}' install='${addon.installationId}' type='${type}' rawId='${id}')` + ); try { // Increase timeout for debrid services const timeout = addon.id.toLowerCase().includes('torrentio') ? 60000 : 10000; const response = await this.retryRequest(async () => { - logger.log(`Making request to ${url} with timeout ${timeout}ms`); + logger.log(`🌐 [fetchStreamsFromAddon] Requesting ${url} (timeout=${timeout}ms)`); return await axios.get(url, createSafeAxiosConfig(timeout, { headers: { 'Accept': 'application/json', @@ -1895,6 +1987,65 @@ class StremioService { return false; } + // Reconcile local addon order to match a remote ordered list of addon manifest URLs. + // Any local addons not present in the remote list are appended in their current order. + async applyAddonOrderFromManifestUrls(manifestUrls: string[]): Promise { + await this.ensureInitialized(); + if (!Array.isArray(manifestUrls) || manifestUrls.length === 0) return false; + + const normalizeManifestUrl = (raw: string): string => { + const value = (raw || '').trim(); + if (!value) return ''; + const withManifest = value.includes('manifest.json') + ? value + : `${value.replace(/\/$/, '')}/manifest.json`; + return withManifest.toLowerCase(); + }; + + const localByNormalizedUrl = new Map(); + for (const installationId of this.addonOrder) { + const addon = this.installedAddons.get(installationId); + if (!addon) continue; + const normalized = normalizeManifestUrl(addon.originalUrl || addon.url || ''); + if (!normalized) continue; + const list = localByNormalizedUrl.get(normalized) || []; + list.push(installationId); + localByNormalizedUrl.set(normalized, list); + } + + const nextOrder: string[] = []; + const seenInstallations = new Set(); + + for (const remoteUrl of manifestUrls) { + const normalizedRemote = normalizeManifestUrl(remoteUrl); + if (!normalizedRemote) continue; + const candidates = localByNormalizedUrl.get(normalizedRemote); + if (!candidates || candidates.length === 0) continue; + const installationId = candidates.shift(); + if (!installationId || seenInstallations.has(installationId)) continue; + nextOrder.push(installationId); + seenInstallations.add(installationId); + } + + for (const installationId of this.addonOrder) { + if (!this.installedAddons.has(installationId)) continue; + if (seenInstallations.has(installationId)) continue; + nextOrder.push(installationId); + seenInstallations.add(installationId); + } + + const changed = + nextOrder.length !== this.addonOrder.length || + nextOrder.some((id, index) => id !== this.addonOrder[index]); + + if (!changed) return false; + + this.addonOrder = nextOrder; + await this.saveAddonOrder(); + addonEmitter.emit(ADDON_EVENTS.ORDER_CHANGED); + return true; + } + // Check if any installed addons can provide streams (including embedded streams in metadata) async hasStreamProviders(type?: string): Promise { await this.ensureInitialized(); @@ -1997,4 +2148,4 @@ export interface AddonCatalogItem { } export const stremioService = StremioService.getInstance(); -export default stremioService; \ No newline at end of file +export default stremioService; diff --git a/src/services/supabaseSyncService.ts b/src/services/supabaseSyncService.ts new file mode 100644 index 00000000..2134edbc --- /dev/null +++ b/src/services/supabaseSyncService.ts @@ -0,0 +1,1299 @@ +import { AppState, Platform } from 'react-native'; +import { mmkvStorage } from './mmkvStorage'; +import { logger } from '../utils/logger'; +import { localScraperService, PLUGIN_SYNC_EVENTS } from './pluginService'; +import { stremioService, addonEmitter, ADDON_EVENTS, Manifest } from './stremioService'; +import { catalogService, StreamingContent } from './catalogService'; +import { storageService } from './storageService'; +import { watchedService, LocalWatchedItem } from './watchedService'; +import { TraktService } from './traktService'; + +const SUPABASE_SESSION_KEY = '@supabase:session'; +const DEFAULT_SYNC_DEBOUNCE_MS = 2000; + +type Nullable = T | null; + +export type SupabaseUser = { + id: string; + email?: string; + user_metadata?: { + display_name?: string; + avatar_url?: string; + [key: string]: unknown; + }; + app_metadata?: { + provider?: string; + [key: string]: unknown; + }; +}; + +type SupabaseSession = { + access_token: string; + refresh_token: string; + expires_at?: number; + expires_in?: number; + token_type?: string; + user: SupabaseUser; +}; + +type PluginRow = { + url: string; + name?: string; + enabled?: boolean; + sort_order?: number; +}; + +type AddonRow = { + url: string; + sort_order: number; +}; + +type WatchProgressRow = { + content_id: string; + content_type: 'movie' | 'series'; + video_id: string; + season: Nullable; + episode: Nullable; + position: number; + duration: number; + last_watched: number; + progress_key: string; +}; + +type LibraryRow = { + content_id: string; + content_type: string; + name?: string; + poster?: string; + poster_shape?: string; + background?: string; + description?: string; + release_info?: string; + imdb_rating?: number; + genres?: string[]; + addon_base_url?: string; + added_at?: number; +}; + +type WatchedRow = { + content_id: string; + content_type: string; + title?: string; + season: Nullable; + episode: Nullable; + watched_at: number; +}; + +type RpcClaimSyncCodeRow = { + result_owner_id: Nullable; + success: boolean; + message: string; +}; + +export type LinkedDevice = { + owner_id: string; + device_user_id: string; + device_name?: string; + linked_at: string; +}; + +export type RemoteSyncStats = { + plugins: number; + addons: number; + watchProgress: number; + libraryItems: number; + watchedItems: number; + linkedDevices: number; +}; + +type PushTarget = 'plugins' | 'addons' | 'watch_progress' | 'library' | 'watched_items'; + +class SupabaseSyncService { + private static instance: SupabaseSyncService; + + private readonly supabaseUrl: string; + private readonly anonKey: string; + private session: SupabaseSession | null = null; + private initializePromise: Promise | null = null; + private startupSyncPromise: Promise | null = null; + private listenersRegistered = false; + private suppressPushes = false; + private appStateSub: { remove: () => void } | null = null; + private lastForegroundPullAt = 0; + private readonly foregroundPullCooldownMs = 30000; + + private pendingPushTimers: Record | null> = { + plugins: null, + addons: null, + watch_progress: null, + library: null, + watched_items: null, + }; + + private constructor() { + this.supabaseUrl = (process.env.EXPO_PUBLIC_SUPABASE_URL || '').replace(/\/$/, ''); + this.anonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || ''; + } + + static getInstance(): SupabaseSyncService { + if (!SupabaseSyncService.instance) { + SupabaseSyncService.instance = new SupabaseSyncService(); + } + return SupabaseSyncService.instance; + } + + public isConfigured(): boolean { + return Boolean(this.supabaseUrl && this.anonKey); + } + + public getCurrentSessionUser(): SupabaseUser | null { + return this.session?.user || null; + } + + public isAnonymousSession(): boolean { + return this.session?.user?.app_metadata?.provider === 'anonymous'; + } + + public async initialize(): Promise { + if (!this.isConfigured()) { + logger.warn('[SupabaseSyncService] Missing Supabase env vars; sync disabled.'); + return; + } + + if (this.initializePromise) { + await this.initializePromise; + return; + } + + this.initializePromise = (async () => { + await this.loadStoredSession(); + await this.ensureValidSession(); + this.registerSyncListeners(); + })(); + + try { + await this.initializePromise; + } finally { + this.initializePromise = null; + } + } + + public async signUpWithEmail(email: string, password: string): Promise<{ user?: SupabaseUser; error?: string }> { + if (!this.isConfigured()) { + return { error: 'Supabase is not configured' }; + } + + try { + const response = await this.requestAuth<{ user?: SupabaseUser; session?: SupabaseSession }>('/auth/v1/signup', { + method: 'POST', + body: { email, password }, + }); + + if (response.session) { + await this.setSession(response.session); + } + + // In email-confirmation mode, Supabase may not establish a session immediately. + // Treat this as a successful signup attempt and let caller handle next UX step. + return { user: response.user }; + } catch (error: any) { + return { error: this.extractErrorMessage(error, 'Signup failed') }; + } + } + + public async signInWithEmail(email: string, password: string): Promise<{ user?: SupabaseUser; error?: string }> { + if (!this.isConfigured()) { + return { error: 'Supabase is not configured' }; + } + + try { + const response = await this.requestAuth('/auth/v1/token?grant_type=password', { + method: 'POST', + body: { email, password }, + }); + await this.setSession(response); + return { user: response.user }; + } catch (error: any) { + return { error: this.extractErrorMessage(error, 'Sign in failed') }; + } + } + + public async signOut(): Promise { + if (!this.isConfigured()) return; + const token = await this.getValidAccessToken(); + + if (token) { + try { + await this.request('/auth/v1/logout', { + method: 'POST', + authToken: token, + }); + } catch (error) { + logger.warn('[SupabaseSyncService] Supabase logout request failed, clearing local session:', error); + } + } + + this.session = null; + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + } + + public async startupSync(): Promise { + if (!this.isConfigured()) return; + await this.initialize(); + logger.log('[SupabaseSyncService] startupSync: begin'); + + if (this.startupSyncPromise) { + await this.startupSyncPromise; + return; + } + + this.startupSyncPromise = this.runStartupSync(); + try { + await this.startupSyncPromise; + logger.log('[SupabaseSyncService] startupSync: complete'); + } finally { + this.startupSyncPromise = null; + } + } + + public async onSignUpPushAll(): Promise { + await this.pushAllLocalData(); + } + + public async onSignInPullAll(): Promise { + await this.pullAllToLocal(); + } + + public async syncNow(): Promise { + await this.startupSync(); + } + + public async pushAllLocalData(): Promise { + await this.initialize(); + logger.log('[SupabaseSyncService] pushAllLocalData: begin'); + + await this.pushPluginsFromLocal(); + await this.pushAddonsFromLocal(); + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) { + return; + } + + await this.pushWatchProgressFromLocal(); + await this.pushLibraryFromLocal(); + await this.pushWatchedItemsFromLocal(); + logger.log('[SupabaseSyncService] pushAllLocalData: complete'); + } + + public async pullAllToLocal(): Promise { + await this.initialize(); + logger.log('[SupabaseSyncService] pullAllToLocal: begin'); + + await this.withSuppressedPushes(async () => { + await this.pullPluginsToLocal(); + await this.pullAddonsToLocal(); + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) { + return; + } + + await this.pullWatchProgressToLocal(); + await this.pullLibraryToLocal(); + await this.pullWatchedItemsToLocal(); + }); + logger.log('[SupabaseSyncService] pullAllToLocal: complete'); + } + + public async generateSyncCode(pin: string): Promise<{ code?: string; error?: string }> { + try { + await this.pushAllLocalData(); + const response = await this.callRpc>('generate_sync_code', { p_pin: pin }); + const code = response?.[0]?.code; + if (!code) return { error: 'Failed to generate sync code' }; + return { code }; + } catch (error: any) { + return { error: this.extractErrorMessage(error, 'Failed to generate sync code') }; + } + } + + public async getSyncCode(pin: string): Promise<{ code?: string; error?: string }> { + try { + const response = await this.callRpc>('get_sync_code', { p_pin: pin }); + const code = response?.[0]?.code; + if (!code) return { error: 'No sync code found' }; + return { code }; + } catch (error: any) { + return { error: this.extractErrorMessage(error, 'Failed to fetch sync code') }; + } + } + + public async claimSyncCode(code: string, pin: string, deviceName?: string): Promise<{ success: boolean; message: string }> { + try { + const response = await this.callRpc('claim_sync_code', { + p_code: code, + p_pin: pin, + p_device_name: deviceName || `Nuvio ${Platform.OS}`, + }); + const result = response?.[0]; + if (!result || !result.success) { + return { + success: false, + message: result?.message || 'Failed to claim sync code', + }; + } + + await this.pullAllToLocal(); + + return { + success: true, + message: result.message || 'Device linked successfully', + }; + } catch (error: any) { + return { + success: false, + message: this.extractErrorMessage(error, 'Failed to claim sync code'), + }; + } + } + + public async getLinkedDevices(): Promise { + try { + const token = await this.getValidAccessToken(); + if (!token) return []; + + const ownerId = await this.getEffectiveOwnerId(); + if (!ownerId) return []; + + return await this.request( + `/rest/v1/linked_devices?select=owner_id,device_user_id,device_name,linked_at&owner_id=eq.${encodeURIComponent(ownerId)}&order=linked_at.desc`, + { + method: 'GET', + authToken: token, + } + ); + } catch (error) { + logger.error('[SupabaseSyncService] Failed to fetch linked devices:', error); + return []; + } + } + + public async getRemoteStats(): Promise { + try { + const token = await this.getValidAccessToken(); + if (!token) return null; + + const ownerId = await this.getEffectiveOwnerId(); + if (!ownerId) return null; + + const ownerFilter = encodeURIComponent(ownerId); + const [ + pluginRows, + addonRows, + watchRows, + libraryRows, + watchedRows, + deviceRows, + ] = await Promise.all([ + this.request>(`/rest/v1/plugins?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/addons?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/watch_progress?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/library_items?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/watched_items?select=id&user_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + this.request>(`/rest/v1/linked_devices?select=device_user_id&owner_id=eq.${ownerFilter}`, { + method: 'GET', + authToken: token, + }), + ]); + + return { + plugins: pluginRows?.length || 0, + addons: addonRows?.length || 0, + watchProgress: watchRows?.length || 0, + libraryItems: libraryRows?.length || 0, + watchedItems: watchedRows?.length || 0, + linkedDevices: deviceRows?.length || 0, + }; + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to fetch remote stats:', error); + return null; + } + } + + public async unlinkDevice(deviceUserId: string): Promise<{ success: boolean; error?: string }> { + try { + await this.callRpc('unlink_device', { p_device_user_id: deviceUserId }); + return { success: true }; + } catch (error: any) { + return { + success: false, + error: this.extractErrorMessage(error, 'Failed to unlink device'), + }; + } + } + + public async getEffectiveOwnerId(): Promise { + try { + const response = await this.callRpc('get_sync_owner', {}); + if (typeof response === 'string') return response; + if (Array.isArray(response)) { + const first = response[0]; + if (typeof first === 'string') return first; + if (first && typeof first === 'object') { + const candidate = (first as any).get_sync_owner || (first as any).id; + return typeof candidate === 'string' ? candidate : null; + } + } + if (response && typeof response === 'object') { + const candidate = (response as any).get_sync_owner || (response as any).id; + return typeof candidate === 'string' ? candidate : null; + } + return null; + } catch (error) { + logger.error('[SupabaseSyncService] Failed to resolve effective owner id:', error); + return null; + } + } + + private async runStartupSync(): Promise { + logger.log('[SupabaseSyncService] runStartupSync: step=pull_plugins:start'); + const pluginPullOk = await this.safeRun('pull_plugins', async () => { + await this.withSuppressedPushes(async () => { + await this.pullPluginsToLocal(); + }); + }); + logger.log(`[SupabaseSyncService] runStartupSync: step=pull_plugins:done ok=${pluginPullOk}`); + + logger.log('[SupabaseSyncService] runStartupSync: step=pull_addons:start'); + const addonPullOk = await this.safeRun('pull_addons', async () => { + await this.withSuppressedPushes(async () => { + await this.pullAddonsToLocal(); + }); + }); + logger.log(`[SupabaseSyncService] runStartupSync: step=pull_addons:done ok=${addonPullOk}`); + if (!pluginPullOk || !addonPullOk) { + logger.warn('[SupabaseSyncService] runStartupSync: one or more pull steps failed; skipped startup push-by-design'); + } + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) { + logger.log('[SupabaseSyncService] Trakt is connected; skipping progress/library/watched Supabase sync.'); + return; + } + + const watchPullOk = await this.safeRun('pull_watch_progress', async () => { + await this.withSuppressedPushes(async () => { + await this.pullWatchProgressToLocal(); + }); + }); + + const libraryPullOk = await this.safeRun('pull_library', async () => { + await this.withSuppressedPushes(async () => { + await this.pullLibraryToLocal(); + }); + }); + + const watchedPullOk = await this.safeRun('pull_watched_items', async () => { + await this.withSuppressedPushes(async () => { + await this.pullWatchedItemsToLocal(); + }); + }); + + if (!watchPullOk || !libraryPullOk || !watchedPullOk) { + logger.warn('[SupabaseSyncService] runStartupSync: one or more content pulls failed; skipped startup push-by-design'); + } + } + + private async safeRun(step: string, task: () => Promise): Promise { + try { + await task(); + return true; + } catch (error) { + logger.error(`[SupabaseSyncService] Sync step failed (${step}):`, error); + return false; + } + } + + private registerSyncListeners(): void { + if (this.listenersRegistered) return; + this.listenersRegistered = true; + + addonEmitter.on(ADDON_EVENTS.ADDON_ADDED, () => this.schedulePush('addons')); + addonEmitter.on(ADDON_EVENTS.ADDON_REMOVED, () => this.schedulePush('addons')); + addonEmitter.on(ADDON_EVENTS.ORDER_CHANGED, () => this.schedulePush('addons')); + + localScraperService.getPluginSyncEventEmitter().on(PLUGIN_SYNC_EVENTS.CHANGED, () => this.schedulePush('plugins')); + + catalogService.onLibraryAdd(() => this.schedulePush('library')); + catalogService.onLibraryRemove(() => this.schedulePush('library')); + + storageService.subscribeToWatchProgressUpdates(() => this.schedulePush('watch_progress')); + storageService.onWatchProgressRemoved(() => this.schedulePush('watch_progress')); + + watchedService.subscribeToWatchedUpdates(() => this.schedulePush('watched_items')); + + if (!this.appStateSub) { + this.appStateSub = AppState.addEventListener('change', (state) => { + if (state === 'active') { + this.onAppForeground().catch((error) => { + logger.warn('[SupabaseSyncService] Foreground pull failed:', error); + }); + } + }); + } + } + + private async onAppForeground(): Promise { + if (!this.isConfigured()) return; + if (this.suppressPushes) return; + + const now = Date.now(); + if (now - this.lastForegroundPullAt < this.foregroundPullCooldownMs) return; + this.lastForegroundPullAt = now; + logger.log('[SupabaseSyncService] App foreground: triggering pullAllToLocal'); + + await this.initialize(); + if (!this.session) return; + + await this.safeRun('foreground_pull_all', async () => { + await this.pullAllToLocal(); + }); + } + + private schedulePush(target: PushTarget): void { + if (!this.isConfigured() || this.suppressPushes) { + return; + } + + const existing = this.pendingPushTimers[target]; + if (existing) clearTimeout(existing); + logger.log(`[SupabaseSyncService] schedulePush: target=${target} delayMs=${DEFAULT_SYNC_DEBOUNCE_MS}`); + + this.pendingPushTimers[target] = setTimeout(() => { + this.pendingPushTimers[target] = null; + this.executeScheduledPush(target).catch((error) => { + logger.error(`[SupabaseSyncService] Scheduled push failed (${target}):`, error); + }); + }, DEFAULT_SYNC_DEBOUNCE_MS); + } + + private async executeScheduledPush(target: PushTarget): Promise { + await this.initialize(); + if (!this.session) return; + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:start`); + + if (target === 'plugins') { + await this.pushPluginsFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + return; + } + + if (target === 'addons') { + await this.pushAddonsFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + return; + } + + const traktConnected = await this.isTraktConnected(); + if (traktConnected) { + return; + } + + if (target === 'watch_progress') { + await this.pushWatchProgressFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + return; + } + if (target === 'library') { + await this.pushLibraryFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + return; + } + + await this.pushWatchedItemsFromLocal(); + logger.log(`[SupabaseSyncService] executeScheduledPush: target=${target}:done`); + } + + private async withSuppressedPushes(task: () => Promise): Promise { + this.suppressPushes = true; + try { + await task(); + } finally { + this.suppressPushes = false; + } + } + + private async loadStoredSession(): Promise { + try { + const raw = await mmkvStorage.getItem(SUPABASE_SESSION_KEY); + if (!raw) { + this.session = null; + return; + } + this.session = JSON.parse(raw) as SupabaseSession; + } catch (error) { + logger.error('[SupabaseSyncService] Failed to load stored session:', error); + this.session = null; + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + } + } + + private async setSession(session: SupabaseSession): Promise { + this.session = session; + await mmkvStorage.setItem(SUPABASE_SESSION_KEY, JSON.stringify(session)); + } + + private isSessionExpired(session: SupabaseSession): boolean { + if (!session.expires_at) return false; + const now = Math.floor(Date.now() / 1000); + return now >= (session.expires_at - 30); + } + + private async refreshSession(refreshToken: string): Promise { + return await this.requestAuth('/auth/v1/token?grant_type=refresh_token', { + method: 'POST', + body: { refresh_token: refreshToken }, + }); + } + + private async ensureValidSession(): Promise { + if (!this.session) return false; + if (!this.session.access_token || !this.session.refresh_token) return false; + + if (!this.isSessionExpired(this.session)) return true; + + try { + const refreshed = await this.refreshSession(this.session.refresh_token); + await this.setSession(refreshed); + return true; + } catch (error) { + logger.error('[SupabaseSyncService] Failed to refresh session:', error); + this.session = null; + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + return false; + } + } + + private async getValidAccessToken(): Promise { + await this.initialize(); + if (!this.session) return null; + + if (this.isSessionExpired(this.session)) { + try { + const refreshed = await this.refreshSession(this.session.refresh_token); + await this.setSession(refreshed); + } catch (error) { + logger.error('[SupabaseSyncService] Token refresh failed:', error); + this.session = null; + await mmkvStorage.removeItem(SUPABASE_SESSION_KEY); + return null; + } + } + + return this.session?.access_token || null; + } + + private async requestAuth(path: string, options: { method: string; body?: unknown }): Promise { + return await this.request(path, { + method: options.method, + body: options.body, + authToken: null, + }); + } + + private async request( + path: string, + options: { + method: string; + body?: unknown; + authToken: string | null; + } + ): Promise { + if (!this.isConfigured()) { + throw new Error('Supabase is not configured'); + } + + const headers: Record = { + apikey: this.anonKey, + }; + if (options.authToken) { + headers.Authorization = `Bearer ${options.authToken}`; + } + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json'; + } + + const response = await fetch(`${this.supabaseUrl}${path}`, { + method: options.method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }); + + const raw = await response.text(); + const parsed = this.parsePayload(raw); + + if (!response.ok) { + throw this.buildRequestError(response.status, parsed, raw); + } + + return parsed as T; + } + + private parsePayload(raw: string): unknown { + if (!raw) return null; + try { + return JSON.parse(raw); + } catch { + return raw; + } + } + + private buildRequestError(status: number, parsed: unknown, raw: string): Error { + if (parsed && typeof parsed === 'object') { + const message = + (parsed as any).message || + (parsed as any).msg || + (parsed as any).error_description || + (parsed as any).error; + if (typeof message === 'string' && message.trim().length > 0) { + return new Error(message); + } + } + if (raw && raw.trim().length > 0) { + return new Error(raw); + } + return new Error(`Supabase request failed with status ${status}`); + } + + private extractErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error && error.message) { + const raw = error.message.trim(); + + let parsed: any = null; + if (raw.startsWith('{') && raw.endsWith('}')) { + try { + parsed = JSON.parse(raw); + } catch { + parsed = null; + } + } + + const errorCode = (parsed?.error_code || parsed?.code || '').toString().toLowerCase(); + const message = (parsed?.msg || parsed?.message || raw).toString().trim(); + + if (errorCode === 'invalid_credentials') { + return 'Invalid email or password'; + } + if (errorCode === 'email_not_confirmed') { + return 'Email not confirmed. Check your inbox or Spam/Junk folder, verify your account, then sign in.'; + } + + if (message.length > 0) { + return message; + } + } + return fallback; + } + + private async callRpc(functionName: string, payload?: Record): Promise { + const token = await this.getValidAccessToken(); + if (!token) { + throw new Error('Not authenticated'); + } + + return await this.request(`/rest/v1/rpc/${functionName}`, { + method: 'POST', + body: payload || {}, + authToken: token, + }); + } + + private normalizeUrl(url: string): string { + return url.trim().toLowerCase(); + } + + private toBigIntNumber(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.trunc(n); + } + + private secondsToMsLong(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + return Math.trunc(n * 1000); + } + + private normalizeEpochMs(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + // If value looks like seconds, convert to milliseconds. + if (n < 1_000_000_000_000) { + return Math.trunc(n * 1000); + } + return Math.trunc(n); + } + + private msToSeconds(value: unknown): number { + const n = Number(value); + if (!Number.isFinite(n) || n <= 0) return 0; + return n / 1000; + } + + private addonManifestUrl(addon: Manifest): string | null { + const raw = (addon.originalUrl || addon.url || '').trim(); + if (!raw) return null; + if (raw.includes('manifest.json')) return raw; + return `${raw.replace(/\/$/, '')}/manifest.json`; + } + + private parseWatchProgressKey(key: string): { + contentType: 'movie' | 'series'; + contentId: string; + season: number | null; + episode: number | null; + videoId: string; + progressKey: string; + } | null { + const parts = key.split(':'); + if (parts.length < 2) return null; + + const contentType: 'movie' | 'series' = parts[0] === 'movie' ? 'movie' : 'series'; + const contentId = parts[1]; + const episodeId = parts.length > 2 ? parts.slice(2).join(':') : ''; + let season: number | null = null; + let episode: number | null = null; + + if (episodeId) { + const match = episodeId.match(/:(\d+):(\d+)$/); + if (match) { + season = Number(match[1]); + episode = Number(match[2]); + } + } + + const videoId = episodeId || contentId; + const progressKey = contentType === 'movie' + ? contentId + : (season != null && episode != null ? `${contentId}_s${season}e${episode}` : `${contentId}_${videoId}`); + + return { + contentType, + contentId, + season, + episode, + videoId, + progressKey, + }; + } + + private toStreamingContent(item: LibraryRow): StreamingContent { + const type = item.content_type === 'movie' ? 'movie' : 'series'; + const posterShape = (item.poster_shape || 'POSTER').toLowerCase() as 'poster' | 'square' | 'landscape'; + + return { + id: item.content_id, + type, + name: item.name || '', + poster: item.poster || '', + posterShape, + banner: item.background, + description: item.description, + releaseInfo: item.release_info, + imdbRating: item.imdb_rating != null ? String(item.imdb_rating) : undefined, + genres: item.genres || [], + addonId: item.addon_base_url, + addedToLibraryAt: item.added_at, + inLibrary: true, + }; + } + + private toWatchedItem(row: WatchedRow): LocalWatchedItem { + return { + content_id: row.content_id, + content_type: row.content_type === 'movie' ? 'movie' : 'series', + title: row.title || '', + season: row.season == null ? null : Number(row.season), + episode: row.episode == null ? null : Number(row.episode), + watched_at: Number(row.watched_at || Date.now()), + }; + } + + private async isTraktConnected(): Promise { + try { + return await TraktService.getInstance().isAuthenticated(); + } catch { + return false; + } + } + + private async pullPluginsToLocal(): Promise { + const token = await this.getValidAccessToken(); + if (!token) return; + const ownerId = await this.getEffectiveOwnerId(); + if (!ownerId) return; + + const rows = await this.request( + `/rest/v1/plugins?select=url,name,enabled,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&order=sort_order.asc`, + { + method: 'GET', + authToken: token, + } + ); + logger.log(`[SupabaseSyncService] pullPluginsToLocal: remoteCount=${rows?.length || 0}`); + + const localRepos = await localScraperService.getRepositories(); + const byUrl = new Map(localRepos.map((repo) => [this.normalizeUrl(repo.url), repo])); + const remoteSet = new Set( + (rows || []) + .map((row) => (row?.url ? this.normalizeUrl(row.url) : null)) + .filter((url): url is string => Boolean(url)) + ); + + for (const row of rows || []) { + if (!row.url) continue; + const normalized = this.normalizeUrl(row.url); + const existing = byUrl.get(normalized); + + if (!existing) { + await localScraperService.addRepository({ + name: row.name || localScraperService.extractRepositoryName(row.url), + url: row.url, + enabled: row.enabled !== false, + description: 'Synced from cloud', + }); + continue; + } + + const shouldUpdate = + (row.name && row.name !== existing.name) || + (typeof row.enabled === 'boolean' && row.enabled !== existing.enabled); + + if (shouldUpdate) { + await localScraperService.updateRepository(existing.id, { + name: row.name || existing.name, + enabled: typeof row.enabled === 'boolean' ? row.enabled : existing.enabled, + }); + } + } + + // Reconcile removals only when remote has at least one entry to avoid wiping local + // data if backend temporarily returns an empty set. + if (remoteSet.size > 0) { + let removedCount = 0; + for (const repo of localRepos) { + const normalized = this.normalizeUrl(repo.url); + if (remoteSet.has(normalized)) continue; + try { + await localScraperService.removeRepository(repo.id); + removedCount += 1; + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to remove local plugin repository missing in remote set:', repo.name, error); + } + } + logger.log(`[SupabaseSyncService] pullPluginsToLocal: removedLocalExtras=${removedCount}`); + } else { + logger.log('[SupabaseSyncService] pullPluginsToLocal: remote set empty, skipped local prune'); + } + } + + private async pushPluginsFromLocal(): Promise { + const repos = await localScraperService.getRepositories(); + logger.log(`[SupabaseSyncService] pushPluginsFromLocal: localCount=${repos.length}`); + const payload: PluginRow[] = repos.map((repo, index) => ({ + url: repo.url, + name: repo.name, + enabled: repo.enabled !== false, + sort_order: index, + })); + await this.callRpc('sync_push_plugins', { p_plugins: payload }); + } + + private async pullAddonsToLocal(): Promise { + const token = await this.getValidAccessToken(); + if (!token) return; + const ownerId = await this.getEffectiveOwnerId(); + if (!ownerId) return; + + const rows = await this.request( + `/rest/v1/addons?select=url,sort_order&user_id=eq.${encodeURIComponent(ownerId)}&order=sort_order.asc`, + { + method: 'GET', + authToken: token, + } + ); + logger.log(`[SupabaseSyncService] pullAddonsToLocal: remoteCount=${rows?.length || 0}`); + const orderedRemoteUrls: string[] = []; + const seenRemoteUrls = new Set(); + for (const row of rows || []) { + if (!row?.url) continue; + const normalized = this.normalizeUrl(row.url); + if (seenRemoteUrls.has(normalized)) continue; + seenRemoteUrls.add(normalized); + orderedRemoteUrls.push(row.url); + } + + const installed = await stremioService.getInstalledAddonsAsync(); + logger.log(`[SupabaseSyncService] pullAddonsToLocal: localInstalledBefore=${installed.length}`); + const remoteSet = new Set( + (rows || []) + .map((row) => (row?.url ? this.normalizeUrl(row.url) : null)) + .filter((url): url is string => Boolean(url)) + ); + const installedUrls = new Set( + installed + .map((addon) => this.addonManifestUrl(addon)) + .filter((url): url is string => Boolean(url)) + .map((url) => this.normalizeUrl(url)) + ); + + for (const row of rows || []) { + if (!row.url) continue; + const normalized = this.normalizeUrl(row.url); + if (installedUrls.has(normalized)) continue; + + try { + await stremioService.installAddon(row.url); + installedUrls.add(normalized); + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to install synced addon:', row.url, error); + } + } + + // Reconcile removals only when remote has at least one entry to avoid wiping local + // data if backend temporarily returns an empty set. + if (remoteSet.size > 0) { + let removedCount = 0; + for (const addon of installed) { + const url = this.addonManifestUrl(addon); + const normalized = url ? this.normalizeUrl(url) : null; + if (!normalized || remoteSet.has(normalized)) continue; + if (!addon.installationId) continue; + try { + await stremioService.removeAddon(addon.installationId); + removedCount += 1; + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to remove local addon missing in remote set:', addon.name, error); + } + } + logger.log(`[SupabaseSyncService] pullAddonsToLocal: removedLocalExtras=${removedCount}`); + } else { + logger.log('[SupabaseSyncService] pullAddonsToLocal: remote set empty, skipped local prune'); + } + + if (orderedRemoteUrls.length > 0) { + try { + const changed = await stremioService.applyAddonOrderFromManifestUrls(orderedRemoteUrls); + logger.log(`[SupabaseSyncService] pullAddonsToLocal: orderReconciled changed=${changed}`); + } catch (error) { + logger.warn('[SupabaseSyncService] pullAddonsToLocal: failed to reconcile addon order:', error); + } + } + } + + private async pushAddonsFromLocal(): Promise { + const addons = await stremioService.getInstalledAddonsAsync(); + logger.log(`[SupabaseSyncService] pushAddonsFromLocal: localInstalledRaw=${addons.length}`); + const seen = new Set(); + const payload: AddonRow[] = addons.reduce((acc, addon) => { + const url = this.addonManifestUrl(addon); + if (!url) return acc; + const normalized = this.normalizeUrl(url); + if (seen.has(normalized)) return acc; + seen.add(normalized); + acc.push({ + url, + sort_order: acc.length, + }); + return acc; + }, []); + logger.log(`[SupabaseSyncService] pushAddonsFromLocal: payloadDeduped=${payload.length}`); + + await this.callRpc('sync_push_addons', { p_addons: payload }); + } + + private async pullWatchProgressToLocal(): Promise { + const rows = await this.callRpc('sync_pull_watch_progress', {}); + const remoteSet = new Set(); + + for (const row of rows || []) { + if (!row.content_id) continue; + const type = row.content_type === 'movie' ? 'movie' : 'series'; + const season = row.season == null ? null : Number(row.season); + const episode = row.episode == null ? null : Number(row.episode); + remoteSet.add(`${type}:${row.content_id}:${season ?? ''}:${episode ?? ''}`); + + const episodeId = type === 'series' && season != null && episode != null + ? `${row.content_id}:${season}:${episode}` + : undefined; + + const local = await storageService.getWatchProgress(row.content_id, type, episodeId); + const remoteLastWatched = this.normalizeEpochMs(row.last_watched); + if (local && Number(local.lastUpdated || 0) >= remoteLastWatched) { + continue; + } + + await storageService.setWatchProgress( + row.content_id, + type, + { + ...(local || {}), + currentTime: this.msToSeconds(row.position), + duration: this.msToSeconds(row.duration), + lastUpdated: remoteLastWatched || Date.now(), + }, + episodeId, + { + preserveTimestamp: true, + forceWrite: true, + forceNotify: true, + } + ); + } + + // Reconcile removals only when remote has at least one entry to avoid wiping local + // data if backend temporarily returns an empty set. + if (remoteSet.size > 0) { + const allLocal = await storageService.getAllWatchProgress(); + let removedCount = 0; + + for (const [key] of Object.entries(allLocal)) { + const parsed = this.parseWatchProgressKey(key); + if (!parsed) continue; + const localSig = `${parsed.contentType}:${parsed.contentId}:${parsed.season ?? ''}:${parsed.episode ?? ''}`; + if (remoteSet.has(localSig)) continue; + + const episodeId = parsed.contentType === 'series' && parsed.season != null && parsed.episode != null + ? `${parsed.contentId}:${parsed.season}:${parsed.episode}` + : undefined; + + await storageService.removeWatchProgress(parsed.contentId, parsed.contentType, episodeId); + removedCount += 1; + } + logger.log(`[SupabaseSyncService] pullWatchProgressToLocal: removedLocalExtras=${removedCount}`); + } else { + logger.log('[SupabaseSyncService] pullWatchProgressToLocal: remote set empty, skipped local prune'); + } + } + + private async pushWatchProgressFromLocal(): Promise { + const all = await storageService.getAllWatchProgress(); + const entries: WatchProgressRow[] = Object.entries(all).reduce((acc, [key, value]) => { + const parsed = this.parseWatchProgressKey(key); + if (!parsed) return acc; + acc.push({ + content_id: parsed.contentId, + content_type: parsed.contentType, + video_id: parsed.videoId, + season: parsed.season, + episode: parsed.episode, + position: this.secondsToMsLong(value.currentTime), + duration: this.secondsToMsLong(value.duration), + last_watched: this.normalizeEpochMs(value.lastUpdated || Date.now()), + progress_key: parsed.progressKey, + }); + return acc; + }, []); + + await this.callRpc('sync_push_watch_progress', { p_entries: entries }); + } + + private async pullLibraryToLocal(): Promise { + const rows = await this.callRpc('sync_pull_library', {}); + const localItems = await catalogService.getLibraryItems(); + const existing = new Set(localItems.map((item) => `${item.type}:${item.id}`)); + const remoteSet = new Set(); + + for (const row of rows || []) { + if (!row.content_id || !row.content_type) continue; + const type = row.content_type === 'movie' ? 'movie' : 'series'; + const key = `${type}:${row.content_id}`; + remoteSet.add(key); + if (existing.has(key)) continue; + + try { + await catalogService.addToLibrary(this.toStreamingContent(row)); + existing.add(key); + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to merge library item from sync:', key, error); + } + } + + // Reconcile removals only when remote has at least one entry to avoid wiping local + // data if backend temporarily returns an empty set. + if (remoteSet.size > 0) { + let removedCount = 0; + for (const item of localItems) { + const key = `${item.type}:${item.id}`; + if (remoteSet.has(key)) continue; + try { + await catalogService.removeFromLibrary(item.type, item.id); + removedCount += 1; + } catch (error) { + logger.warn('[SupabaseSyncService] Failed to remove local library item missing in remote set:', key, error); + } + } + logger.log(`[SupabaseSyncService] pullLibraryToLocal: removedLocalExtras=${removedCount}`); + } else { + logger.log('[SupabaseSyncService] pullLibraryToLocal: remote set empty, skipped local prune'); + } + } + + private async pushLibraryFromLocal(): Promise { + const items = await catalogService.getLibraryItems(); + const payload: LibraryRow[] = items.map((item) => ({ + content_id: item.id, + content_type: item.type === 'movie' ? 'movie' : 'series', + name: item.name, + poster: item.poster, + poster_shape: (item.posterShape || 'poster').toUpperCase(), + background: item.banner || (item as any).background, + description: item.description, + release_info: item.releaseInfo, + imdb_rating: item.imdbRating != null ? Number(item.imdbRating) : undefined, + genres: item.genres || [], + addon_base_url: item.addonId, + added_at: item.addedToLibraryAt, + })); + + await this.callRpc('sync_push_library', { p_items: payload }); + } + + private async pullWatchedItemsToLocal(): Promise { + const rows = await this.callRpc('sync_pull_watched_items', {}); + const mapped = (rows || []).map((row) => this.toWatchedItem(row)); + await watchedService.reconcileRemoteWatchedItems(mapped); + } + + private async pushWatchedItemsFromLocal(): Promise { + const items = await watchedService.getAllWatchedItems(); + const payload: WatchedRow[] = items.map((item) => ({ + content_id: item.content_id, + content_type: item.content_type, + title: item.title, + season: item.season, + episode: item.episode, + watched_at: item.watched_at, + })); + await this.callRpc('sync_push_watched_items', { p_items: payload }); + } +} + +export const supabaseSyncService = SupabaseSyncService.getInstance(); +export default supabaseSyncService; diff --git a/src/services/watchedService.ts b/src/services/watchedService.ts index 50218aeb..5b609ff2 100644 --- a/src/services/watchedService.ts +++ b/src/services/watchedService.ts @@ -4,10 +4,19 @@ import { storageService } from './storageService'; import { mmkvStorage } from './mmkvStorage'; import { logger } from '../utils/logger'; +export interface LocalWatchedItem { + content_id: string; + content_type: 'movie' | 'series'; + title: string; + season: number | null; + episode: number | null; + watched_at: number; +} + /** * WatchedService - Manages "watched" status for movies, episodes, and seasons. * Handles both local storage and Trakt sync transparently. - * + * * When Trakt is authenticated, it syncs to Trakt. * When not authenticated, it stores locally. */ @@ -15,6 +24,8 @@ class WatchedService { private static instance: WatchedService; private traktService: TraktService; private simklService: SimklService; + private readonly WATCHED_ITEMS_KEY = '@user:local:watched_items'; + private watchedSubscribers: Array<() => void> = []; private constructor() { this.traktService = TraktService.getInstance(); @@ -28,6 +39,158 @@ class WatchedService { return WatchedService.instance; } + private watchedKey(item: Pick): string { + return `${item.content_id}::${item.season ?? -1}::${item.episode ?? -1}`; + } + + private normalizeWatchedItem(item: LocalWatchedItem): LocalWatchedItem { + return { + content_id: String(item.content_id || ''), + content_type: item.content_type === 'movie' ? 'movie' : 'series', + title: item.title || '', + season: item.season == null ? null : Number(item.season), + episode: item.episode == null ? null : Number(item.episode), + watched_at: Number(item.watched_at || Date.now()), + }; + } + + private notifyWatchedSubscribers(): void { + if (this.watchedSubscribers.length === 0) return; + this.watchedSubscribers.forEach((cb) => cb()); + } + + public subscribeToWatchedUpdates(callback: () => void): () => void { + this.watchedSubscribers.push(callback); + return () => { + const index = this.watchedSubscribers.indexOf(callback); + if (index > -1) { + this.watchedSubscribers.splice(index, 1); + } + }; + } + + private async loadWatchedItems(): Promise { + try { + const json = await mmkvStorage.getItem(this.WATCHED_ITEMS_KEY); + if (!json) return []; + const parsed = JSON.parse(json); + if (!Array.isArray(parsed)) return []; + + const deduped = new Map(); + parsed.forEach((raw) => { + if (!raw || typeof raw !== 'object') return; + const normalized = this.normalizeWatchedItem(raw as LocalWatchedItem); + if (!normalized.content_id) return; + const key = this.watchedKey(normalized); + const existing = deduped.get(key); + if (!existing || normalized.watched_at > existing.watched_at) { + deduped.set(key, normalized); + } + }); + + return Array.from(deduped.values()); + } catch (error) { + logger.error('[WatchedService] Failed to load local watched items:', error); + return []; + } + } + + private async saveWatchedItems(items: LocalWatchedItem[]): Promise { + try { + await mmkvStorage.setItem(this.WATCHED_ITEMS_KEY, JSON.stringify(items)); + } catch (error) { + logger.error('[WatchedService] Failed to save local watched items:', error); + } + } + + public async getAllWatchedItems(): Promise { + return await this.loadWatchedItems(); + } + + private async upsertLocalWatchedItems(items: LocalWatchedItem[]): Promise { + if (items.length === 0) return; + + const current = await this.loadWatchedItems(); + const byKey = new Map( + current.map((item) => [this.watchedKey(item), item]) + ); + + let changed = false; + for (const raw of items) { + const normalized = this.normalizeWatchedItem(raw); + if (!normalized.content_id) continue; + + const key = this.watchedKey(normalized); + const existing = byKey.get(key); + if (!existing || normalized.watched_at > existing.watched_at || (normalized.title && normalized.title !== existing.title)) { + byKey.set(key, normalized); + changed = true; + } + } + + if (changed) { + await this.saveWatchedItems(Array.from(byKey.values())); + this.notifyWatchedSubscribers(); + } + } + + private async removeLocalWatchedItems(items: Array>): Promise { + if (items.length === 0) return; + + const current = await this.loadWatchedItems(); + const toRemove = new Set(items.map((item) => this.watchedKey({ content_id: item.content_id, season: item.season ?? null, episode: item.episode ?? null }))); + const filtered = current.filter((item) => !toRemove.has(this.watchedKey(item))); + + if (filtered.length !== current.length) { + await this.saveWatchedItems(filtered); + this.notifyWatchedSubscribers(); + } + } + + public async mergeRemoteWatchedItems(items: LocalWatchedItem[]): Promise { + const normalized = items + .map((item) => this.normalizeWatchedItem(item)) + .filter((item) => Boolean(item.content_id)); + + await this.upsertLocalWatchedItems(normalized); + + for (const item of normalized) { + if (item.content_type === 'movie') { + await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at)); + continue; + } + + if (item.season == null || item.episode == null) continue; + const episodeId = `${item.content_id}:${item.season}:${item.episode}`; + await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at)); + } + } + + public async reconcileRemoteWatchedItems(items: LocalWatchedItem[]): Promise { + const normalizedRemote = items + .map((item) => this.normalizeWatchedItem(item)) + .filter((item) => Boolean(item.content_id)); + + // Guard: do not wipe local watched data if backend temporarily returns empty. + if (normalizedRemote.length === 0) { + return; + } + + await this.saveWatchedItems(normalizedRemote); + this.notifyWatchedSubscribers(); + + for (const item of normalizedRemote) { + if (item.content_type === 'movie') { + await this.setLocalWatchedStatus(item.content_id, 'movie', true, undefined, new Date(item.watched_at)); + continue; + } + + if (item.season == null || item.episode == null) continue; + const episodeId = `${item.content_id}:${item.season}:${item.episode}`; + await this.setLocalWatchedStatus(item.content_id, 'series', true, episodeId, new Date(item.watched_at)); + } + } + /** * Mark a movie as watched * @param imdbId - The IMDb ID of the movie @@ -59,6 +222,16 @@ class WatchedService { // Also store locally as "completed" (100% progress) await this.setLocalWatchedStatus(imdbId, 'movie', true, undefined, watchedAt); + await this.upsertLocalWatchedItems([ + { + content_id: imdbId, + content_type: 'movie', + title: imdbId, + season: null, + episode: null, + watched_at: watchedAt.getTime(), + }, + ]); return { success: true, syncedToTrakt }; } catch (error) { @@ -119,6 +292,16 @@ class WatchedService { // Store locally as "completed" const episodeId = `${showId}:${season}:${episode}`; await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); + await this.upsertLocalWatchedItems([ + { + content_id: showImdbId, + content_type: 'series', + title: showImdbId, + season, + episode, + watched_at: watchedAt.getTime(), + }, + ]); return { success: true, syncedToTrakt }; } catch (error) { @@ -188,6 +371,17 @@ class WatchedService { await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); } + await this.upsertLocalWatchedItems( + episodes.map((ep) => ({ + content_id: showImdbId, + content_type: 'series' as const, + title: showImdbId, + season: ep.season, + episode: ep.episode, + watched_at: watchedAt.getTime(), + })) + ); + return { success: true, syncedToTrakt, count: episodes.length }; } catch (error) { logger.error('[WatchedService] Failed to mark episodes as watched:', error); @@ -231,7 +425,6 @@ class WatchedService { const isSimklAuth = await this.simklService.isAuthenticated(); if (isSimklAuth) { // Simkl doesn't have a direct "mark season" generic endpoint in the same way, but we can construct it - // We know the episodeNumbers from the arguments! const episodes = episodeNumbers.map(num => ({ number: num, watched_at: watchedAt.toISOString() })); await this.simklService.addToHistory({ shows: [{ @@ -251,6 +444,17 @@ class WatchedService { await this.setLocalWatchedStatus(showId, 'series', true, episodeId, watchedAt); } + await this.upsertLocalWatchedItems( + episodeNumbers.map((episode) => ({ + content_id: showImdbId, + content_type: 'series' as const, + title: showImdbId, + season, + episode, + watched_at: watchedAt.getTime(), + })) + ); + return { success: true, syncedToTrakt, count: episodeNumbers.length }; } catch (error) { logger.error('[WatchedService] Failed to mark season as watched:', error); @@ -285,6 +489,9 @@ class WatchedService { // Remove local progress await storageService.removeWatchProgress(imdbId, 'movie'); await mmkvStorage.removeItem(`watched:movie:${imdbId}`); + await this.removeLocalWatchedItems([ + { content_id: imdbId, season: null, episode: null }, + ]); return { success: true, syncedToTrakt }; } catch (error) { @@ -335,6 +542,9 @@ class WatchedService { // Remove local progress const episodeId = `${showId}:${season}:${episode}`; await storageService.removeWatchProgress(showId, 'series', episodeId); + await this.removeLocalWatchedItems([ + { content_id: showImdbId, season, episode }, + ]); return { success: true, syncedToTrakt }; } catch (error) { @@ -368,10 +578,6 @@ class WatchedService { showImdbId, season ); - syncedToTrakt = await this.traktService.removeSeasonFromHistory( - showImdbId, - season - ); logger.log(`[WatchedService] Trakt season removal result: ${syncedToTrakt}`); } @@ -397,6 +603,14 @@ class WatchedService { await storageService.removeWatchProgress(showId, 'series', episodeId); } + await this.removeLocalWatchedItems( + episodeNumbers.map((episode) => ({ + content_id: showImdbId, + season, + episode, + })) + ); + return { success: true, syncedToTrakt, count: episodeNumbers.length }; } catch (error) { logger.error('[WatchedService] Failed to unmark season as watched:', error);