Merge remote-tracking branch 'origin/main'

# Conflicts:
#	src/i18n/locales/en.json
This commit is contained in:
meilluer 2026-02-17 15:23:02 +05:30
commit 50d6e85bd7
133 changed files with 23103 additions and 2120 deletions

1
.gitignore vendored
View file

@ -106,3 +106,4 @@ iTorrent/
simkl-docss
downloader.md
server
Deliverables 2

10
App.tsx
View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 173 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -35,7 +35,7 @@
"LSSupportsOpeningDocumentsInPlace": true,
"UIFileSharingEnabled": true
},
"bundleIdentifier": "com.nuvio.app",
"bundleIdentifier": "com.nuvio.hub",
"associatedDomains": [],
"jsEngine": "hermes",
"appleTeamId": "8QBDZ766S3"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 760 B

After

Width:  |  Height:  |  Size: 968 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 398 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 840 KiB

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

1254
docs/SUPABASE_SYNC.md Normal file

File diff suppressed because it is too large Load diff

View file

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

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 122 KiB

View file

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

66
package-lock.json generated
View file

@ -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"
}
@ -1992,7 +1991,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",
@ -2795,7 +2793,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"
}
@ -2981,7 +2978,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"
},
@ -3337,7 +3333,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",
@ -3458,7 +3454,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",
@ -4100,7 +4095,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",
@ -4321,7 +4315,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"
@ -4331,9 +4324,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": "*"
@ -4869,7 +4861,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",
@ -5273,7 +5264,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -6503,7 +6493,6 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz",
"integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.20.0",
"@expo/cli": "54.0.23",
@ -6707,7 +6696,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"
},
@ -6735,7 +6723,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": "*"
@ -6746,7 +6733,6 @@
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz",
"integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==",
"license": "MIT",
"peer": true,
"dependencies": {
"fontfaceobserver": "^2.1.0"
},
@ -6842,7 +6828,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"
},
@ -8024,7 +8009,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.28.4"
},
@ -10880,7 +10864,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"
}
@ -10900,7 +10883,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"
},
@ -10958,7 +10940,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",
@ -11047,7 +11028,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",
@ -11078,7 +11058,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",
@ -11177,7 +11156,6 @@
"integrity": "sha512-hcvjTu9YJE9fMmnAUvhG8CxvYLpOuMQ/2eyi/S6GyrecezF6Rmk/uRQEL6v09BRFWA/xRVZNQVulQPS+2HS3mQ==",
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"react": "*",
"react-native": "*"
@ -11243,7 +11221,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"
@ -11283,7 +11260,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": "*"
@ -11294,7 +11270,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"
@ -11309,7 +11284,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",
@ -11538,7 +11512,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",
@ -11795,7 +11768,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"
}
@ -13282,24 +13254,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",
@ -13354,19 +13308,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",
@ -13441,9 +13382,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"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 77 KiB

View file

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

View file

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

View file

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

View file

@ -4,6 +4,7 @@ import { Animated } from 'react-native';
import { MaterialIcons } from '@expo/vector-icons';
import { logger } from '../../../utils/logger';
import { LinearGradient } from 'expo-linear-gradient';
import { 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

View file

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

View file

@ -20,3 +20,4 @@ export { usePlayerSetup } from './usePlayerSetup';
// Content
export { useNextEpisode } from './useNextEpisode';
export { useWatchProgress } from './useWatchProgress';
export { useSkipSegments } from './useSkipSegments';

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

View file

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

View file

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

View file

@ -10,5 +10,17 @@ export const LOCALES = [
{ code: 'hr', key: 'croatian' },
{ code: 'zh-CN', key: 'chinese' },
{ code: 'hi', key: 'hindi' },
{ code: 'sr', key: 'serbian' }
{ code: 'sr', key: 'serbian' },
{ code: 'he', key: 'hebrew' },
{ code: 'bg', key: 'bulgarian' },
{ code: 'pl', key: 'polish' },
{ code: 'cs', key: 'czech' },
{ code: 'tr', key: 'turkish' },
{ code: 'sl', key: 'slovenian' },
{ code: 'mk', key: 'macedonian' },
{ code: 'ru', key: 'russian' },
{ code: 'fil', key: 'filipino' },
{ code: 'nl-NL', key: 'dutch_nl' },
{ code: 'ro', key: 'romanian' },
{ code: 'sq', key: 'albanian' },
];

View file

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

View file

@ -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;
const requestedEpisodeType = type;
let streamAddons = pickStreamCapableAddons(requestedEpisodeType);
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;
}
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);

View file

@ -636,6 +636,18 @@
"chinese": "الصينية (المبسطة)",
"hindi": "الهندية",
"serbian": "الصربية",
"hebrew": "العبرية",
"bulgarian": "بلغاري",
"polish": "بولندي",
"czech": "التشيكية",
"turkish": "التركية",
"slovenian": "السلوفينية",
"macedonian": "مقدوني",
"russian": "الروسية",
"filipino": "الفلبينية",
"dutch_nl": "الهولندية (هولندا)",
"romanian": "روماني",
"albanian": "ألباني",
"account": "الحساب",
"content_discovery": "المحتوى والاكتشاف",
"appearance": "المظهر",
@ -896,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": "متصل",
@ -924,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 *",
@ -1324,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": "يتم توفير هذا البرنامج \"كما هو\"، دون أي ضمان من أي نوع، صريحًا أو ضمنيًا. لا يتحمل المؤلفون أو أصحاب حقوق الطبع والنشر بأي حال من الأحوال المسؤولية عن أي مطالبة أو أضرار أو مسؤولية أخرى تنشأ عن استخدام هذا البرنامج."
},

1430
src/i18n/locales/bg.json Normal file

File diff suppressed because it is too large Load diff

1420
src/i18n/locales/cs.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -636,6 +636,18 @@
"chinese": "Chinesisch (Vereinfacht)",
"hindi": "Hindi",
"serbian": "Serbisch",
"hebrew": "Hebräisch",
"bulgarian": "Bulgarisch",
"polish": "Polnisch",
"czech": "Tschechisch",
"turkish": "Türkisch",
"slovenian": "Slowenisch",
"macedonian": "Makedonisch",
"russian": "Russisch",
"filipino": "Philippinisch",
"dutch_nl": "Niederländisch (Niederlande)",
"romanian": "Rumänisch",
"albanian": "Albanisch",
"account": "Konto",
"content_discovery": "Inhalt & Entdeckung",
"appearance": "Aussehen",
@ -896,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",
@ -924,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 *",
@ -1324,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."
},

View file

@ -14,7 +14,6 @@
"try_again": "Try Again",
"go_back": "Go Back",
"settings": "Settings",
"any": "Any",
"close": "Close",
"enable": "Enable",
"disable": "Disable",
@ -642,9 +641,6 @@
"chinese": "Chinese (Simplified)",
"hindi": "Hindi",
"serbian": "Serbian",
"russian": "Russian",
"japanese": "Japanese",
"korean": "Korean",
"account": "Account",
"content_discovery": "Content & Discovery",
"appearance": "Appearance",
@ -1187,10 +1183,6 @@
"powered_by_introdb": "Powered by IntroDB",
"autoplay_title": "Auto-play First Stream",
"autoplay_desc": "Automatically start the first stream shown in the list.",
"preferred_quality_title": "Preferred Quality",
"preferred_quality_desc": "Select preferred quality for autoplay.",
"preferred_language_title": "Preferred Language",
"preferred_language_desc": "Select preferred language for autoplay.",
"resume_title": "Always Resume",
"resume_desc": "Skip the resume prompt and automatically continue where you left off (if less than 85% watched).",
"engine_title": "Video Player Engine",

View file

@ -636,6 +636,18 @@
"chinese": "Chino (Simplificado)",
"hindi": "Hindi",
"serbian": "Serbio",
"hebrew": "Hebreo",
"bulgarian": "Búlgaro",
"polish": "Polaco",
"czech": "Checo",
"turkish": "Turco",
"slovenian": "Esloveno",
"macedonian": "Macedonio",
"russian": "Ruso",
"filipino": "Filipino",
"dutch_nl": "Holandés (Países Bajos)",
"romanian": "Rumano",
"albanian": "Albanés",
"account": "Cuenta",
"content_discovery": "Contenido y descubrimiento",
"appearance": "Apariencia",
@ -896,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",
@ -924,15 +936,15 @@
"enter_api_key": "Introduce tu clave de 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 de API *",
@ -1324,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."
},

Some files were not shown because too many files have changed in this diff Show more