Merge branch 'tapframe:main' into main
3
.gitignore
vendored
|
|
@ -105,4 +105,5 @@ LibTorrent/
|
|||
iTorrent/
|
||||
simkl-docss
|
||||
downloader.md
|
||||
server
|
||||
server
|
||||
Deliverables 2
|
||||
12
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);
|
||||
export default Sentry.wrap(App);
|
||||
|
|
|
|||
25
README.md
|
|
@ -11,16 +11,16 @@
|
|||
[![License][license-shield]][license-url]
|
||||
|
||||
<p>
|
||||
A modern media hub built with React Native and Expo.
|
||||
A modern media hub for Android and iOS built with React Native and Expo.
|
||||
<br />
|
||||
Stremio Addon ecosystem • Cross-platform • Offline metadata & sync
|
||||
Stremio Addon ecosystem • Cross-platform
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
## 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
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 173 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 8 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 12 KiB |
2
app.json
|
|
@ -35,7 +35,7 @@
|
|||
"LSSupportsOpeningDocumentsInPlace": true,
|
||||
"UIFileSharingEnabled": true
|
||||
},
|
||||
"bundleIdentifier": "com.nuvio.app",
|
||||
"bundleIdentifier": "com.nuvio.hub",
|
||||
"associatedDomains": [],
|
||||
"jsEngine": "hermes",
|
||||
"appleTeamId": "8QBDZ766S3"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 8.2 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 3 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
assets/android/mipmap-ldpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 760 B After Width: | Height: | Size: 968 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 105 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 840 KiB After Width: | Height: | Size: 71 KiB |
BIN
assets/nuvio-sync-icon-og.png
Normal file
|
After Width: | Height: | Size: 72 KiB |
4
assets/player-icons/ic_player_aspect_ratio.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#FFFFFF" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19,3 C20.5976809,3 21.9036609,4.24891996 21.9949073,5.82372721 L22,6 L22,18 C22,19.5976809 20.75108,20.9036609 19.1762728,20.9949073 L19,21 L5,21 C3.40231912,21 2.09633912,19.75108 2.00509269,18.1762728 L2,18 L2,6 C2,4.40231912 3.24891996,3.09633912 4.82372721,3.00509269 L5,3 L19,3 Z M19,5 L5,5 C4.48716416,5 4.06449284,5.38604019 4.00672773,5.88337887 L4,6 L4,18 C4,18.5128358 4.38604019,18.9355072 4.88337887,18.9932723 L5,19 L19,19 C19.5128358,19 19.9355072,18.6139598 19.9932723,18.1166211 L20,18 L20,6 C20,5.48716416 19.6139598,5.06449284 19.1166211,5.00672773 L19,5 Z M17,12 C17.5128358,12 17.9355072,12.3860402 17.9932723,12.8833789 L18,13 L18,16 C18,16.5128358 17.6139598,16.9355072 17.1166211,16.9932723 L17,17 L14,17 C13.4477153,17 13,16.5522847 13,16 C13,15.4871642 13.3860402,15.0644928 13.8833789,15.0067277 L14,15 L16,15 L16,13 C16,12.4871642 16.3860402,12.0644928 16.8833789,12.0067277 L17,12 Z M10,7 C10.5522847,7 11,7.44771525 11,8 C11,8.51283584 10.6139598,8.93550716 10.1166211,8.99327227 L10,9 L8,9 L8,11 C8,11.5128358 7.61395981,11.9355072 7.11662113,11.9932723 L7,12 C6.48716416,12 6.06449284,11.6139598 6.00672773,11.1166211 L6,11 L6,8 C6,7.48716416 6.38604019,7.06449284 6.88337887,7.00672773 L7,7 L10,7 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
6
assets/player-icons/ic_player_audio_filled.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 6.25C11.3096 6.25 10.75 6.80964 10.75 7.5C10.75 8.19036 11.3096 8.75 12 8.75C12.6904 8.75 13.25 8.19036 13.25 7.5C13.25 6.80964 12.6904 6.25 12 6.25Z" fill="#FFFFFF"/>
|
||||
<path d="M9.75 15.5C9.75 14.2574 10.7574 13.25 12 13.25C13.2426 13.25 14.25 14.2574 14.25 15.5C14.25 16.7426 13.2426 17.75 12 17.75C10.7574 17.75 9.75 16.7426 9.75 15.5Z" fill="#FFFFFF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 10C4 6.22876 4 4.34315 5.17157 3.17157C6.34315 2 8.22876 2 12 2C15.7712 2 17.6569 2 18.8284 3.17157C20 4.34315 20 6.22876 20 10V14C20 17.7712 20 19.6569 18.8284 20.8284C17.6569 22 15.7712 22 12 22C8.22876 22 6.34315 22 5.17157 20.8284C4 19.6569 4 17.7712 4 14V10ZM9.25 7.5C9.25 5.98122 10.4812 4.75 12 4.75C13.5188 4.75 14.75 5.98122 14.75 7.5C14.75 9.01878 13.5188 10.25 12 10.25C10.4812 10.25 9.25 9.01878 9.25 7.5ZM12 11.75C9.92893 11.75 8.25 13.4289 8.25 15.5C8.25 17.5711 9.92893 19.25 12 19.25C14.0711 19.25 15.75 17.5711 15.75 15.5C15.75 13.4289 14.0711 11.75 12 11.75Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
6
assets/player-icons/ic_player_audio_outline.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path opacity="0.5" d="M4 10C4 6.22876 4 4.34315 5.17157 3.17157C6.34315 2 8.22876 2 12 2C15.7712 2 17.6569 2 18.8284 3.17157C20 4.34315 20 6.22876 20 10V14C20 17.7712 20 19.6569 18.8284 20.8284C17.6569 22 15.7712 22 12 22C8.22876 22 6.34315 22 5.17157 20.8284C4 19.6569 4 17.7712 4 14V10Z" stroke="#FFFFFF" stroke-width="1.5"/>
|
||||
<path d="M14 7.5C14 8.60457 13.1046 9.5 12 9.5C10.8954 9.5 10 8.60457 10 7.5C10 6.39543 10.8954 5.5 12 5.5C13.1046 5.5 14 6.39543 14 7.5Z" stroke="#FFFFFF" stroke-width="1.5"/>
|
||||
<path d="M15 15.5C15 17.1569 13.6569 18.5 12 18.5C10.3431 18.5 9 17.1569 9 15.5C9 13.8431 10.3431 12.5 12 12.5C13.6569 12.5 15 13.8431 15 15.5Z" stroke="#FFFFFF" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 919 B |
4
assets/player-icons/ic_player_episodes.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg fill="#FFFFFF" width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" d="M4,8 L14,8 C15.1045695,8 16,8.8954305 16,10 L16,20 C16,21.1045695 15.1045695,22 14,22 L4,22 C2.8954305,22 2,21.1045695 2,20 L2,10 C2,8.8954305 2.8954305,8 4,8 Z M4,10 L4,20 L14,20 L14,10 L4,10 Z M17,19 L17,8 C17,7.44771525 16.5522847,7 16,7 L5,7 C5,5.8954305 5.8954305,5 7,5 L17,5 C18.1045695,5 19,5.8954305 19,7 L19,17 C19,18.1045695 18.1045695,19 17,19 Z M20,16 L20,5 C20,4.44771525 19.5522847,4 19,4 L8,4 C8,2.8954305 8.8954305,2 10,2 L20,2 C21.1045695,2 22,2.8954305 22,4 L22,14 C22,15.1045695 21.1045695,16 20,16 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 785 B |
5
assets/player-icons/ic_player_pause.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M2 6C2 4.11438 2 3.17157 2.58579 2.58579C3.17157 2 4.11438 2 6 2C7.88562 2 8.82843 2 9.41421 2.58579C10 3.17157 10 4.11438 10 6V18C10 19.8856 10 20.8284 9.41421 21.4142C8.82843 22 7.88562 22 6 22C4.11438 22 3.17157 22 2.58579 21.4142C2 20.8284 2 19.8856 2 18V6Z" fill="#FFFFFF"/>
|
||||
<path d="M14 6C14 4.11438 14 3.17157 14.5858 2.58579C15.1716 2 16.1144 2 18 2C19.8856 2 20.8284 2 21.4142 2.58579C22 3.17157 22 4.11438 22 6V18C22 19.8856 22 20.8284 21.4142 21.4142C20.8284 22 19.8856 22 18 22C16.1144 22 15.1716 22 14.5858 21.4142C14 20.8284 14 19.8856 14 18V6Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 816 B |
4
assets/player-icons/ic_player_play.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.4086 9.35258C23.5305 10.5065 23.5305 13.4935 21.4086 14.6474L8.59662 21.6145C6.53435 22.736 4 21.2763 4 18.9671L4 5.0329C4 2.72368 6.53435 1.26402 8.59661 2.38548L21.4086 9.35258Z" fill="#FFFFFF"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 441 B |
4
assets/player-icons/ic_player_source.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 13.6493C3 16.6044 5.41766 19 8.4 19L16.5 19C18.9853 19 21 16.9839 21 14.4969C21 12.6503 19.8893 10.9449 18.3 10.25C18.1317 7.32251 15.684 5 12.6893 5C10.3514 5 8.34694 6.48637 7.5 8.5C4.8 8.9375 3 11.2001 3 13.6493Z" stroke="#FFFFFF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 542 B |
4
assets/player-icons/ic_player_subtitles.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="none" stroke="#FFFFFF" stroke-width="2" d="M1,12 C1,5 2.5,4 12,4 C21.5,4 23,5 23,12 C23,19 21.5,20 12,20 C2.5,20 1,19 1,12 Z M5.25,14 C5.25,15.5 6,16 7.75,16 C9.5,16 10.25,15.5 10.25,14 L9.97861679,14 C9.97861671,15.25 8.97905547,16 7.75,16 C6.52094453,16 5.52138329,15.25 5.52138321,14 L5.52138321,10 C5.5,8.75 6.5,8 7.75,8 C9,8 10,8.75 9.97861679,10 L10.25,10 C10.25,8.75 9.2286998,8 7.75,8 C6.2713002,8 5.25,8.75 5.25,10 L5.25,14 Z M13.25,14 C13.25,15.5 14,16 15.75,16 C17.5,16 18.25,15.5 18.25,14 L17.9786168,14 C17.9786167,15.25 16.9790555,16 15.75,16 C14.5209445,16 13.5213833,15.25 13.5213832,14 L13.5213832,10 C13.5,8.75 14.5,8 15.75,8 C17,8 18,8.75 17.9786168,10 L18.25,10 C18.25,8.75 17.2286998,8 15.75,8 C14.2713002,8 13.25,8.75 13.25,10 L13.25,14 Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 994 B |
BIN
assets/text_only_og.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
1254
docs/SUPABASE_SYNC.md
Normal file
14
index.html
|
|
@ -779,9 +779,9 @@
|
|||
<div class="features-grid">
|
||||
<div class="feature-item">
|
||||
<div class="feature-number">01</div>
|
||||
<h3 class="feature-title">Stremio Addon Support</h3>
|
||||
<p class="feature-desc">Full compatibility with Stremio addons. Access your favorite content
|
||||
providers seamlessly.</p>
|
||||
<h3 class="feature-title">Stremio Addon Integration</h3>
|
||||
<p class="feature-desc">Supports user-installed Stremio addons for metadata and source
|
||||
integration.</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-item">
|
||||
|
|
@ -984,9 +984,9 @@
|
|||
|
||||
<div class="privacy-section">
|
||||
<h2>Copyright & DMCA</h2>
|
||||
<p>We respect the intellectual property rights of others. Since Nuvio does not host any content, we
|
||||
cannot remove content from the internet. However, if you believe that the application interface
|
||||
itself infringes on your rights, please contact us.</p>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="privacy-section">
|
||||
|
|
@ -1157,4 +1157,4 @@
|
|||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 166 KiB After Width: | Height: | Size: 127 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 122 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 122 KiB |
|
|
@ -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": []
|
||||
}
|
||||
}
|
||||
|
|
|
|||
66
package-lock.json
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 77 KiB |
|
|
@ -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<AppleTVHeroProps> = ({
|
|||
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<AppleTVHeroProps> = ({
|
|||
}
|
||||
}, [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<AppleTVHeroProps> = ({
|
|||
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<AppleTVHeroProps> = ({
|
|||
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<AppleTVHeroProps> = ({
|
|||
|
||||
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<AppleTVHeroProps> = ({
|
|||
startAutoPlay();
|
||||
}
|
||||
}, 25000); // Auto-advance every 25 seconds
|
||||
}, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress]);
|
||||
}, [items.length, globalTrailerPlaying, trailerReady, currentIndex, dragDirection, dragProgress, isOutOfView]);
|
||||
|
||||
useEffect(() => {
|
||||
startAutoPlay();
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
const [loading, setLoading] = useState(true);
|
||||
const appState = useRef(AppState.currentState);
|
||||
const refreshTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const pendingRefreshRef = useRef(false);
|
||||
const [deletingItemId, setDeletingItemId] = useState<string | null>(null);
|
||||
const longPressTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
|
|
@ -326,6 +327,7 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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<ContinueWatchingRef>((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 (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.posterContentItem,
|
||||
|
|
@ -1978,10 +2008,12 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
), [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 (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.wideContentItem,
|
||||
|
|
@ -2143,7 +2175,8 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
|
|||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
), [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<ContinueWatchingRef>((props, re
|
|||
</View>
|
||||
|
||||
<FlatList
|
||||
data={[...continueWatchingItems].sort((a, b) => (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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
export default KSPlayerCore;
|
||||
|
|
|
|||
|
|
@ -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<UpNextButtonProps> = ({
|
||||
|
|
@ -49,6 +51,7 @@ const UpNextButton: React.FC<UpNextButtonProps> = ({
|
|||
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<UpNextButtonProps> = ({
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<PlayerControlsProps> = ({
|
|||
{isBuffering ? (
|
||||
<ActivityIndicator size="large" color="#FFFFFF" />
|
||||
) : (
|
||||
<Ionicons
|
||||
name={paused ? "play" : "pause"}
|
||||
size={playIconSizeCalculated}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
paused ? (
|
||||
<PlayerPlayIcon width={playIconSizeCalculated} height={playIconSizeCalculated} />
|
||||
) : (
|
||||
<PlayerPauseIcon width={playIconSizeCalculated} height={playIconSizeCalculated} />
|
||||
)
|
||||
)}
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
|
@ -594,7 +602,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
<View style={styles.centerControlsContainer} pointerEvents="box-none">
|
||||
{/* Left Side: Aspect Ratio Button */}
|
||||
<TouchableOpacity style={styles.iconButton} onPress={cycleAspectRatio}>
|
||||
<Ionicons name="expand-outline" size={24} color="white" />
|
||||
<PlayerAspectRatioIcon width={24} height={24} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Subtitle Button */}
|
||||
|
|
@ -602,7 +610,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
style={styles.iconButton}
|
||||
onPress={() => setShowSubtitleModal(!isSubtitleModalOpen)}
|
||||
>
|
||||
<Ionicons name="text" size={24} color="white" />
|
||||
<PlayerSubtitlesIcon width={24} height={24} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Change Source Button */}
|
||||
|
|
@ -611,7 +619,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
style={styles.iconButton}
|
||||
onPress={() => setShowSourcesModal(true)}
|
||||
>
|
||||
<Ionicons name="cloud-outline" size={24} color="white" />
|
||||
<PlayerSourceIcon width={24} height={24} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
|
|
@ -626,11 +634,11 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
onPress={() => setShowAudioModal(true)}
|
||||
disabled={ksAudioTracks.length <= 1}
|
||||
>
|
||||
<Ionicons
|
||||
name="musical-notes-outline"
|
||||
size={24}
|
||||
color={ksAudioTracks.length <= 1 ? 'grey' : 'white'}
|
||||
/>
|
||||
{ksAudioTracks.length <= 1 ? (
|
||||
<PlayerAudioOutlineIcon width={24} height={24} opacity={0.55} />
|
||||
) : (
|
||||
<PlayerAudioFilledIcon width={24} height={24} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Submit Intro Button */}
|
||||
|
|
@ -653,7 +661,7 @@ export const PlayerControls: React.FC<PlayerControlsProps> = ({
|
|||
style={styles.iconButton}
|
||||
onPress={() => setShowEpisodesModal(true)}
|
||||
>
|
||||
<Ionicons name="list" size={24} color="white" />
|
||||
<PlayerEpisodesIcon width={24} height={24} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
|
|
|||
|
|
@ -20,3 +20,4 @@ export { usePlayerSetup } from './usePlayerSetup';
|
|||
// Content
|
||||
export { useNextEpisode } from './useNextEpisode';
|
||||
export { useWatchProgress } from './useWatchProgress';
|
||||
export { useSkipSegments } from './useSkipSegments';
|
||||
|
|
|
|||
100
src/components/player/hooks/useSkipSegments.ts
Normal file
|
|
@ -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<SkipInterval[]>([]);
|
||||
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<SkipInterval | null>((latest, interval) => {
|
||||
if (!latest || interval.endTime > latest.endTime) return interval;
|
||||
return latest;
|
||||
}, null);
|
||||
|
||||
return {
|
||||
segments,
|
||||
getActiveSegment,
|
||||
outroSegment,
|
||||
isLoading
|
||||
};
|
||||
};
|
||||
|
|
@ -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<SubmitIntroModalProps> = ({
|
|||
|
||||
const [startTimeStr, setStartTimeStr] = useState('00:00');
|
||||
const [endTimeStr, setEndTimeStr] = useState(formatSecondsToMMSS(currentTime));
|
||||
const [segmentType, setSegmentType] = useState<SkipType>('intro');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -107,14 +108,15 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
|||
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<SubmitIntroModalProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<View style={[StyleSheet.absoluteFill, { zIndex: 10000 }]}>
|
||||
|
|
@ -144,13 +148,42 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
|||
style={[localStyles.modalContainer, { width: Math.min(width * 0.85, 380) }]}
|
||||
>
|
||||
<View style={localStyles.header}>
|
||||
<Text style={localStyles.title}>Submit Intro Timestamp</Text>
|
||||
<Text style={localStyles.title}>Submit Timestamps</Text>
|
||||
<TouchableOpacity onPress={onClose} style={localStyles.closeButton}>
|
||||
<Ionicons name="close" size={24} color="rgba(255,255,255,0.5)" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={localStyles.content}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={localStyles.content}>
|
||||
{/* Segment Type Selector */}
|
||||
<View>
|
||||
<Text style={localStyles.label}>Segment Type</Text>
|
||||
<View style={localStyles.typeRow}>
|
||||
{segmentTypes.map((type) => (
|
||||
<TouchableOpacity
|
||||
key={type.value}
|
||||
onPress={() => setSegmentType(type.value)}
|
||||
style={[
|
||||
localStyles.typeButton,
|
||||
segmentType === type.value && localStyles.typeButtonActive
|
||||
]}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={type.icon}
|
||||
size={18}
|
||||
color={segmentType === type.value ? 'black' : 'rgba(255,255,255,0.6)'}
|
||||
/>
|
||||
<Text style={[
|
||||
localStyles.typeButtonText,
|
||||
segmentType === type.value && localStyles.typeButtonTextActive
|
||||
]}>
|
||||
{type.label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Start Time Input */}
|
||||
<View style={localStyles.inputRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
|
|
@ -214,7 +247,7 @@ export const SubmitIntroModal: React.FC<SubmitIntroModalProps> = ({
|
|||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<SkipIntroButtonProps> = ({
|
|||
episode,
|
||||
malId,
|
||||
kitsuId,
|
||||
skipIntervals: externalSkipIntervals,
|
||||
currentTime,
|
||||
onSkip,
|
||||
controlsVisible = false,
|
||||
|
|
@ -46,16 +49,25 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
|
||||
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<SkipInterval[]>([]);
|
||||
const [currentInterval, setCurrentInterval] = useState<SkipInterval | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [hasSkippedCurrent, setHasSkippedCurrent] = useState(false);
|
||||
const [autoHidden, setAutoHidden] = useState(false);
|
||||
|
||||
// Refs
|
||||
const fetchedRef = useRef(false);
|
||||
const lastEpisodeRef = useRef<string>('');
|
||||
const autoHideTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Animation values
|
||||
|
|
@ -63,55 +75,11 @@ export const SkipIntroButton: React.FC<SkipIntroButtonProps> = ({
|
|||
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<SkipIntroButtonProps> = ({
|
|||
style={styles.icon}
|
||||
/>
|
||||
<Text style={styles.text}>{getButtonText()}</Text>
|
||||
<Animated.View
|
||||
<View
|
||||
style={[
|
||||
styles.accentBar,
|
||||
{ backgroundColor: currentTheme.colors.primary }
|
||||
|
|
|
|||
|
|
@ -38,11 +38,17 @@ export const AccountProvider: React.FC<{ children: React.ReactNode }> = ({ 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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": "يتم توفير هذا البرنامج \"كما هو\"، دون أي ضمان من أي نوع، صريحًا أو ضمنيًا. لا يتحمل المؤلفون أو أصحاب حقوق الطبع والنشر بأي حال من الأحوال المسؤولية عن أي مطالبة أو أضرار أو مسؤولية أخرى تنشأ عن استخدام هذا البرنامج."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
},
|
||||
|
|
|
|||