mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 23:42:04 +00:00
Merge branch 'main' into patch-5
This commit is contained in:
commit
53dd480231
20 changed files with 2751 additions and 2001 deletions
|
|
@ -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"> [](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"> [](https://tinyurl.com/NuvioAltstore)
|
<img src="https://upload.wikimedia.org/wikipedia/commons/2/20/AltStore_logo.png" width="24" height="24" align="left"> [](https://tinyurl.com/NuvioAltstore)
|
||||||
|
|
||||||
|
|
|
||||||
269
index.html
269
index.html
|
|
@ -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>
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
241
src/components/common/ScreenHeader.tsx
Normal file
241
src/components/common/ScreenHeader.tsx
Normal 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;
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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', {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue