Merge branch 'main' into patch-5

This commit is contained in:
AdityasahuX07 2025-12-14 13:47:20 +05:30 committed by GitHub
commit 53dd480231
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 2751 additions and 2001 deletions

View file

@ -66,6 +66,9 @@ Download the latest APK from [GitHub Releases](https://github.com/tapframe/Nuvio
### iOS ### iOS
#### TestFlight (Recommended)
<img src="https://upload.wikimedia.org/wikipedia/fr/b/bc/TestFlight-icon.png" width="24" height="24" align="left"> [![Join TestFlight](https://img.shields.io/badge/Join-TestFlight-blue?style=for-the-badge)](https://testflight.apple.com/join/QkKMGRqp)
#### AltStore #### AltStore
<img src="https://upload.wikimedia.org/wikipedia/commons/2/20/AltStore_logo.png" width="24" height="24" align="left"> [![Add to AltStore](https://img.shields.io/badge/Add%20to-AltStore-blue?style=for-the-badge)](https://tinyurl.com/NuvioAltstore) <img src="https://upload.wikimedia.org/wikipedia/commons/2/20/AltStore_logo.png" width="24" height="24" align="left"> [![Add to AltStore](https://img.shields.io/badge/Add%20to-AltStore-blue?style=for-the-badge)](https://tinyurl.com/NuvioAltstore)

View file

@ -1,10 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nuvio - Media Hub</title> <title>Nuvio - Media Hub</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap"
rel="stylesheet">
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet"> <link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
<style> <style>
* { * {
@ -231,7 +233,8 @@
scroll-behavior: smooth; scroll-behavior: smooth;
} }
html, body { html,
body {
width: 100%; width: 100%;
min-height: 100%; min-height: 100%;
margin: 0; margin: 0;
@ -302,14 +305,29 @@
} }
@keyframes float { @keyframes float {
0%, 100% { transform: translateY(0px) rotate(0deg); }
33% { transform: translateY(-20px) rotate(1deg); } 0%,
66% { transform: translateY(10px) rotate(-1deg); } 100% {
transform: translateY(0px) rotate(0deg);
}
33% {
transform: translateY(-20px) rotate(1deg);
}
66% {
transform: translateY(10px) rotate(-1deg);
}
} }
@keyframes rotate { @keyframes rotate {
0% { transform: rotate(0deg); } 0% {
100% { transform: rotate(360deg); } transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
} }
.hero-content { .hero-content {
@ -388,17 +406,26 @@
0% { 0% {
background-position: 0% 50%; background-position: 0% 50%;
} }
50% { 50% {
background-position: 100% 50%; background-position: 100% 50%;
} }
100% { 100% {
background-position: 0% 50%; background-position: 0% 50%;
} }
} }
@keyframes titleFloat { @keyframes titleFloat {
0%, 100% { transform: translateY(0px) rotateX(0deg); }
50% { transform: translateY(-10px) rotateX(2deg); } 0%,
100% {
transform: translateY(0px) rotateX(0deg);
}
50% {
transform: translateY(-10px) rotateX(2deg);
}
} }
.hero p { .hero p {
@ -428,6 +455,7 @@
opacity: 0; opacity: 0;
transform: translateY(30px); transform: translateY(30px);
} }
100% { 100% {
opacity: 0.9; opacity: 0.9;
transform: translateY(0); transform: translateY(0);
@ -458,8 +486,17 @@
} }
@keyframes pulse { @keyframes pulse {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.3; }
50% { transform: translate(-50%, -50%) scale(1.2); opacity: 0.1; } 0%,
100% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.3;
}
50% {
transform: translate(-50%, -50%) scale(1.2);
opacity: 0.1;
}
} }
.download-btn { .download-btn {
@ -550,12 +587,19 @@
} }
@keyframes bounce { @keyframes bounce {
0%, 20%, 50%, 80%, 100% {
0%,
20%,
50%,
80%,
100% {
transform: translateY(0); transform: translateY(0);
} }
40% { 40% {
transform: translateY(-8px); transform: translateY(-8px);
} }
60% { 60% {
transform: translateY(-4px); transform: translateY(-4px);
} }
@ -601,8 +645,13 @@
} }
@keyframes floatParticles { @keyframes floatParticles {
0% { transform: translateY(0) rotate(0deg); } 0% {
100% { transform: translateY(-20px) rotate(360deg); } transform: translateY(0) rotate(0deg);
}
100% {
transform: translateY(-20px) rotate(360deg);
}
} }
.features h2 { .features h2 {
@ -715,8 +764,13 @@
} }
@keyframes shimmerSweep { @keyframes shimmerSweep {
0% { transform: translateX(-100%) rotate(45deg); } 0% {
100% { transform: translateX(100%) rotate(45deg); } transform: translateX(-100%) rotate(45deg);
}
100% {
transform: translateX(100%) rotate(45deg);
}
} }
.feature-card:hover { .feature-card:hover {
@ -1523,15 +1577,30 @@
} }
/* Mobile focus highlight removal */ /* Mobile focus highlight removal */
a, button, .download-btn, .privacy-back-btn, .ios-install-option, .ios-modal-close, .scroll-down { a,
button,
.download-btn,
.privacy-back-btn,
.ios-install-option,
.ios-modal-close,
.scroll-down {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
a:focus, a:active, button:focus, button:active,
.download-btn:focus, .download-btn:active, a:focus,
.privacy-back-btn:focus, .privacy-back-btn:active, a:active,
.ios-install-option:focus, .ios-install-option:active, button:focus,
.ios-modal-close:focus, .ios-modal-close:active, button:active,
.scroll-down:focus, .scroll-down:active { .download-btn:focus,
.download-btn:active,
.privacy-back-btn:focus,
.privacy-back-btn:active,
.ios-install-option:focus,
.ios-install-option:active,
.ios-modal-close:focus,
.ios-modal-close:active,
.scroll-down:focus,
.scroll-down:active {
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
@ -1540,12 +1609,15 @@
* { * {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
*:focus, *:active {
*:focus,
*:active {
outline: none !important; outline: none !important;
box-shadow: none !important; box-shadow: none !important;
} }
</style> </style>
</head> </head>
<body> <body>
<section class="hero"> <section class="hero">
<div class="hero-content"> <div class="hero-content">
@ -1567,7 +1639,8 @@
<a href="#features" class="scroll-down"> <a href="#features" class="scroll-down">
<div class="scroll-down-arrow"> <div class="scroll-down-arrow">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M7 13L12 18L17 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M7 13L12 18L17 13" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg> </svg>
</div> </div>
<span class="scroll-down-text">Explore</span> <span class="scroll-down-text">Explore</span>
@ -1584,9 +1657,21 @@
<p class="ios-modal-subtitle">Choose your preferred installation method</p> <p class="ios-modal-subtitle">Choose your preferred installation method</p>
</div> </div>
<div class="ios-install-options"> <div class="ios-install-options">
<a href="https://github.com/tapframe/NuvioStreaming/releases" class="ios-install-option" id="direct-download"> <a href="https://testflight.apple.com/join/QkKMGRqp" class="ios-install-option" id="testflight-install">
<div class="ios-option-icon"> <div class="ios-option-icon">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCA5TDEzLjA5IDE1Ljc0TDEyIDIyTDEwLjkxIDE1Ljc0TDQgOUwxMC45MSA4LjI2TDEyIDJaIiBmaWxsPSIjNjY2NjY2Ii8+Cjwvc3ZnPgo=" alt="Direct Download"> <img src="https://upload.wikimedia.org/wikipedia/fr/b/bc/TestFlight-icon.png" alt="TestFlight">
</div>
<div class="ios-option-content">
<div class="ios-option-title">TestFlight (Recommended)</div>
<div class="ios-option-description">Install via Apple's official beta testing platform</div>
</div>
</a>
<a href="https://github.com/tapframe/NuvioStreaming/releases" class="ios-install-option"
id="direct-download">
<div class="ios-option-icon">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTEyIDJMMTMuMDkgOC4yNkwyMCA5TDEzLjA5IDE1Ljc0TDEyIDIyTDEwLjkxIDE1Ljc0TDQgOUwxMC45MSA4LjI2TDEyIDJaIiBmaWxsPSIjNjY2NjY2Ii8+Cjwvc3ZnPgo="
alt="Direct Download">
</div> </div>
<div class="ios-option-content"> <div class="ios-option-content">
<div class="ios-option-title">Direct Download</div> <div class="ios-option-title">Direct Download</div>
@ -1616,7 +1701,8 @@
<div class="ios-install-option" id="copy-url"> <div class="ios-install-option" id="copy-url">
<div class="ios-option-icon"> <div class="ios-option-icon">
<img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTggNUMzIDUgMyA1IDMgMTBWMTRDMyAxOSAzIDE5IDggMTlIMTZDMjEgMTkgMjEgMTkgMjEgMTRWMTBDMjEgNSAyMSA1IDE2IDVIOFoiIHN0cm9rZT0iIzY2NjY2NiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPHBhdGggZD0iTTEwIDEySDEwLjAxIiBzdHJva2U9IiM2NjY2NjYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+CjxwYXRoIGQ9Ik0xNCAxMkgxNC4wMSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K" alt="Copy URL"> <img src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzIiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTggNUMzIDUgMyA1IDMgMTBWMTRDMyAxOSAzIDE5IDggMTlIMTZDMjEgMTkgMjEgMTkgMjEgMTRWMTBDMjEgNSAyMSA1IDE2IDVIOFoiIHN0cm9rZT0iIzY2NjY2NiIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4KPHBhdGggZD0iTTEwIDEySDEwLjAxIiBzdHJva2U9IiM2NjY2NjYiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+CjxwYXRoIGQ9Ik0xNCAxMkgxNC4wMSIgc3Ryb2tlPSIjNjY2NjY2IiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIvPgo8L3N2Zz4K"
alt="Copy URL">
</div> </div>
<div class="ios-option-content"> <div class="ios-option-content">
<div class="ios-option-title">Copy Source URL</div> <div class="ios-option-title">Copy Source URL</div>
@ -1639,56 +1725,70 @@
<div class="feature-card" data-aos="fade-up" data-aos-delay="100"> <div class="feature-card" data-aos="fade-up" data-aos-delay="100">
<div class="feature-icon"> <div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L3 7L12 12L21 7L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12 2L3 7L12 12L21 7L12 2Z" stroke="currentColor" stroke-width="2"
<path d="M3 17L12 22L21 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> stroke-linecap="round" stroke-linejoin="round" />
<path d="M3 12L12 17L21 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M3 17L12 22L21 17" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<path d="M3 12L12 17L21 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg> </svg>
</div> </div>
<h3>Stremio Addon Support</h3> <h3>Stremio Addon Support</h3>
<p>Full compatibility with Stremio addons, allowing you to access your favorite content providers seamlessly.</p> <p>Full compatibility with Stremio addons, allowing you to access your favorite content providers
seamlessly.</p>
</div> </div>
<div class="feature-card" data-aos="fade-up" data-aos-delay="200"> <div class="feature-card" data-aos="fade-up" data-aos-delay="200">
<div class="feature-icon"> <div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 2L13.09 8.26L22 9L17 14L18.18 23L12 19.77L5.82 23L7 14L2 9L10.91 8.26L12 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12 2L13.09 8.26L22 9L17 14L18.18 23L12 19.77L5.82 23L7 14L2 9L10.91 8.26L12 2Z"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</div> </div>
<h3>Advanced Rating System</h3> <h3>Advanced Rating System</h3>
<p>Comprehensive rating screens with IMDB, TMDB, Rotten Tomatoes, and Metacritic scores for informed viewing decisions.</p> <p>Comprehensive rating screens with IMDB, TMDB, Rotten Tomatoes, and Metacritic scores for informed
viewing decisions.</p>
</div> </div>
<div class="feature-card" data-aos="fade-up" data-aos-delay="300"> <div class="feature-card" data-aos="fade-up" data-aos-delay="300">
<div class="feature-icon"> <div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M12 1L3 5V11C3 16.55 6.84 21.74 12 23C17.16 21.74 21 16.55 21 11V5L12 1Z"
<path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
</svg> </svg>
</div> </div>
<h3>Deep Customization</h3> <h3>Deep Customization</h3>
<p>Extensive customization options including themes, player settings, notification preferences, and personalized content discovery.</p> <p>Extensive customization options including themes, player settings, notification preferences, and
personalized content discovery.</p>
</div> </div>
<div class="feature-card" data-aos="fade-up" data-aos-delay="400"> <div class="feature-card" data-aos="fade-up" data-aos-delay="400">
<div class="feature-icon"> <div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M9 12L11 14L15 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" />
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" /> <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
</svg> </svg>
</div> </div>
<h3>Watch Progress Tracking</h3> <h3>Watch Progress Tracking</h3>
<p>Seamless progress synchronization across devices with Trakt.tv integration and local watch history management.</p> <p>Seamless progress synchronization across devices with Trakt.tv integration and local watch
history management.</p>
</div> </div>
<div class="feature-card" data-aos="fade-up" data-aos-delay="500"> <div class="feature-card" data-aos="fade-up" data-aos-delay="500">
<div class="feature-icon"> <div class="feature-icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 16V8A2 2 0 0 0 19 6H5A2 2 0 0 0 3 8V16A2 2 0 0 0 5 18H19A2 2 0 0 0 21 16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> <path d="M21 16V8A2 2 0 0 0 19 6H5A2 2 0 0 0 3 8V16A2 2 0 0 0 5 18H19A2 2 0 0 0 21 16Z"
<polygon points="7 10 12 15 17 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
<polygon points="7 10 12 15 17 10" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" />
</svg> </svg>
</div> </div>
<h3>Multi-Platform Support</h3> <h3>Multi-Platform Support</h3>
<p>Available on iOS and Android platforms with consistent experience and cross-device synchronization</p> <p>Available on iOS and Android platforms with consistent experience and cross-device
synchronization</p>
</div> </div>
</div> </div>
</div> </div>
@ -1699,27 +1799,33 @@
<h2>SEE IT IN ACTION</h2> <h2>SEE IT IN ACTION</h2>
<div class="screenshots-grid"> <div class="screenshots-grid">
<div class="screenshot"> <div class="screenshot">
<img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.08.32-portrait.png" alt="Home Screen" loading="lazy"> <img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.08.32-portrait.png"
alt="Home Screen" loading="lazy">
<h4>Home Screen</h4> <h4>Home Screen</h4>
</div> </div>
<div class="screenshot"> <div class="screenshot">
<img src="screenshots/WhatsApp Image 2025-09-02 at 00.24.31-portrait.png" alt="App Interface" loading="lazy"> <img src="screenshots/WhatsApp Image 2025-09-02 at 00.24.31-portrait.png" alt="App Interface"
loading="lazy">
<h4>Details Page</h4> <h4>Details Page</h4>
</div> </div>
<div class="screenshot"> <div class="screenshot">
<img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.09.43-portrait.png" alt="Home Screen 2" loading="lazy"> <img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.09.43-portrait.png"
alt="Home Screen 2" loading="lazy">
<h4>Home Screen 2</h4> <h4>Home Screen 2</h4>
</div> </div>
<div class="screenshot"> <div class="screenshot">
<img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.10.14-portrait.png" alt="Library" loading="lazy"> <img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.10.14-portrait.png"
alt="Library" loading="lazy">
<h4>Library</h4> <h4>Library</h4>
</div> </div>
<div class="screenshot"> <div class="screenshot">
<img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.12.41-landscape.png" alt="Player Loading" loading="lazy"> <img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.12.41-landscape.png"
alt="Player Loading" loading="lazy">
<h4>Player Loading</h4> <h4>Player Loading</h4>
</div> </div>
<div class="screenshot"> <div class="screenshot">
<img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.13.36-landscape.png" alt="Video Player" loading="lazy"> <img src="screenshots/Simulator Screenshot - iPhone 16 Pro - 2025-08-27 at 21.13.36-landscape.png"
alt="Video Player" loading="lazy">
<h4>Video Player</h4> <h4>Video Player</h4>
</div> </div>
<div class="screenshot"> <div class="screenshot">
@ -1753,41 +1859,57 @@
<p class="last-updated">Last updated: January 2025</p> <p class="last-updated">Last updated: January 2025</p>
<div class="privacy-section"> <div class="privacy-section">
<h2>Data Collection</h2> <h2>No Account Sync</h2>
<p>Nuvio does not collect personal information. We only store:</p> <p>Nuvio operates entirely offline regarding user data. We <strong>do not</strong> have servers to
<ul> store your account, preferences, or viewing history. All data is stored locally on your device.
<li><strong>User preferences:</strong> App settings and viewing preferences for account sync</li> </p>
<li><strong>Viewing history:</strong> Stored locally on your device for your own use</li>
</ul>
</div> </div>
<div class="privacy-section"> <div class="privacy-section">
<h2>How We Use Your Data</h2> <h2>Data Storage & Backup</h2>
<p>Your data is stored for your own purposes:</p> <p>We use <strong>React Native MMKV</strong> for high-performance local storage. This includes:</p>
<ul> <ul>
<li>Sync your preferences and viewing history across your devices</li> <li>Library and favorites</li>
<li>Maintain your app settings and customizations</li> <li>Watch history and progress</li>
<li>App settings and customization</li>
</ul> </ul>
<p>We do not personalize recommendations or use your data for any other purposes.</p> <p><strong>Important:</strong> Since data is stored only on your device, you are responsible for
backing it up. If you uninstall the app or clear its data without a backup, your personalized
data will be lost permanently.</p>
</div> </div>
<div class="privacy-section"> <div class="privacy-section">
<h2>Third-Party Services</h2> <h2>Third-Party Services</h2>
<p>Nuvio integrates with third-party services that have their own privacy policies:</p> <p>Nuvio integrates with external services to provide content and features:</p>
<ul> <ul>
<li><strong>Trakt.tv:</strong> For tracking viewing progress (optional)</li> <li><strong>TMDB (The Movie Database):</strong> Used to fetch metadata like posters, plot
<li><strong>TMDB:</strong> For movie and TV show information</li> summaries, and cast info.</li>
<li><strong>Trakt.tv (Optional):</strong> If you choose to connect your account, your watch
history will be synced with Trakt.tv subject to their privacy policy.</li>
<li><strong>Sentry:</strong> We use Sentry for anonymous crash reporting to help us identify and
fix bugs. No personal identifiable information (PII) is sent.</li>
</ul> </ul>
</div> </div>
<div class="privacy-section">
<h2>Content Disclaimer</h2>
<p>Nuvio is a media player and aggregator. We <strong>do not host</strong> any content. All video
content is provided by user-installed addons. Nuvio has no control over and assumes no
responsibility for the content provided by third-party addons.</p>
</div>
<div class="privacy-section"> <div class="privacy-section">
<h2>Open Source</h2> <h2>Open Source</h2>
<p>Nuvio is open-source. You can review our code and data handling on our <a href="https://github.com/tapframe/NuvioStreaming" target="_blank">GitHub repository</a> to verify our privacy practices.</p> <p>Nuvio is open-source software. You can review our source code to verify our data handling
practices on our <a href="https://github.com/tapframe/NuvioStreaming" target="_blank">GitHub
repository</a>.</p>
</div> </div>
<div class="privacy-section"> <div class="privacy-section">
<h2>Contact</h2> <h2>Contact</h2>
<p>Questions about this policy? Contact us through our <a href="https://github.com/tapframe/NuvioStreaming/issues" target="_blank">GitHub repository</a>.</p> <p>Questions or concerns? Please reach out via our <a
href="https://github.com/tapframe/NuvioStreaming/issues" target="_blank">GitHub Issues</a>.
</p>
</div> </div>
</div> </div>
</div> </div>
@ -1799,16 +1921,20 @@
<h3>Special Thanks</h3> <h3>Special Thanks</h3>
<div class="credits-grid"> <div class="credits-grid">
<div class="credit-item"> <div class="credit-item">
<img src="https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg" alt="TMDB" class="credit-logo"> <img src="https://www.themoviedb.org/assets/2/v4/logos/v2/blue_square_2-d537fb228cf3ded904ef09b136fe3fec72548ebc1fea3fbbd1ad9e36364db38b.svg"
alt="TMDB" class="credit-logo">
</div> </div>
<div class="credit-item"> <div class="credit-item">
<div class="stremio-logos"> <div class="stremio-logos">
<img src="https://www.stremio.com/website/stremio-logo-small.png" alt="Stremio" class="credit-logo"> <img src="https://www.stremio.com/website/stremio-logo-small.png" alt="Stremio"
<img src="https://www.stremio.com/website/stremio-txt-logo-small.png" alt="Stremio" class="credit-logo"> class="credit-logo">
<img src="https://www.stremio.com/website/stremio-txt-logo-small.png" alt="Stremio"
class="credit-logo">
</div> </div>
</div> </div>
<div class="credit-item"> <div class="credit-item">
<img src="https://upload.wikimedia.org/wikipedia/commons/6/69/IMDB_Logo_2016.svg" alt="IMDb" class="credit-logo"> <img src="https://upload.wikimedia.org/wikipedia/commons/6/69/IMDB_Logo_2016.svg" alt="IMDb"
class="credit-logo">
</div> </div>
<div class="credit-item"> <div class="credit-item">
<img src="https://mdblist.com/static/mdblist_logo.png" alt="MDBList" class="credit-logo"> <img src="https://mdblist.com/static/mdblist_logo.png" alt="MDBList" class="credit-logo">
@ -1818,11 +1944,13 @@
<p>Built with ❤️ using React Native & Expo</p> <p>Built with ❤️ using React Native & Expo</p>
<div class="footer-links"> <div class="footer-links">
<a href="https://discord.gg/6w8dr3TSDN" class="github-link" target="_blank"> <a href="https://discord.gg/6w8dr3TSDN" class="github-link" target="_blank">
<img src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d7f4ef6498ac018f2c55_Symbol.svg" alt="Discord" class="github-logo"> <img src="https://cdn.prod.website-files.com/6257adef93867e50d84d30e2/66e3d7f4ef6498ac018f2c55_Symbol.svg"
alt="Discord" class="github-logo">
Discord Discord
</a> </a>
<a href="https://github.com/tapframe/NuvioStreaming" class="github-link"> <a href="https://github.com/tapframe/NuvioStreaming" class="github-link">
<img src="https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png" alt="GitHub" class="github-logo"> <img src="https://github.githubassets.com/assets/GitHub-Mark-ea2971cee799.png" alt="GitHub"
class="github-logo">
GitHub GitHub
</a> </a>
<a href="#privacy-policy" class="privacy-link" onclick="showPrivacyPolicy()"> <a href="#privacy-policy" class="privacy-link" onclick="showPrivacyPolicy()">
@ -2059,4 +2187,5 @@
}); });
</script> </script>
</body> </body>
</html> </html>

View file

@ -477,7 +477,7 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.app;
PRODUCT_NAME = "Nuvio"; PRODUCT_NAME = Nuvio;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
@ -494,7 +494,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Nuvio/NuvioRelease.entitlements; CODE_SIGN_ENTITLEMENTS = Nuvio/NuvioRelease.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = NLXTHANK2N; DEVELOPMENT_TEAM = 8QBDZ766S3;
INFOPLIST_FILE = Nuvio/Info.plist; INFOPLIST_FILE = Nuvio/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -508,8 +508,8 @@
"-lc++", "-lc++",
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = "com.nuvio.app"; PRODUCT_BUNDLE_IDENTIFIER = com.nuvio.hub;
PRODUCT_NAME = "Nuvio"; PRODUCT_NAME = Nuvio;
SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Nuvio/Nuvio-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";

View file

@ -57,10 +57,6 @@
<string>_googlecast._tcp</string> <string>_googlecast._tcp</string>
<string>_CC1AD845._googlecast._tcp</string> <string>_CC1AD845._googlecast._tcp</string>
</array> </array>
<key>NSLocalNetworkUsageDescription</key>
<string>Allow $(PRODUCT_NAME) to access your local network</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require microphone access.</string>
<key>RCTNewArchEnabled</key> <key>RCTNewArchEnabled</key>
<true/> <true/>
<key>RCTRootViewBackgroundColor</key> <key>RCTRootViewBackgroundColor</key>

View file

@ -4,7 +4,5 @@
<dict> <dict>
<key>aps-environment</key> <key>aps-environment</key>
<string>development</string> <string>development</string>
<key>com.apple.developer.associated-domains</key>
<array/>
</dict> </dict>
</plist> </plist>

View file

@ -0,0 +1,241 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
Platform,
} from 'react-native';
import { useTheme } from '../../contexts/ThemeContext';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Feather, MaterialIcons } from '@expo/vector-icons';
const ANDROID_STATUSBAR_HEIGHT = StatusBar.currentHeight || 0;
interface ScreenHeaderProps {
/**
* The main title displayed in the header
*/
title: string;
/**
* Optional right action button (icon name from Feather icons)
*/
rightActionIcon?: string;
/**
* Optional callback for right action button press
*/
onRightActionPress?: () => void;
/**
* Optional custom right action component (overrides rightActionIcon)
*/
rightActionComponent?: React.ReactNode;
/**
* Optional back button (shows arrow back icon)
*/
showBackButton?: boolean;
/**
* Optional callback for back button press
*/
onBackPress?: () => void;
/**
* Whether this screen is displayed on a tablet layout
*/
isTablet?: boolean;
/**
* Optional extra top padding for tablet navigation offset
*/
tabletNavOffset?: number;
/**
* Optional custom title component (overrides title text)
*/
titleComponent?: React.ReactNode;
/**
* Optional children to render below the title row (e.g., filters, search bar)
*/
children?: React.ReactNode;
/**
* Whether to hide the header title row (useful when showing only children)
*/
hideTitleRow?: boolean;
/**
* Use MaterialIcons instead of Feather for icons
*/
useMaterialIcons?: boolean;
/**
* Optional custom style for title
*/
titleStyle?: object;
}
const ScreenHeader: React.FC<ScreenHeaderProps> = ({
title,
rightActionIcon,
onRightActionPress,
rightActionComponent,
showBackButton = false,
onBackPress,
isTablet = false,
tabletNavOffset = 64,
titleComponent,
children,
hideTitleRow = false,
useMaterialIcons = false,
titleStyle,
}) => {
const { currentTheme } = useTheme();
const insets = useSafeAreaInsets();
// Calculate header spacing
const topSpacing =
(Platform.OS === 'android' ? ANDROID_STATUSBAR_HEIGHT : insets.top) +
(isTablet ? tabletNavOffset : 0);
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
const titleRowHeight = headerBaseHeight + topSpacing;
const IconComponent = useMaterialIcons ? MaterialIcons : Feather;
const backIconName = useMaterialIcons ? 'arrow-back' : 'arrow-left';
return (
<>
{/* Fixed position header background to prevent shifts */}
<View
style={[
styles.headerBackground,
{
backgroundColor: currentTheme.colors.darkBackground,
},
]}
/>
{/* Header Section */}
<View
style={[
styles.header,
{
paddingTop: topSpacing,
backgroundColor: 'transparent',
},
]}
>
{/* Title Row */}
{!hideTitleRow && (
<View
style={[
styles.titleRow,
{
height: headerBaseHeight,
},
]}
>
<View style={styles.headerContent}>
{showBackButton ? (
<TouchableOpacity
style={styles.backButton}
onPress={onBackPress}
activeOpacity={0.7}
>
<IconComponent
name={backIconName as any}
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
) : null}
{titleComponent ? (
titleComponent
) : (
<Text
style={[
styles.headerTitle,
{ color: currentTheme.colors.text },
isTablet && { fontSize: 48 }, // Increase font size for tablet
showBackButton && styles.headerTitleWithBack,
titleStyle,
]}
>
{title}
</Text>
)}
{/* Right Action */}
{rightActionComponent ? (
<View style={styles.rightActionContainer}>{rightActionComponent}</View>
) : rightActionIcon && onRightActionPress ? (
<TouchableOpacity
style={styles.rightActionButton}
onPress={onRightActionPress}
activeOpacity={0.7}
>
<IconComponent
name={rightActionIcon as any}
size={24}
color={currentTheme.colors.text}
/>
</TouchableOpacity>
) : (
<View style={styles.rightActionPlaceholder} />
)}
</View>
</View>
)}
{/* Children (filters, search bar, etc.) */}
{children}
</View>
</>
);
};
const styles = StyleSheet.create({
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
},
header: {
paddingHorizontal: 20,
zIndex: 11,
},
titleRow: {
justifyContent: 'flex-end',
paddingBottom: 8,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
backButton: {
padding: 8,
marginLeft: -8,
marginRight: 8,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.5,
flex: 1,
},
headerTitleWithBack: {
fontSize: 24,
flex: 0,
},
rightActionContainer: {
minWidth: 40,
alignItems: 'flex-end',
},
rightActionButton: {
padding: 8,
marginRight: -8,
},
rightActionPlaceholder: {
width: 40,
},
});
export default ScreenHeader;

View file

@ -39,6 +39,12 @@ import { useSettings } from '../../hooks/useSettings';
import { useTrailer } from '../../contexts/TrailerContext'; import { useTrailer } from '../../contexts/TrailerContext';
import TrailerService from '../../services/trailerService'; import TrailerService from '../../services/trailerService';
import TrailerPlayer from '../video/TrailerPlayer'; import TrailerPlayer from '../video/TrailerPlayer';
import { useLibrary } from '../../hooks/useLibrary';
import { useToast } from '../../contexts/ToastContext';
import { useTraktContext } from '../../contexts/TraktContext';
import { BlurView as ExpoBlurView } from 'expo-blur';
import { useWatchProgress } from '../../hooks/useWatchProgress';
import { streamCacheService } from '../../services/streamCacheService';
interface AppleTVHeroProps { interface AppleTVHeroProps {
featuredContent: StreamingContent | null; featuredContent: StreamingContent | null;
@ -144,6 +150,16 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { settings, updateSetting } = useSettings(); const { settings, updateSetting } = useSettings();
const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer(); const { isTrailerPlaying: globalTrailerPlaying, setTrailerPlaying } = useTrailer();
const { toggleLibrary, isInLibrary: checkIsInLibrary } = useLibrary();
const { showSaved, showTraktSaved, showRemoved, showTraktRemoved } = useToast();
const { isAuthenticated: isTraktAuthenticated } = useTraktContext();
// Library and watch state
const [inLibrary, setInLibrary] = useState(false);
const [isInWatchlist, setIsInWatchlist] = useState(false);
const [isWatched, setIsWatched] = useState(false);
const [playButtonText, setPlayButtonText] = useState('Play');
const [type, setType] = useState<'movie' | 'series'>('movie');
// Create internal scrollY if not provided externally // Create internal scrollY if not provided externally
const internalScrollY = useSharedValue(0); const internalScrollY = useSharedValue(0);
@ -185,6 +201,18 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const currentItem = items[currentIndex] || null; const currentItem = items[currentIndex] || null;
// Use watch progress hook
const {
watchProgress,
getPlayButtonText: getProgressPlayButtonText,
loadWatchProgress
} = useWatchProgress(
currentItem?.id || '',
type,
undefined,
[] // Pass episodes if you have them for series
);
// Animation values // Animation values
const dragProgress = useSharedValue(0); const dragProgress = useSharedValue(0);
const dragDirection = useSharedValue(0); // -1 for left, 1 for right const dragDirection = useSharedValue(0); // -1 for left, 1 for right
@ -196,6 +224,15 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
const trailerMuted = settings?.trailerMuted ?? true; const trailerMuted = settings?.trailerMuted ?? true;
const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in const heroOpacity = useSharedValue(0); // Start hidden for smooth fade-in
// Handler for trailer end
const handleTrailerEnd = useCallback(() => {
logger.info('[AppleTVHero] Trailer ended');
setTrailerPlaying(false);
// Fade back to thumbnail
trailerOpacity.value = withTiming(0, { duration: 300 });
thumbnailOpacity.value = withTiming(1, { duration: 300 });
}, [setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
// Animated style for trailer container - 60% height with zoom // Animated style for trailer container - 60% height with zoom
const trailerContainerStyle = useAnimatedStyle(() => { const trailerContainerStyle = useAnimatedStyle(() => {
// Faster fade out during drag - complete fade by 0.3 progress instead of 1.0 // Faster fade out during drag - complete fade by 0.3 progress instead of 1.0
@ -480,19 +517,196 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
logger.error('[AppleTVHero] Trailer playback error'); logger.error('[AppleTVHero] Trailer playback error');
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); }, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]);
// Handle trailer end // Update state when current item changes and load watch progress
const handleTrailerEnd = useCallback(() => { useEffect(() => {
logger.info('[AppleTVHero] Trailer ended'); if (currentItem) {
setType(currentItem.type as 'movie' | 'series');
checkItemStatus(currentItem.id);
loadWatchProgress();
}
}, [currentItem, loadWatchProgress]);
// Update play button text and watched state when watch progress changes
useEffect(() => {
if (currentItem) {
const buttonText = getProgressPlayButtonText();
setPlayButtonText(buttonText);
// Update watched state based on progress
if (watchProgress) {
const progressPercent = (watchProgress.currentTime / watchProgress.duration) * 100;
setIsWatched(progressPercent >= 85); // Consider watched if 85% or more completed
} else {
setIsWatched(false);
}
}
}, [watchProgress, getProgressPlayButtonText, currentItem]);
// Function to check item status
const checkItemStatus = useCallback(async (itemId: string) => {
try {
// Check if item is in library
const libraryStatus = checkIsInLibrary(itemId);
setInLibrary(libraryStatus);
// TODO: Check Trakt watchlist status if authenticated
if (isTraktAuthenticated) {
// await traktService.isInWatchlist(itemId);
setIsInWatchlist(Math.random() > 0.5); // Replace with actual Trakt call
}
} catch (error) {
logger.error('[AppleTVHero] Error checking item status:', error);
}
}, [checkIsInLibrary, isTraktAuthenticated]);
// Update the handleSaveAction function:
const handleSaveAction = useCallback(async (e?: any) => {
if (e) {
e.stopPropagation();
e.preventDefault();
}
if (!currentItem) return;
const wasInLibrary = inLibrary;
const wasInWatchlist = isInWatchlist;
// Update local state immediately for responsiveness
setInLibrary(!wasInLibrary);
try {
// Toggle library using the useLibrary hook
const success = await toggleLibrary(currentItem);
if (success) {
logger.info('[AppleTVHero] Successfully toggled library:', currentItem.name);
} else {
logger.warn('[AppleTVHero] Library toggle returned false');
}
// If authenticated with Trakt, also toggle Trakt watchlist
if (isTraktAuthenticated) {
setIsInWatchlist(!wasInWatchlist);
// TODO: Replace with your actual Trakt service call
// await traktService.toggleWatchlist(currentItem.id, !wasInWatchlist);
logger.info('[AppleTVHero] Toggled Trakt watchlist');
}
} catch (error) {
logger.error('[AppleTVHero] Error toggling library:', error);
// Revert state on error
setInLibrary(wasInLibrary);
if (isTraktAuthenticated) {
setIsInWatchlist(wasInWatchlist);
}
}
}, [currentItem, inLibrary, isInWatchlist, isTraktAuthenticated, toggleLibrary, showSaved, showTraktSaved, showRemoved, showTraktRemoved]);
// Play button handler - navigates to Streams screen with progress data if available
const handlePlayAction = useCallback(async () => {
logger.info('[AppleTVHero] Play button pressed for:', currentItem?.name);
if (!currentItem) return;
// Stop any playing trailer
try {
setTrailerPlaying(false); setTrailerPlaying(false);
} catch {}
// Reset trailer state // Check if we should resume based on watch progress
setTrailerReady(false); const shouldResume = watchProgress &&
setTrailerPreloaded(false); watchProgress.currentTime > 0 &&
(watchProgress.currentTime / watchProgress.duration) < 0.85;
// Smooth fade back to thumbnail logger.info('[AppleTVHero] Should resume:', shouldResume, watchProgress);
trailerOpacity.value = withTiming(0, { duration: 500 });
thumbnailOpacity.value = withTiming(1, { duration: 500 }); try {
}, [trailerOpacity, thumbnailOpacity, setTrailerPlaying]); // Check if we have a cached stream for this content
const episodeId = currentItem.type === 'series' && watchProgress?.episodeId
? watchProgress.episodeId
: undefined;
logger.info('[AppleTVHero] Looking for cached stream with episodeId:', episodeId);
const cachedStream = await streamCacheService.getCachedStream(currentItem.id, currentItem.type, episodeId);
if (cachedStream && cachedStream.stream?.url) {
// We have a valid cached stream, navigate directly to player
logger.info('[AppleTVHero] Using cached stream for:', currentItem.name);
// Determine the player route based on platform
const playerRoute = Platform.OS === 'ios' ? 'PlayerIOS' : 'PlayerAndroid';
// Navigate directly to player with cached stream data AND RESUME DATA
navigation.navigate(playerRoute as any, {
uri: cachedStream.stream.url,
title: cachedStream.metadata?.name || currentItem.name,
episodeTitle: cachedStream.episodeTitle,
season: cachedStream.season,
episode: cachedStream.episode,
quality: (cachedStream.stream.title?.match(/(\d+)p/) || [])[1] || undefined,
year: cachedStream.metadata?.year || currentItem.year,
streamProvider: cachedStream.stream.addonId || cachedStream.stream.addonName || cachedStream.stream.name,
streamName: cachedStream.stream.name || cachedStream.stream.title || 'Unnamed Stream',
headers: cachedStream.stream.headers || undefined,
forceVlc: false,
id: currentItem.id,
type: currentItem.type,
episodeId: episodeId,
imdbId: cachedStream.imdbId || cachedStream.metadata?.imdbId || currentItem.imdb_id,
backdrop: cachedStream.metadata?.backdrop || currentItem.banner,
videoType: undefined, // Let player auto-detect
// ADD RESUME DATA if we should resume
...(shouldResume && watchProgress && {
resumeTime: watchProgress.currentTime,
duration: watchProgress.duration
})
} as any);
return;
}
// No cached stream, navigate to Streams screen with resume data
logger.info('[AppleTVHero] No cached stream, navigating to StreamsScreen for:', currentItem.name);
const navigationParams: any = {
id: currentItem.id,
type: currentItem.type,
title: currentItem.name,
metadata: {
poster: currentItem.poster,
banner: currentItem.banner,
releaseInfo: currentItem.releaseInfo,
genres: currentItem.genres
}
};
// Add resume data if we have progress that's not near completion
if (shouldResume && watchProgress) {
navigationParams.resumeTime = watchProgress.currentTime;
navigationParams.duration = watchProgress.duration;
navigationParams.episodeId = watchProgress.episodeId;
logger.info('[AppleTVHero] Passing resume data to Streams:', watchProgress.currentTime, watchProgress.duration);
}
navigation.navigate('Streams', navigationParams);
} catch (error) {
logger.error('[AppleTVHero] Error handling play action:', error);
// Fallback to StreamsScreen on any error
navigation.navigate('Streams', {
id: currentItem.id,
type: currentItem.type,
title: currentItem.name,
metadata: {
poster: currentItem.poster,
banner: currentItem.banner,
releaseInfo: currentItem.releaseInfo,
genres: currentItem.genres
}
});
}
}, [currentItem, navigation, setTrailerPlaying, watchProgress]);
// Handle fullscreen toggle // Handle fullscreen toggle
const handleFullscreenToggle = useCallback(async () => { const handleFullscreenToggle = useCallback(async () => {
@ -569,33 +783,6 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
); );
}, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]); }, [currentIndex, setTrailerPlaying, trailerOpacity, thumbnailOpacity]);
// Preload next and previous images for instant swiping
useEffect(() => {
if (items.length <= 1) return;
const prevIdx = (currentIndex - 1 + items.length) % items.length;
const nextIdx = (currentIndex + 1) % items.length;
const prevItem = items[prevIdx];
const nextItem = items[nextIdx];
const urlsToPreload: { uri: string }[] = [];
if (prevItem) {
const url = prevItem.banner || prevItem.poster;
if (url) urlsToPreload.push({ uri: url });
}
if (nextItem) {
const url = nextItem.banner || nextItem.poster;
if (url) urlsToPreload.push({ uri: url });
}
if (urlsToPreload.length > 0) {
FastImage.preload(urlsToPreload);
}
}, [currentIndex, items]);
// Callback for updating interaction time // Callback for updating interaction time
const updateInteractionTime = useCallback(() => { const updateInteractionTime = useCallback(() => {
lastInteractionRef.current = Date.now(); lastInteractionRef.current = Date.now();
@ -972,6 +1159,17 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
style={logoAnimatedStyle} style={logoAnimatedStyle}
> >
{currentItem.logo && !logoError[currentIndex] ? ( {currentItem.logo && !logoError[currentIndex] ? (
<TouchableOpacity
activeOpacity={0.7}
onPress={() => {
if (currentItem) {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}
}}
>
<View <View
style={[ style={[
styles.logoContainer, styles.logoContainer,
@ -995,12 +1193,25 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
}} }}
/> />
</View> </View>
</TouchableOpacity>
) : ( ) : (
<TouchableOpacity
activeOpacity={0.8}
onPress={() => {
if (currentItem) {
navigation.navigate('Metadata', {
id: currentItem.id,
type: currentItem.type,
});
}
}}
>
<View style={styles.titleContainer}> <View style={styles.titleContainer}>
<Text style={styles.title} numberOfLines={2}> <Text style={styles.title} numberOfLines={2}>
{currentItem.name} {currentItem.name}
</Text> </Text>
</View> </View>
</TouchableOpacity>
)} )}
</Animated.View> </Animated.View>
@ -1020,21 +1231,33 @@ const AppleTVHero: React.FC<AppleTVHeroProps> = ({
</View> </View>
</View> </View>
{/* Action Buttons - Always Visible */} {/* Action Buttons - Play and Save buttons */}
<View style={styles.buttonsContainer}> <View style={styles.buttonsContainer}>
{/* Info Button */} {/* Play Button */}
<TouchableOpacity <TouchableOpacity
style={styles.playButton} style={[styles.playButton]}
onPress={() => { onPress={handlePlayAction}
navigation.navigate('Metadata', { activeOpacity={0.85}
id: currentItem.id,
type: currentItem.type,
});
}}
activeOpacity={0.8}
> >
<MaterialIcons name="info-outline" size={28} color="#000" /> <MaterialIcons
<Text style={styles.playButtonText}>Info</Text> name={playButtonText === 'Resume' ? "replay" : "play-arrow"}
size={24}
color="#000"
/>
<Text style={styles.playButtonText}>{playButtonText}</Text>
</TouchableOpacity>
{/* Save Button */}
<TouchableOpacity
style={styles.saveButton}
onPress={handleSaveAction}
activeOpacity={0.85}
>
<MaterialIcons
name={inLibrary ? "bookmark" : "bookmark-outline"}
size={24}
color="white"
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@ -1171,25 +1394,25 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backgroundColor: '#fff', backgroundColor: '#fff',
paddingVertical: 14, paddingVertical: 11,
paddingHorizontal: 32, paddingHorizontal: 32,
borderRadius: 24, borderRadius: 40,
gap: 8, gap: 8,
minWidth: 140, minWidth: 130,
}, },
playButtonText: { playButtonText: {
color: '#000', color: '#000',
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
}, },
secondaryButton: { saveButton: {
width: 48, width: 52,
height: 48, height: 52,
borderRadius: 24, borderRadius: 30,
backgroundColor: 'rgba(255,255,255,0.2)', backgroundColor: 'rgba(255,255,255,0.2)',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
borderWidth: 1, borderWidth: 1.5,
borderColor: 'rgba(255,255,255,0.3)', borderColor: 'rgba(255,255,255,0.3)',
}, },
paginationContainer: { paginationContainer: {

View file

@ -240,6 +240,44 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
} }
}, []); }, []);
// Helper function to find the next episode
const findNextEpisode = useCallback((currentSeason: number, currentEpisode: number, videos: any[]) => {
if (!videos || !Array.isArray(videos)) return null;
// Sort videos to ensure correct order
const sortedVideos = [...videos].sort((a, b) => {
if (a.season !== b.season) return a.season - b.season;
return a.episode - b.episode;
});
// Strategy 1: Look for next episode in the same season
let nextEp = sortedVideos.find(v => v.season === currentSeason && v.episode === currentEpisode + 1);
// Strategy 2: If not found, look for the first episode of the next season
if (!nextEp) {
nextEp = sortedVideos.find(v => v.season === currentSeason + 1 && v.episode === 1);
}
// Strategy 3: Just find the very next video in the list after the current one
// This handles cases where episode numbering isn't sequential or S+1 E1 isn't the standard start
if (!nextEp) {
const currentIndex = sortedVideos.findIndex(v => v.season === currentSeason && v.episode === currentEpisode);
if (currentIndex !== -1 && currentIndex + 1 < sortedVideos.length) {
const candidate = sortedVideos[currentIndex + 1];
// Ensure we didn't just jump to a random special; check reasonable bounds if needed,
// but generally taking the next sorted item is correct for sequential viewing.
nextEp = candidate;
}
}
// Verify the found episode is released
if (nextEp && isEpisodeReleased(nextEp)) {
return nextEp;
}
return null;
}, []);
// Modified loadContinueWatching to render incrementally // Modified loadContinueWatching to render incrementally
const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => { const loadContinueWatching = useCallback(async (isBackgroundRefresh = false) => {
if (isRefreshingRef.current) { if (isRefreshingRef.current) {
@ -432,44 +470,44 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
const { episodeId, progress, progressPercent } = episode; const { episodeId, progress, progressPercent } = episode;
if (group.type === 'series' && progressPercent >= 85) { if (group.type === 'series' && progressPercent >= 85) {
let nextSeason: number | undefined; // Local progress completion check
let nextEpisode: number | undefined;
if (episodeId) { if (episodeId) {
let currentSeason: number | undefined;
let currentEpisode: number | undefined;
const match = episodeId.match(/s(\d+)e(\d+)/i); const match = episodeId.match(/s(\d+)e(\d+)/i);
if (match) { if (match) {
const currentSeason = parseInt(match[1], 10); currentSeason = parseInt(match[1], 10);
const currentEpisode = parseInt(match[2], 10); currentEpisode = parseInt(match[2], 10);
nextSeason = currentSeason;
nextEpisode = currentEpisode + 1;
} else { } else {
const parts = episodeId.split(':'); const parts = episodeId.split(':');
if (parts.length >= 2) { if (parts.length >= 2) {
const seasonNum = parseInt(parts[parts.length - 2], 10); const seasonNum = parseInt(parts[parts.length - 2], 10);
const episodeNum = parseInt(parts[parts.length - 1], 10); const episodeNum = parseInt(parts[parts.length - 1], 10);
if (!isNaN(seasonNum) && !isNaN(episodeNum)) { if (!isNaN(seasonNum) && !isNaN(episodeNum)) {
nextSeason = seasonNum; currentSeason = seasonNum;
nextEpisode = episodeNum + 1; currentEpisode = episodeNum;
} }
} }
} }
}
if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) { if (currentSeason !== undefined && currentEpisode !== undefined && metadata?.videos) {
const nextEpisodeVideo = metadata.videos.find((video: any) => const nextEpisodeVideo = findNextEpisode(currentSeason, currentEpisode, metadata.videos);
video.season === nextSeason && video.episode === nextEpisode
); if (nextEpisodeVideo) {
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
batch.push({ batch.push({
...basicContent, ...basicContent,
id: group.id, id: group.id,
type: group.type, type: group.type,
progress: 0, progress: 0,
lastUpdated: progress.lastUpdated, lastUpdated: progress.lastUpdated,
season: nextSeason, season: nextEpisodeVideo.season,
episode: nextEpisode, episode: nextEpisodeVideo.episode,
episodeTitle: `Episode ${nextEpisode}`, episodeTitle: `Episode ${nextEpisodeVideo.episode}`,
} as ContinueWatchingItem); } as ContinueWatchingItem);
} }
} }
}
continue; continue;
} }
@ -532,23 +570,18 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
// If watched on Trakt, treat it as completed (try to find next episode) // If watched on Trakt, treat it as completed (try to find next episode)
if (isWatchedOnTrakt) { if (isWatchedOnTrakt) {
let nextSeason = season; if (season !== undefined && episodeNumber !== undefined && metadata?.videos) {
let nextEpisode = (episodeNumber || 0) + 1; const nextEpisodeVideo = findNextEpisode(season, episodeNumber, metadata.videos);
if (nextEpisodeVideo) {
if (nextSeason !== undefined && nextEpisode !== undefined && metadata?.videos && Array.isArray(metadata.videos)) {
const nextEpisodeVideo = metadata.videos.find((video: any) =>
video.season === nextSeason && video.episode === nextEpisode
);
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
batch.push({ batch.push({
...basicContent, ...basicContent,
id: group.id, id: group.id,
type: group.type, type: group.type,
progress: 0, progress: 0,
lastUpdated: progress.lastUpdated, lastUpdated: progress.lastUpdated,
season: nextSeason, season: nextEpisodeVideo.season,
episode: nextEpisode, episode: nextEpisodeVideo.episode,
episodeTitle: `Episode ${nextEpisode}`, episodeTitle: `Episode ${nextEpisodeVideo.episode}`,
} as ContinueWatchingItem); } as ContinueWatchingItem);
} }
} }
@ -614,29 +647,26 @@ const ContinueWatchingSection = React.forwardRef<ContinueWatchingRef>((props, re
continue; continue;
} }
const nextEpisode = info.episode + 1;
const cachedData = await getCachedMetadata('series', showId); const cachedData = await getCachedMetadata('series', showId);
if (!cachedData?.basicContent) continue; if (!cachedData?.basicContent) continue;
const { metadata, basicContent } = cachedData; const { metadata, basicContent } = cachedData;
let nextEpisodeVideo = null;
if (metadata?.videos && Array.isArray(metadata.videos)) { if (metadata?.videos) {
nextEpisodeVideo = metadata.videos.find((video: any) => const nextEpisodeVideo = findNextEpisode(info.season, info.episode, metadata.videos);
video.season === info.season && video.episode === nextEpisode if (nextEpisodeVideo) {
); logger.log(` [TraktSync] Adding next episode for ${showId}: S${nextEpisodeVideo.season}E${nextEpisodeVideo.episode}`);
}
if (nextEpisodeVideo && isEpisodeReleased(nextEpisodeVideo)) {
logger.log(` [TraktSync] Adding next episode for ${showId}: S${info.season}E${nextEpisode}`);
traktBatch.push({ traktBatch.push({
...basicContent, ...basicContent,
id: showId, id: showId,
type: 'series', type: 'series',
progress: 0, progress: 0,
lastUpdated: info.watchedAt, lastUpdated: info.watchedAt,
season: info.season, season: nextEpisodeVideo.season,
episode: nextEpisode, episode: nextEpisodeVideo.episode,
episodeTitle: `Episode ${nextEpisode}`, episodeTitle: `Episode ${nextEpisodeVideo.episode}`,
} as ContinueWatchingItem); } as ContinueWatchingItem);
} }
}
// Persist "watched" progress for the episode that Trakt reported (only if not recently removed) // Persist "watched" progress for the episode that Trakt reported (only if not recently removed)
if (!recentlyRemovedRef.current.has(showKey)) { if (!recentlyRemovedRef.current.has(showKey)) {

View file

@ -541,7 +541,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
return null; return null;
} }
if (__DEV__) console.log('[SeriesContent] renderSeasonSelector called, current view mode:', seasonViewMode);
const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b); const seasons = Object.keys(groupedEpisodes).map(Number).sort((a, b) => a - b);
@ -630,7 +630,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
if (seasonViewMode === 'text') { if (seasonViewMode === 'text') {
// Text-only view // Text-only view
if (__DEV__) console.log('[SeriesContent] Rendering text view for season:', season, 'View mode ref:', seasonViewMode);
return ( return (
<View <View
key={season} key={season}
@ -668,7 +668,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
} }
// Poster view (current implementation) // Poster view (current implementation)
if (__DEV__) console.log('[SeriesContent] Rendering poster view for season:', season, 'View mode ref:', seasonViewMode);
return ( return (
<View <View
key={season} key={season}
@ -796,7 +796,7 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
const effectiveVote = imdbRating ?? tmdbRating ?? 0; const effectiveVote = imdbRating ?? tmdbRating ?? 0;
const isImdbRating = imdbRating !== null; const isImdbRating = imdbRating !== null;
logger.log(`[SeriesContent] Vertical card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`);
const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime;
if (!episode.still_path && tmdbOverride?.still_path) { if (!episode.still_path && tmdbOverride?.still_path) {
@ -1067,8 +1067,6 @@ const SeriesContentComponent: React.FC<SeriesContentProps> = ({
const isImdbRating = imdbRating !== null; const isImdbRating = imdbRating !== null;
const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime; const effectiveRuntime = tmdbOverride?.runtime ?? (episode as any).runtime;
logger.log(`[SeriesContent] Horizontal card S${episode.season_number}E${episode.episode_number}: IMDb=${imdbRating}, TMDB=${tmdbRating}, effective=${effectiveVote}, isImdb=${isImdbRating}`);
const formatDate = (dateString: string) => { const formatDate = (dateString: string) => {
const date = new Date(dateString); const date = new Date(dateString);
return date.toLocaleDateString('en-US', { return date.toLocaleDateString('en-US', {

View file

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image } from 'react-native'; import { View, TouchableOpacity, TouchableWithoutFeedback, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, Image, InteractionManager } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType, ViewType } from 'react-native-video'; import Video, { VideoRef, SelectedTrack, SelectedTrackType, BufferingStrategyType, ViewType } from 'react-native-video';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
@ -641,6 +641,8 @@ const AndroidVideoPlayer: React.FC = () => {
// Prefetch backdrop and title logo for faster loading screen appearance // Prefetch backdrop and title logo for faster loading screen appearance
useEffect(() => { useEffect(() => {
// Defer prefetching until after navigation animation completes
const task = InteractionManager.runAfterInteractions(() => {
if (backdrop && typeof backdrop === 'string') { if (backdrop && typeof backdrop === 'string') {
// Reset loading state // Reset loading state
setIsBackdropLoaded(false); setIsBackdropLoaded(false);
@ -667,9 +669,13 @@ const AndroidVideoPlayer: React.FC = () => {
setIsBackdropLoaded(true); setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(0); backdropImageOpacityAnim.setValue(0);
} }
});
return () => task.cancel();
}, [backdrop]); }, [backdrop]);
useEffect(() => { useEffect(() => {
// Defer logo prefetch until after navigation animation
const task = InteractionManager.runAfterInteractions(() => {
const logoUrl = (metadata && (metadata as any).logo) as string | undefined; const logoUrl = (metadata && (metadata as any).logo) as string | undefined;
if (logoUrl && typeof logoUrl === 'string') { if (logoUrl && typeof logoUrl === 'string') {
try { try {
@ -678,6 +684,8 @@ const AndroidVideoPlayer: React.FC = () => {
// Silently ignore logo prefetch errors // Silently ignore logo prefetch errors
} }
} }
});
return () => task.cancel();
}, [metadata]); }, [metadata]);
// Resolve current episode description for series // Resolve current episode description for series

View file

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState } from 'react-native'; import { View, TouchableOpacity, Dimensions, Animated, ActivityIndicator, Platform, NativeModules, StatusBar, Text, StyleSheet, Modal, AppState, InteractionManager } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native'; import { useNavigation, useRoute, RouteProp, useFocusEffect } from '@react-navigation/native';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
@ -342,6 +342,8 @@ const KSPlayerCore: React.FC = () => {
// Load custom backdrop on mount // Load custom backdrop on mount
// Prefetch backdrop and title logo for faster loading screen appearance // Prefetch backdrop and title logo for faster loading screen appearance
useEffect(() => { useEffect(() => {
// Defer prefetching until after navigation animation completes
const task = InteractionManager.runAfterInteractions(() => {
if (backdrop && typeof backdrop === 'string') { if (backdrop && typeof backdrop === 'string') {
// Reset loading state // Reset loading state
setIsBackdropLoaded(false); setIsBackdropLoaded(false);
@ -368,9 +370,13 @@ const KSPlayerCore: React.FC = () => {
setIsBackdropLoaded(true); setIsBackdropLoaded(true);
backdropImageOpacityAnim.setValue(0); backdropImageOpacityAnim.setValue(0);
} }
});
return () => task.cancel();
}, [backdrop]); }, [backdrop]);
useEffect(() => { useEffect(() => {
// Defer logo prefetch until after navigation animation
const task = InteractionManager.runAfterInteractions(() => {
const logoUrl = (metadata && (metadata as any).logo) as string | undefined; const logoUrl = (metadata && (metadata as any).logo) as string | undefined;
if (logoUrl && typeof logoUrl === 'string') { if (logoUrl && typeof logoUrl === 'string') {
try { try {
@ -379,6 +385,8 @@ const KSPlayerCore: React.FC = () => {
// Silently ignore logo prefetch errors // Silently ignore logo prefetch errors
} }
} }
});
return () => task.cancel();
}, [metadata]); }, [metadata]);
// Log video source configuration with headers // Log video source configuration with headers

View file

@ -87,6 +87,7 @@ export interface AppSettings {
openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen openMetadataScreenWhenCacheDisabled: boolean; // When cache disabled, open MetadataScreen instead of StreamsScreen
streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour) streamCacheTTL: number; // Stream cache duration in milliseconds (default: 1 hour)
enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile enableStreamsBackdrop: boolean; // Enable blurred backdrop background on StreamsScreen mobile
useExternalPlayerForDownloads: boolean; // Enable/disable external player for downloaded content
} }
export const DEFAULT_SETTINGS: AppSettings = { export const DEFAULT_SETTINGS: AppSettings = {
@ -122,6 +123,7 @@ export const DEFAULT_SETTINGS: AppSettings = {
alwaysResume: true, alwaysResume: true,
// Downloads // Downloads
enableDownloads: false, enableDownloads: false,
useExternalPlayerForDownloads: false,
// Theme defaults // Theme defaults
themeId: 'default', themeId: 'default',
customThemes: [], customThemes: [],

View file

@ -1210,7 +1210,7 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
options={{ options={{
animation: 'default', animation: 'default',
animationDuration: 0, animationDuration: 0,
// Force fullscreen presentation on iPad // fullScreenModal required for proper video rendering on iOS
presentation: 'fullScreenModal', presentation: 'fullScreenModal',
// Disable gestures during video playback // Disable gestures during video playback
gestureEnabled: false, gestureEnabled: false,

View file

@ -11,6 +11,7 @@ import {
Alert, Alert,
Platform, Platform,
Clipboard, Clipboard,
Linking,
} from 'react-native'; } from 'react-native';
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useNavigation, useFocusEffect } from '@react-navigation/native'; import { useNavigation, useFocusEffect } from '@react-navigation/native';
@ -28,9 +29,12 @@ import { RootStackParamList } from '../navigation/AppNavigator';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import FastImage from '@d11/react-native-fast-image'; import FastImage from '@d11/react-native-fast-image';
import { useDownloads } from '../contexts/DownloadsContext'; import { useDownloads } from '../contexts/DownloadsContext';
import { useSettings } from '../hooks/useSettings';
import { VideoPlayerService } from '../services/videoPlayerService';
import type { DownloadItem } from '../contexts/DownloadsContext'; import type { DownloadItem } from '../contexts/DownloadsContext';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import ScreenHeader from '../components/common/ScreenHeader';
const { height, width } = Dimensions.get('window'); const { height, width } = Dimensions.get('window');
const isTablet = width >= 768; const isTablet = width >= 768;
@ -342,7 +346,7 @@ const DownloadItemComponent: React.FC<{
const DownloadsScreen: React.FC = () => { const DownloadsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>(); const navigation = useNavigation<NavigationProp<RootStackParamList>>();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { top: safeAreaTop } = useSafeAreaInsets(); const { settings } = useSettings();
const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads(); const { downloads, pauseDownload, resumeDownload, cancelDownload } = useDownloads();
const { showSuccess, showInfo } = useToast(); const { showSuccess, showInfo } = useToast();
@ -352,9 +356,6 @@ const DownloadsScreen: React.FC = () => {
const [showRemoveAlert, setShowRemoveAlert] = useState(false); const [showRemoveAlert, setShowRemoveAlert] = useState(false);
const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null); const [pendingRemoveItem, setPendingRemoveItem] = useState<DownloadItem | null>(null);
// Animation values
const headerOpacity = useSharedValue(1);
// Filter downloads based on selected filter // Filter downloads based on selected filter
const filteredDownloads = useMemo(() => { const filteredDownloads = useMemo(() => {
if (selectedFilter === 'all') return downloads; if (selectedFilter === 'all') return downloads;
@ -394,7 +395,7 @@ const DownloadsScreen: React.FC = () => {
setIsRefreshing(false); setIsRefreshing(false);
}, []); }, []);
const handleDownloadPress = useCallback((item: DownloadItem) => { const handleDownloadPress = useCallback(async (item: DownloadItem) => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (item.status !== 'completed') { if (item.status !== 'completed') {
Alert.alert('Download not ready', 'Please wait until the download completes.'); Alert.alert('Download not ready', 'Please wait until the download completes.');
@ -411,6 +412,102 @@ const DownloadsScreen: React.FC = () => {
const isMp4 = /\.mp4(\?|$)/i.test(lower); const isMp4 = /\.mp4(\?|$)/i.test(lower);
const videoType = isM3u8 ? 'm3u8' : isMpd ? 'mpd' : isMp4 ? 'mp4' : undefined; const videoType = isM3u8 ? 'm3u8' : isMpd ? 'mpd' : isMp4 ? 'mp4' : undefined;
// Use external player if enabled in settings
if (settings.useExternalPlayerForDownloads) {
if (Platform.OS === 'android') {
try {
// Use VideoPlayerService for Android external playback
const success = await VideoPlayerService.playVideo(uri, {
useExternalPlayer: true,
title: item.title,
episodeTitle: item.type === 'series' ? item.episodeTitle : undefined,
episodeNumber: item.type === 'series' && item.season && item.episode ? `S${item.season}E${item.episode}` : undefined,
});
if (success) return;
// Fall through to internal player if external fails
} catch (error) {
console.error('External player failed:', error);
// Fall through to internal player
}
} else if (Platform.OS === 'ios') {
const streamUrl = encodeURIComponent(uri);
let externalPlayerUrls: string[] = [];
switch (settings.preferredPlayer) {
case 'vlc':
externalPlayerUrls = [
`vlc://${uri}`,
`vlc-x-callback://x-callback-url/stream?url=${streamUrl}`,
`vlc://${streamUrl}`
];
break;
case 'outplayer':
externalPlayerUrls = [
`outplayer://${uri}`,
`outplayer://${streamUrl}`,
`outplayer://play?url=${streamUrl}`,
`outplayer://stream?url=${streamUrl}`,
`outplayer://play/browser?url=${streamUrl}`
];
break;
case 'infuse':
externalPlayerUrls = [
`infuse://x-callback-url/play?url=${streamUrl}`,
`infuse://play?url=${streamUrl}`,
`infuse://${streamUrl}`
];
break;
case 'vidhub':
externalPlayerUrls = [
`vidhub://play?url=${streamUrl}`,
`vidhub://${streamUrl}`
];
break;
case 'infuse_livecontainer':
const infuseUrls = [
`infuse://x-callback-url/play?url=${streamUrl}`,
`infuse://play?url=${streamUrl}`,
`infuse://${streamUrl}`
];
externalPlayerUrls = infuseUrls.map(infuseUrl => {
const encoded = Buffer.from(infuseUrl).toString('base64');
return `livecontainer://open-url?url=${encoded}`;
});
break;
default:
// Internal logic will handle 'internal' choice
break;
}
if (settings.preferredPlayer !== 'internal') {
// Try each URL format in sequence
const tryNextUrl = (index: number) => {
if (index >= externalPlayerUrls.length) {
// Fallback to internal player if all external attempts fail
openInternalPlayer();
return;
}
const url = externalPlayerUrls[index];
Linking.openURL(url)
.catch(() => tryNextUrl(index + 1));
};
if (externalPlayerUrls.length > 0) {
tryNextUrl(0);
return;
}
}
}
}
const openInternalPlayer = () => {
// Build episodeId for series progress tracking (format: contentId:season:episode) // Build episodeId for series progress tracking (format: contentId:season:episode)
const episodeId = item.type === 'series' && item.season && item.episode const episodeId = item.type === 'series' && item.season && item.episode
? `${item.contentId}:${item.season}:${item.episode}` ? `${item.contentId}:${item.season}:${item.episode}`
@ -437,7 +534,10 @@ const DownloadsScreen: React.FC = () => {
backdrop: undefined, backdrop: undefined,
videoType, videoType,
} as any); } as any);
}, [navigation]); };
openInternalPlayer();
}, [navigation, settings]);
const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => { const handleDownloadAction = useCallback((item: DownloadItem, action: 'pause' | 'resume' | 'cancel' | 'retry') => {
if (action === 'pause') pauseDownload(item.id); if (action === 'pause') pauseDownload(item.id);
@ -468,11 +568,6 @@ const DownloadsScreen: React.FC = () => {
}, []) }, [])
); );
// Animated styles
const headerStyle = useAnimatedStyle(() => ({
opacity: headerOpacity.value,
}));
const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => ( const renderFilterButton = (filter: typeof selectedFilter, label: string, count: number) => (
<TouchableOpacity <TouchableOpacity
key={filter} key={filter}
@ -529,22 +624,10 @@ const DownloadsScreen: React.FC = () => {
backgroundColor="transparent" backgroundColor="transparent"
/> />
{/* Header */} {/* ScreenHeader Component */}
<Animated.View style={[ <ScreenHeader
styles.header, title="Downloads"
{ rightActionComponent={
backgroundColor: currentTheme.colors.darkBackground,
paddingTop: (Platform.OS === 'android'
? (StatusBar.currentHeight || 0) + 26
: safeAreaTop + 15) + (isTablet ? 64 : 0),
borderBottomColor: currentTheme.colors.border,
},
headerStyle,
]}>
<View style={styles.headerTitleRow}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Downloads
</Text>
<TouchableOpacity <TouchableOpacity
style={styles.helpButton} style={styles.helpButton}
onPress={showDownloadHelp} onPress={showDownloadHelp}
@ -556,8 +639,9 @@ const DownloadsScreen: React.FC = () => {
color={currentTheme.colors.mediumEmphasis} color={currentTheme.colors.mediumEmphasis}
/> />
</TouchableOpacity> </TouchableOpacity>
</View> }
isTablet={isTablet}
>
{downloads.length > 0 && ( {downloads.length > 0 && (
<View style={styles.filterContainer}> <View style={styles.filterContainer}>
{renderFilterButton('all', 'All', stats.total)} {renderFilterButton('all', 'All', stats.total)}
@ -566,7 +650,7 @@ const DownloadsScreen: React.FC = () => {
{renderFilterButton('paused', 'Paused', stats.paused)} {renderFilterButton('paused', 'Paused', stats.paused)}
</View> </View>
)} )}
</Animated.View> </ScreenHeader>
{/* Content */} {/* Content */}
{downloads.length === 0 ? ( {downloads.length === 0 ? (
@ -639,23 +723,6 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
header: {
paddingHorizontal: isTablet ? 24 : Math.max(1, width * 0.05),
paddingBottom: isTablet ? 20 : 16,
borderBottomWidth: StyleSheet.hairlineWidth,
},
headerTitleRow: {
flexDirection: 'row',
alignItems: 'flex-end',
justifyContent: 'space-between',
marginBottom: isTablet ? 20 : 16,
paddingBottom: 8,
},
headerTitle: {
fontSize: isTablet ? 36 : Math.min(32, width * 0.08),
fontWeight: '800',
letterSpacing: 0.3,
},
helpButton: { helpButton: {
padding: 8, padding: 8,
marginLeft: 8, marginLeft: 8,

View file

@ -4,6 +4,7 @@ import { Share } from 'react-native';
import { mmkvStorage } from '../services/mmkvStorage'; import { mmkvStorage } from '../services/mmkvStorage';
import { useToast } from '../contexts/ToastContext'; import { useToast } from '../contexts/ToastContext';
import DropUpMenu from '../components/home/DropUpMenu'; import DropUpMenu from '../components/home/DropUpMenu';
import ScreenHeader from '../components/common/ScreenHeader';
import { import {
View, View,
Text, Text,
@ -217,7 +218,7 @@ const LibraryScreen = () => {
const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null); const [selectedItem, setSelectedItem] = useState<LibraryItem | null>(null);
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { currentTheme } = useTheme(); const { currentTheme } = useTheme();
const { settings } = useSettings(); // ADD THIS const { settings } = useSettings();
// Trakt integration // Trakt integration
const { const {
@ -915,16 +916,11 @@ const LibraryScreen = () => {
); );
}; };
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
// Tablet detection aligned with navigation tablet logic // Tablet detection aligned with navigation tablet logic
const isTablet = useMemo(() => { const isTablet = useMemo(() => {
const smallestDimension = Math.min(width, height); const smallestDimension = Math.min(width, height);
return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768); return (Platform.OS === 'ios' ? (Platform as any).isPad === true : smallestDimension >= 768);
}, [width, height]); }, [width, height]);
// Keep header below floating top navigator on tablets
const tabletNavOffset = isTablet ? 64 : 0;
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
const headerHeight = headerBaseHeight + topSpacing;
return ( return (
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
@ -993,8 +989,17 @@ const LibraryScreen = () => {
</View> </View>
)} )}
{showTraktContent ? renderTraktContent() : renderContent()} {/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{!showTraktContent && (
<View style={styles.filtersContainer}>
{renderFilter('trakt', 'Trakt', 'pan-tool')}
{renderFilter('movies', 'Movies', 'movie')}
{renderFilter('series', 'TV Shows', 'live-tv')}
</View> </View>
)}
{showTraktContent ? renderTraktContent() : renderContent()}
</View> </View>
{/* DropUpMenu integration */} {/* DropUpMenu integration */}
@ -1060,13 +1065,6 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
watchedIndicator: { watchedIndicator: {
position: 'absolute', position: 'absolute',
top: 8, top: 8,
@ -1078,23 +1076,6 @@ const styles = StyleSheet.create({
contentContainer: { contentContainer: {
flex: 1, flex: 1,
}, },
header: {
paddingHorizontal: 20,
justifyContent: 'flex-end',
paddingBottom: 8,
backgroundColor: 'transparent',
zIndex: 2,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.5,
},
filtersContainer: { filtersContainer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',

View file

@ -322,6 +322,48 @@ const PlayerSettingsScreen: React.FC = () => {
/> />
</View> </View>
</View> </View>
{/* External Player for Downloads */}
{((Platform.OS === 'android' && settings.useExternalPlayer) ||
(Platform.OS === 'ios' && settings.preferredPlayer !== 'internal')) && (
<View style={[styles.settingItem, styles.settingItemBorder, { borderBottomWidth: 0, borderTopWidth: 1, borderTopColor: 'rgba(255,255,255,0.08)' }]}>
<View style={styles.settingContent}>
<View style={[
styles.settingIconContainer,
{ backgroundColor: 'rgba(255,255,255,0.1)' }
]}>
<MaterialIcons
name="open-in-new"
size={20}
color={currentTheme.colors.primary}
/>
</View>
<View style={styles.settingText}>
<Text
style={[
styles.settingTitle,
{ color: currentTheme.colors.text },
]}
>
External Player for Downloads
</Text>
<Text
style={[
styles.settingDescription,
{ color: currentTheme.colors.textMuted },
]}
>
Play downloaded content in your preferred external player.
</Text>
</View>
<Switch
value={settings.useExternalPlayerForDownloads}
onValueChange={(value) => updateSetting('useExternalPlayerForDownloads', value)}
thumbColor={settings.useExternalPlayerForDownloads ? currentTheme.colors.primary : undefined}
/>
</View>
</View>
)}
</View> </View>
</View> </View>
</ScrollView> </ScrollView>

View file

@ -43,6 +43,7 @@ import { BlurView } from 'expo-blur';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import LoadingSpinner from '../components/common/LoadingSpinner'; import LoadingSpinner from '../components/common/LoadingSpinner';
import ScreenHeader from '../components/common/ScreenHeader';
const { width, height } = Dimensions.get('window'); const { width, height } = Dimensions.get('window');
@ -784,12 +785,6 @@ const SearchScreen = () => {
return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex; return prev.addonGroup === next.addonGroup && prev.addonIndex === next.addonIndex;
}); });
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
// Keep header below floating top navigator on tablets by adding extra offset
const tabletNavOffset = (isTV || isLargeTablet || isTablet) ? 64 : 0;
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
const headerHeight = headerBaseHeight + topSpacing + 60;
// Set up listeners for watched status and library updates // Set up listeners for watched status and library updates
// These will trigger re-renders in individual SearchResultItem components // These will trigger re-renders in individual SearchResultItem components
useEffect(() => { useEffect(() => {
@ -822,15 +817,13 @@ const SearchScreen = () => {
backgroundColor="transparent" backgroundColor="transparent"
translucent translucent
/> />
{/* Fixed position header background to prevent shifts */}
<View style={[styles.headerBackground, { {/* ScreenHeader Component */}
height: headerHeight, <ScreenHeader
backgroundColor: currentTheme.colors.darkBackground title="Search"
}]} /> isTablet={isTV || isLargeTablet || isTablet}
<View style={{ flex: 1 }}> >
{/* Header Section with proper top spacing */} {/* Search Bar */}
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.white }]}>Search</Text>
<View style={styles.searchBarContainer}> <View style={styles.searchBarContainer}>
<View style={[ <View style={[
styles.searchBarWrapper, styles.searchBarWrapper,
@ -879,7 +872,8 @@ const SearchScreen = () => {
</View> </View>
</View> </View>
</View> </View>
</View> </ScreenHeader>
{/* Content Container */} {/* Content Container */}
<View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}> <View style={[styles.contentContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
{searching ? ( {searching ? (
@ -987,7 +981,6 @@ const SearchScreen = () => {
}} }}
/> />
)} )}
</View>
</Animated.View> </Animated.View>
); );
}; };
@ -996,30 +989,10 @@ const styles = StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, },
headerBackground: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 1,
},
contentContainer: { contentContainer: {
flex: 1, flex: 1,
paddingTop: 0, paddingTop: 0,
}, },
header: {
paddingHorizontal: 15,
justifyContent: 'flex-end',
paddingBottom: 0,
backgroundColor: 'transparent',
zIndex: 2,
},
headerTitle: {
fontSize: 32,
fontWeight: '800',
letterSpacing: 0.5,
marginBottom: 12,
},
searchBarContainer: { searchBarContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',

View file

@ -33,6 +33,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import * as Sentry from '@sentry/react-native'; import * as Sentry from '@sentry/react-native';
import { getDisplayedAppVersion } from '../utils/version'; import { getDisplayedAppVersion } from '../utils/version';
import CustomAlert from '../components/CustomAlert'; import CustomAlert from '../components/CustomAlert';
import ScreenHeader from '../components/common/ScreenHeader';
import PluginIcon from '../components/icons/PluginIcon'; import PluginIcon from '../components/icons/PluginIcon';
import TraktIcon from '../components/icons/TraktIcon'; import TraktIcon from '../components/icons/TraktIcon';
import TMDBIcon from '../components/icons/TMDBIcon'; import TMDBIcon from '../components/icons/TMDBIcon';
@ -86,7 +87,11 @@ const SettingsCard: React.FC<SettingsCardProps> = ({ children, title, isTablet =
)} )}
<View style={[ <View style={[
styles.card, styles.card,
{ backgroundColor: currentTheme.colors.elevation1 }, {
backgroundColor: currentTheme.colors.elevation1,
borderWidth: 1,
borderColor: currentTheme.colors.elevation2,
},
isTablet && styles.tabletCard isTablet && styles.tabletCard
]}> ]}>
{children} {children}
@ -134,9 +139,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
<View style={[ <View style={[
styles.settingIconContainer, styles.settingIconContainer,
{ {
backgroundColor: currentTheme.colors.darkGray, backgroundColor: currentTheme.colors.primary + '12',
borderWidth: 1,
borderColor: currentTheme.colors.primary + '20'
}, },
isTablet && styles.tabletSettingIconContainer isTablet && styles.tabletSettingIconContainer
]}> ]}>
@ -145,7 +148,7 @@ const SettingItem: React.FC<SettingItemProps> = ({
) : ( ) : (
<Feather <Feather
name={icon! as any} name={icon! as any}
size={isTablet ? 24 : 20} size={isTablet ? 22 : 18}
color={currentTheme.colors.primary} color={currentTheme.colors.primary}
/> />
)} )}
@ -195,11 +198,18 @@ interface SidebarProps {
const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, currentTheme, categories, extraTopPadding = 0 }) => { const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, currentTheme, categories, extraTopPadding = 0 }) => {
return ( return (
<View style={[styles.sidebar, { backgroundColor: currentTheme.colors.elevation1 }]}> <View style={[
styles.sidebar,
{
backgroundColor: currentTheme.colors.elevation1,
borderRightColor: currentTheme.colors.elevation2,
}
]}>
<View style={[ <View style={[
styles.sidebarHeader, styles.sidebarHeader,
{ {
paddingTop: (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48) + extraTopPadding, paddingTop: (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48) + extraTopPadding,
borderBottomColor: currentTheme.colors.elevation2,
} }
]}> ]}>
<Text style={[styles.sidebarTitle, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.sidebarTitle, { color: currentTheme.colors.highEmphasis }]}>
@ -215,26 +225,37 @@ const Sidebar: React.FC<SidebarProps> = ({ selectedCategory, onCategorySelect, c
styles.sidebarItem, styles.sidebarItem,
selectedCategory === category.id && [ selectedCategory === category.id && [
styles.sidebarItemActive, styles.sidebarItemActive,
{ backgroundColor: `${currentTheme.colors.primary}15` } { backgroundColor: currentTheme.colors.primary + '10' }
] ]
]} ]}
onPress={() => onCategorySelect(category.id)} onPress={() => onCategorySelect(category.id)}
activeOpacity={0.6}
> >
<View style={[
styles.sidebarItemIconContainer,
{
backgroundColor: selectedCategory === category.id
? currentTheme.colors.primary + '15'
: 'transparent',
}
]}>
<Feather <Feather
name={category.icon as any} name={category.icon as any}
size={22} size={20}
color={ color={
selectedCategory === category.id selectedCategory === category.id
? currentTheme.colors.primary ? currentTheme.colors.primary
: currentTheme.colors.mediumEmphasis : currentTheme.colors.mediumEmphasis
} }
/> />
</View>
<Text style={[ <Text style={[
styles.sidebarItemText, styles.sidebarItemText,
{ {
color: selectedCategory === category.id color: selectedCategory === category.id
? currentTheme.colors.primary ? currentTheme.colors.highEmphasis
: currentTheme.colors.mediumEmphasis : currentTheme.colors.mediumEmphasis,
fontWeight: selectedCategory === category.id ? '600' : '500',
} }
]}> ]}>
{category.title} {category.title}
@ -863,11 +884,8 @@ const SettingsScreen: React.FC = () => {
} }
}; };
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
// Keep headers below floating top navigator on tablets by adding extra offset // Keep headers below floating top navigator on tablets by adding extra offset
const tabletNavOffset = isTablet ? 64 : 0; const tabletNavOffset = isTablet ? 64 : 0;
const topSpacing = (Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top) + tabletNavOffset;
const headerHeight = headerBaseHeight + topSpacing;
if (isTablet) { if (isTablet) {
return ( return (
@ -917,7 +935,21 @@ const SettingsScreen: React.FC = () => {
</Text> </Text>
</View> </View>
<View style={styles.discordContainer}> <View style={styles.discordContainer}>
<View style={{ flexDirection: 'row', gap: 12 }}> <TouchableOpacity
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0, marginBottom: 8 }]}
onPress={() => WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', {
presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET
})}
activeOpacity={0.7}
>
<FastImage
source={require('../../assets/support_me_on_kofi_red.png')}
style={styles.kofiImage}
resizeMode={FastImage.resizeMode.contain}
/>
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<TouchableOpacity <TouchableOpacity
style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]} style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={() => Linking.openURL('https://discord.gg/6w8dr3TSDN')} onPress={() => Linking.openURL('https://discord.gg/6w8dr3TSDN')}
@ -930,23 +962,26 @@ const SettingsScreen: React.FC = () => {
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
Join Discord Discord
</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0 }]} style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]}
onPress={() => WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET
})}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.discordButtonContent}>
<FastImage <FastImage
source={require('../../assets/support_me_on_kofi_red.png')} source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
style={styles.kofiImage} style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
<Text style={[styles.discordButtonText, { color: '#FF4500' }]}>
Reddit
</Text>
</View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -973,12 +1008,10 @@ const SettingsScreen: React.FC = () => {
{ backgroundColor: currentTheme.colors.darkBackground } { backgroundColor: currentTheme.colors.darkBackground }
]}> ]}>
<StatusBar barStyle={'light-content'} /> <StatusBar barStyle={'light-content'} />
<ScreenHeader
title="Settings"
/>
<View style={{ flex: 1 }}> <View style={{ flex: 1 }}>
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
Settings
</Text>
</View>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
<ScrollView <ScrollView
@ -1017,7 +1050,21 @@ const SettingsScreen: React.FC = () => {
{/* Support & Community Buttons */} {/* Support & Community Buttons */}
<View style={styles.discordContainer}> <View style={styles.discordContainer}>
<View style={{ flexDirection: 'row', gap: 12 }}> <TouchableOpacity
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0, marginBottom: 8 }]}
onPress={() => WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', {
presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET
})}
activeOpacity={0.7}
>
<FastImage
source={require('../../assets/support_me_on_kofi_red.png')}
style={styles.kofiImage}
resizeMode={FastImage.resizeMode.contain}
/>
</TouchableOpacity>
<View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap', justifyContent: 'center' }}>
<TouchableOpacity <TouchableOpacity
style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]} style={[styles.discordButton, { backgroundColor: currentTheme.colors.elevation1 }]}
onPress={() => Linking.openURL('https://discord.gg/6w8dr3TSDN')} onPress={() => Linking.openURL('https://discord.gg/6w8dr3TSDN')}
@ -1030,23 +1077,26 @@ const SettingsScreen: React.FC = () => {
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
<Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}> <Text style={[styles.discordButtonText, { color: currentTheme.colors.highEmphasis }]}>
Join Discord Discord
</Text> </Text>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity <TouchableOpacity
style={[styles.discordButton, { backgroundColor: 'transparent', paddingVertical: 0, paddingHorizontal: 0 }]} style={[styles.discordButton, { backgroundColor: '#FF4500' + '15' }]}
onPress={() => WebBrowser.openBrowserAsync('https://ko-fi.com/tapframe', { onPress={() => Linking.openURL('https://www.reddit.com/r/Nuvio/')}
presentationStyle: Platform.OS === 'ios' ? WebBrowser.WebBrowserPresentationStyle.FORM_SHEET : WebBrowser.WebBrowserPresentationStyle.FORM_SHEET
})}
activeOpacity={0.7} activeOpacity={0.7}
> >
<View style={styles.discordButtonContent}>
<FastImage <FastImage
source={require('../../assets/support_me_on_kofi_red.png')} source={{ uri: 'https://www.iconpacks.net/icons/2/free-reddit-logo-icon-2436-thumb.png' }}
style={styles.kofiImage} style={styles.discordLogo}
resizeMode={FastImage.resizeMode.contain} resizeMode={FastImage.resizeMode.contain}
/> />
<Text style={[styles.discordButtonText, { color: '#FF4500' }]}>
Reddit
</Text>
</View>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View> </View>
@ -1069,20 +1119,6 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
// Mobile styles // Mobile styles
header: {
paddingHorizontal: Math.max(1, width * 0.05),
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-end',
paddingBottom: 8,
backgroundColor: 'transparent',
zIndex: 2,
},
headerTitle: {
fontSize: Math.min(32, width * 0.08),
fontWeight: '800',
letterSpacing: 0.3,
},
contentContainer: { contentContainer: {
flex: 1, flex: 1,
zIndex: 1, zIndex: 1,
@ -1095,7 +1131,8 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
width: '100%', width: '100%',
paddingBottom: 90, paddingTop: 8,
paddingBottom: 100,
}, },
// Tablet-specific styles // Tablet-specific styles
@ -1106,39 +1143,45 @@ const styles = StyleSheet.create({
sidebar: { sidebar: {
width: 280, width: 280,
borderRightWidth: 1, borderRightWidth: 1,
borderRightColor: 'rgba(255,255,255,0.1)',
}, },
sidebarHeader: { sidebarHeader: {
padding: 24, paddingHorizontal: 24,
paddingBottom: 20,
paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48, paddingTop: Platform.OS === 'android' ? (StatusBar.currentHeight || 0) + 24 : 48,
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: 'rgba(255,255,255,0.1)',
}, },
sidebarTitle: { sidebarTitle: {
fontSize: 28, fontSize: 42,
fontWeight: '800', fontWeight: '700',
letterSpacing: 0.3, letterSpacing: -0.3,
}, },
sidebarContent: { sidebarContent: {
flex: 1, flex: 1,
paddingTop: 16, paddingTop: 12,
paddingBottom: 24,
}, },
sidebarItem: { sidebarItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: 24, paddingHorizontal: 16,
paddingVertical: 16, paddingVertical: 12,
marginHorizontal: 12, marginHorizontal: 12,
marginVertical: 2, marginVertical: 2,
borderRadius: 12, borderRadius: 10,
}, },
sidebarItemActive: { sidebarItemActive: {
borderRadius: 12, borderRadius: 10,
},
sidebarItemIconContainer: {
width: 32,
height: 32,
borderRadius: 8,
alignItems: 'center',
justifyContent: 'center',
}, },
sidebarItemText: { sidebarItemText: {
fontSize: 16, fontSize: 15,
fontWeight: '500', marginLeft: 12,
marginLeft: 16,
}, },
tabletContent: { tabletContent: {
flex: 1, flex: 1,
@ -1146,80 +1189,74 @@ const styles = StyleSheet.create({
}, },
tabletScrollView: { tabletScrollView: {
flex: 1, flex: 1,
paddingHorizontal: 32, paddingHorizontal: 40,
}, },
tabletScrollContent: { tabletScrollContent: {
paddingBottom: 32, paddingTop: 8,
paddingBottom: 40,
}, },
// Common card styles // Common card styles
cardContainer: { cardContainer: {
width: '100%', width: '100%',
marginBottom: 20, marginBottom: 24,
}, },
tabletCardContainer: { tabletCardContainer: {
marginBottom: 32, marginBottom: 28,
}, },
cardTitle: { cardTitle: {
fontSize: 13, fontSize: 12,
fontWeight: '600', fontWeight: '600',
letterSpacing: 0.8, letterSpacing: 1,
marginLeft: Math.max(12, width * 0.04), marginLeft: Math.max(16, width * 0.045),
marginBottom: 8, marginBottom: 10,
textTransform: 'uppercase',
}, },
tabletCardTitle: { tabletCardTitle: {
fontSize: 14, fontSize: 12,
marginLeft: 0, marginLeft: 4,
marginBottom: 12, marginBottom: 12,
}, },
card: { card: {
marginHorizontal: Math.max(12, width * 0.04), marginHorizontal: Math.max(16, width * 0.04),
borderRadius: 16, borderRadius: 14,
overflow: 'hidden', overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 4,
elevation: 3,
width: undefined, width: undefined,
}, },
tabletCard: { tabletCard: {
marginHorizontal: 0, marginHorizontal: 0,
borderRadius: 20, borderRadius: 16,
shadowOpacity: 0.15,
shadowRadius: 8,
elevation: 5,
}, },
settingItem: { settingItem: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: 12, paddingVertical: 14,
paddingHorizontal: Math.max(12, width * 0.04), paddingHorizontal: Math.max(14, width * 0.04),
borderBottomWidth: 0.5, borderBottomWidth: StyleSheet.hairlineWidth,
minHeight: Math.max(54, width * 0.14), minHeight: Math.max(60, width * 0.15),
width: '100%', width: '100%',
}, },
tabletSettingItem: { tabletSettingItem: {
paddingVertical: 16, paddingVertical: 16,
paddingHorizontal: 24, paddingHorizontal: 20,
minHeight: 70, minHeight: 68,
}, },
settingItemBorder: { settingItemBorder: {
// Border styling handled directly in the component with borderBottomWidth // Border styling handled directly in the component with borderBottomWidth
}, },
settingIconContainer: { settingIconContainer: {
marginRight: 16, marginRight: 14,
width: 36, width: 38,
height: 36, height: 38,
borderRadius: 10, borderRadius: 10,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
tabletSettingIconContainer: { tabletSettingIconContainer: {
width: 44, width: 42,
height: 44, height: 42,
borderRadius: 12, borderRadius: 11,
marginRight: 20, marginRight: 16,
}, },
settingContent: { settingContent: {
flex: 1, flex: 1,
@ -1230,32 +1267,33 @@ const styles = StyleSheet.create({
flex: 1, flex: 1,
}, },
settingTitle: { settingTitle: {
fontSize: Math.min(16, width * 0.042), fontSize: Math.min(16, width * 0.04),
fontWeight: '500',
marginBottom: 2,
letterSpacing: -0.2,
},
tabletSettingTitle: {
fontSize: 17,
fontWeight: '500', fontWeight: '500',
marginBottom: 3, marginBottom: 3,
}, },
tabletSettingTitle: {
fontSize: 18,
fontWeight: '600',
marginBottom: 4,
},
settingDescription: { settingDescription: {
fontSize: Math.min(14, width * 0.037), fontSize: Math.min(13, width * 0.034),
opacity: 0.8, opacity: 0.7,
}, },
tabletSettingDescription: { tabletSettingDescription: {
fontSize: 16, fontSize: 14,
opacity: 0.7, opacity: 0.6,
}, },
settingControl: { settingControl: {
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingLeft: 12, paddingLeft: 10,
}, },
badge: { badge: {
height: 22, height: 20,
minWidth: 22, minWidth: 20,
borderRadius: 11, borderRadius: 10,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingHorizontal: 6, paddingHorizontal: 6,
@ -1263,8 +1301,8 @@ const styles = StyleSheet.create({
}, },
badgeText: { badgeText: {
color: 'white', color: 'white',
fontSize: 12, fontSize: 11,
fontWeight: '600', fontWeight: '700',
}, },
segmentedControl: { segmentedControl: {
flexDirection: 'row', flexDirection: 'row',
@ -1293,26 +1331,27 @@ const styles = StyleSheet.create({
footer: { footer: {
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginTop: 10, marginTop: 24,
marginBottom: 8, marginBottom: 12,
}, },
footerText: { footerText: {
fontSize: 14, fontSize: 13,
opacity: 0.5, opacity: 0.5,
letterSpacing: 0.2,
}, },
// New styles for Discord button // Support buttons
discordContainer: { discordContainer: {
marginTop: 8, marginTop: 12,
marginBottom: 20, marginBottom: 24,
alignItems: 'center', alignItems: 'center',
}, },
discordButton: { discordButton: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: 8, paddingVertical: 10,
paddingHorizontal: 16, paddingHorizontal: 18,
borderRadius: 8, borderRadius: 10,
maxWidth: 200, maxWidth: 200,
}, },
discordButtonContent: { discordButtonContent: {
@ -1320,34 +1359,34 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
}, },
discordLogo: { discordLogo: {
width: 16, width: 18,
height: 16, height: 18,
marginRight: 8, marginRight: 10,
}, },
discordButtonText: { discordButtonText: {
fontSize: 14, fontSize: 14,
fontWeight: '500', fontWeight: '600',
}, },
kofiImage: { kofiImage: {
height: 32, height: 34,
width: 150, width: 155,
}, },
downloadsContainer: { downloadsContainer: {
marginTop: 20, marginTop: 32,
marginBottom: 12, marginBottom: 16,
alignItems: 'center', alignItems: 'center',
}, },
downloadsNumber: { downloadsNumber: {
fontSize: 32, fontSize: 36,
fontWeight: '800', fontWeight: '800',
letterSpacing: 1, letterSpacing: 0.5,
marginBottom: 4, marginBottom: 6,
}, },
downloadsLabel: { downloadsLabel: {
fontSize: 11, fontSize: 11,
fontWeight: '600', fontWeight: '600',
opacity: 0.6, opacity: 0.5,
letterSpacing: 1.2, letterSpacing: 1.5,
textTransform: 'uppercase', textTransform: 'uppercase',
}, },
loadingSpinner: { loadingSpinner: {

View file

@ -826,9 +826,6 @@ export const StreamsScreen = () => {
streamName: stream.name || stream.title streamName: stream.name || stream.title
}); });
// Add 50ms delay before navigating to player
await new Promise(resolve => setTimeout(resolve, 50));
// Prepare available streams for the change source feature // Prepare available streams for the change source feature
const streamsToPass = (type === 'series' || (type === 'other' && selectedEpisode)) ? episodeStreams : groupedStreams; const streamsToPass = (type === 'series' || (type === 'other' && selectedEpisode)) ? episodeStreams : groupedStreams;

View file

@ -266,6 +266,21 @@ class StorageService {
const timestamp = (options?.preserveTimestamp && typeof progress.lastUpdated === 'number') const timestamp = (options?.preserveTimestamp && typeof progress.lastUpdated === 'number')
? progress.lastUpdated ? progress.lastUpdated
: Date.now(); : Date.now();
try {
const removedMap = await this.getContinueWatchingRemoved();
const removedKey = this.buildWpKeyString(id, type);
const removedAt = removedMap[removedKey];
if (removedAt != null && timestamp > removedAt) {
logger.log(`♻️ [StorageService] restoring content to continue watching due to new progress: ${type}:${id}`);
await this.removeContinueWatchingRemoved(id, type);
}
} catch (e) {
// Ignore error checks for restoration to prevent blocking save
}
const updated = { ...progress, lastUpdated: timestamp }; const updated = { ...progress, lastUpdated: timestamp };
await mmkvStorage.setItem(key, JSON.stringify(updated)); await mmkvStorage.setItem(key, JSON.stringify(updated));