Added streaming service style watch page for movies and series playback

This commit is contained in:
Trevor Mears
2026-01-06 20:01:43 -08:00
parent 3e377da67c
commit f525a6ee67
8 changed files with 1712 additions and 68 deletions
+642
View File
@@ -3144,4 +3144,646 @@ kbd {
margin-bottom: var(--space-xs);
color: var(--color-text-primary);
font-weight: 500;
}
/* =====================================================
Watch Page (VOD Player)
===================================================== */
/* Page Container - scrollable */
.watch-page {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
scroll-behavior: smooth;
background: var(--color-bg-primary);
}
.watch-page.active {
display: block;
}
/* Video Section - full viewport height minus navbar */
.watch-video-section {
position: relative;
width: 100%;
height: calc(100vh - var(--navbar-height));
min-height: 400px;
background: #000;
}
#watch-video {
width: 100%;
height: 100%;
object-fit: contain;
background: #000;
}
/* Player Overlay */
.watch-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
background: linear-gradient(to bottom,
rgba(0, 0, 0, 0.7) 0%,
transparent 20%,
transparent 75%,
rgba(0, 0, 0, 0.8) 100%);
opacity: 1;
transition: opacity 0.3s ease;
pointer-events: auto;
z-index: 10;
}
.watch-overlay.hidden {
opacity: 0;
pointer-events: none;
}
/* Top Bar */
.watch-top-bar {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-lg);
padding-top: calc(var(--space-lg) + var(--safe-area-inset-top));
}
.watch-back-btn {
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: var(--radius-full);
color: #fff;
cursor: pointer;
transition: background var(--transition-fast);
flex-shrink: 0;
}
.watch-back-btn:hover {
background: rgba(255, 255, 255, 0.2);
}
.watch-back-btn .icon {
width: 24px;
height: 24px;
}
.watch-title-info {
flex: 1;
min-width: 0;
}
.watch-title {
font-size: 1.25rem;
font-weight: 600;
color: #fff;
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watch-subtitle {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.7);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Center Play Button */
.watch-center-play {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 80px;
height: 80px;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.6);
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: var(--radius-full);
color: #fff;
cursor: pointer;
transition: all var(--transition-fast);
}
.watch-center-play:hover {
background: rgba(0, 0, 0, 0.8);
border-color: rgba(255, 255, 255, 0.5);
transform: translate(-50%, -50%) scale(1.1);
}
.watch-center-play.show {
display: flex;
}
.watch-center-play .icon {
width: 40px;
height: 40px;
}
/* Bottom Bar */
.watch-bottom-bar {
padding: var(--space-md) var(--space-lg);
padding-bottom: calc(var(--space-lg) + var(--safe-area-inset-bottom));
}
/* Progress Container */
.watch-progress-container {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-bottom: var(--space-md);
}
.watch-time {
font-size: 0.875rem;
color: rgba(255, 255, 255, 0.8);
min-width: 50px;
font-variant-numeric: tabular-nums;
}
.watch-time:last-child {
text-align: right;
}
.watch-progress {
flex: 1;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
cursor: pointer;
}
.watch-progress::-webkit-slider-runnable-track {
height: 6px;
border-radius: 3px;
}
.watch-progress::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 16px;
height: 16px;
background: var(--color-accent);
border-radius: 50%;
cursor: pointer;
margin-top: -5px;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
transition: transform var(--transition-fast);
}
.watch-progress:hover::-webkit-slider-thumb {
transform: scale(1.2);
}
/* Control Buttons */
.watch-controls {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
}
.watch-btn {
position: relative;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
border: none;
color: #fff;
cursor: pointer;
border-radius: var(--radius-md);
transition: background var(--transition-fast);
}
.watch-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.watch-btn .icon {
width: 24px;
height: 24px;
}
.watch-btn-lg {
width: 64px;
height: 64px;
background: rgba(255, 255, 255, 0.1);
border-radius: var(--radius-full);
}
.watch-btn-lg:hover {
background: rgba(255, 255, 255, 0.2);
}
.watch-btn-lg .icon {
width: 32px;
height: 32px;
}
.skip-label {
position: absolute;
bottom: 2px;
font-size: 0.625rem;
font-weight: 600;
}
.watch-spacer {
flex: 1;
}
/* Volume Slider */
.watch-volume {
width: 80px;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.3);
border-radius: 2px;
cursor: pointer;
}
.watch-volume::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 12px;
height: 12px;
background: #fff;
border-radius: 50%;
cursor: pointer;
}
/* Scroll Hint */
.watch-scroll-hint {
position: absolute;
bottom: 100px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
color: rgba(255, 255, 255, 0.5);
font-size: 0.75rem;
animation: bounce 2s infinite ease-in-out;
pointer-events: none;
}
.watch-scroll-hint .icon {
width: 20px;
height: 20px;
}
@keyframes bounce {
0%,
100% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(8px);
}
}
/* Next Episode Panel */
.watch-next-episode {
position: absolute;
bottom: 140px;
right: var(--space-lg);
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-md) var(--space-lg);
background: var(--glass-bg);
backdrop-filter: blur(16px);
border: 1px solid var(--glass-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-lg);
animation: slideInRight 0.3s ease;
z-index: 20;
}
.watch-next-episode.hidden {
display: none;
}
@keyframes slideInRight {
from {
transform: translateX(100px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.next-info {
min-width: 0;
}
.next-label {
font-size: 0.75rem;
text-transform: uppercase;
color: var(--color-text-muted);
margin: 0 0 var(--space-xs) 0;
}
.next-info h4 {
color: #fff;
margin: 0;
font-size: 0.875rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
}
.next-countdown {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-accent);
min-width: 30px;
text-align: center;
}
/* ===== Details Section ===== */
.watch-details-section {
padding: var(--space-xl) var(--space-lg);
padding-bottom: var(--space-2xl);
max-width: 1200px;
margin: 0 auto;
}
.watch-content-info {
display: flex;
gap: var(--space-xl);
margin-bottom: var(--space-2xl);
}
.watch-poster {
width: 200px;
height: 300px;
object-fit: cover;
border-radius: var(--radius-md);
flex-shrink: 0;
background: var(--color-bg-tertiary);
}
.watch-meta {
flex: 1;
min-width: 0;
}
.watch-meta h2 {
font-size: 2rem;
margin-bottom: var(--space-sm);
color: var(--color-text-primary);
}
.watch-meta-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
color: var(--color-text-secondary);
margin-bottom: var(--space-md);
font-size: 0.875rem;
}
.watch-rating {
display: flex;
align-items: center;
gap: var(--space-xs);
color: var(--color-warning);
}
.watch-description {
color: var(--color-text-secondary);
line-height: 1.7;
margin-bottom: var(--space-lg);
max-width: 700px;
}
.watch-actions {
display: flex;
gap: var(--space-md);
flex-wrap: wrap;
}
.watch-actions .btn {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.watch-actions .btn .icon {
width: 18px;
height: 18px;
}
.watch-actions .btn .icon-fav-filled {
color: var(--color-error);
}
/* Recommended Movies Grid */
.watch-recommended h3,
.watch-episodes h3 {
margin-bottom: var(--space-lg);
font-size: 1.25rem;
color: var(--color-text-primary);
}
.watch-recommended-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: var(--space-md);
}
.watch-recommended-card {
cursor: pointer;
transition: transform var(--transition-fast);
}
.watch-recommended-card:hover {
transform: scale(1.05);
}
.watch-recommended-card img {
width: 100%;
aspect-ratio: 2/3;
object-fit: cover;
border-radius: var(--radius-sm);
background: var(--color-bg-tertiary);
}
.watch-recommended-card p {
margin-top: var(--space-xs);
font-size: 0.813rem;
color: var(--color-text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Series Episodes */
.watch-season-group {
margin-bottom: var(--space-sm);
}
.watch-season-header {
display: flex;
align-items: center;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
background: var(--color-bg-tertiary);
border-radius: var(--radius-sm);
cursor: pointer;
user-select: none;
transition: background var(--transition-fast);
}
.watch-season-header:hover {
background: var(--color-bg-hover);
}
.watch-season-header .icon {
width: 20px;
height: 20px;
color: var(--color-text-muted);
transition: transform var(--transition-fast);
}
.watch-season-header.collapsed .icon {
transform: rotate(-90deg);
}
.watch-season-header.collapsed+.watch-episode-list {
display: none;
}
.watch-season-name {
flex: 1;
font-weight: 500;
}
.watch-season-count {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.watch-episode-list {
padding: var(--space-xs) 0 var(--space-xs) var(--space-lg);
}
.watch-episode-item {
display: flex;
align-items: center;
gap: var(--space-md);
padding: var(--space-sm) var(--space-md);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--transition-fast);
}
.watch-episode-item:hover {
background: var(--color-bg-hover);
}
.watch-episode-item.active {
background: var(--color-accent-dim);
border-left: 3px solid var(--color-accent);
}
.watch-episode-num {
width: 32px;
color: var(--color-text-muted);
font-weight: 600;
font-size: 0.875rem;
flex-shrink: 0;
}
.watch-episode-title {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.watch-episode-duration {
color: var(--color-text-muted);
font-size: 0.813rem;
flex-shrink: 0;
}
/* Mobile Responsive */
@media (max-width: 768px) {
.watch-content-info {
flex-direction: column;
align-items: center;
text-align: center;
}
.watch-poster {
width: 150px;
height: 225px;
}
.watch-meta h2 {
font-size: 1.5rem;
}
.watch-meta-row {
justify-content: center;
}
.watch-description {
text-align: left;
}
.watch-actions {
justify-content: center;
}
.watch-scroll-hint {
display: none;
}
.watch-volume {
display: none;
}
.watch-next-episode {
left: var(--space-md);
right: var(--space-md);
bottom: 100px;
flex-wrap: wrap;
}
.next-info {
flex: 1;
}
.next-info h4 {
max-width: none;
}
}
+157 -1
View File
@@ -540,6 +540,161 @@
</div>
</div>
</div>
<!-- Watch Page (VOD Player) - Not in navigation -->
<div id="page-watch" class="page watch-page">
<!-- Video Section (100vh) -->
<section class="watch-video-section">
<video id="watch-video" playsinline></video>
<!-- Player Controls Overlay -->
<div class="watch-overlay" id="watch-overlay">
<!-- Top Bar: Back + Title -->
<div class="watch-top-bar">
<button class="watch-back-btn" id="watch-back-btn" title="Back">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
</svg>
</button>
<div class="watch-title-info">
<h3 class="watch-title" id="watch-title"></h3>
<p class="watch-subtitle" id="watch-subtitle"></p>
</div>
</div>
<!-- Center: Large Play Button -->
<button class="watch-center-play" id="watch-center-play">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" />
</svg>
</button>
<!-- Bottom Bar: Controls -->
<div class="watch-bottom-bar">
<div class="watch-progress-container">
<span id="watch-time-current" class="watch-time">0:00</span>
<input type="range" class="watch-progress" id="watch-progress" min="0" max="100" value="0" step="0.1">
</div>
<div class="watch-controls">
<button class="watch-btn" id="watch-skip-back" title="Back 10 seconds">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z" />
</svg>
<span class="skip-label">10</span>
</button>
<button class="watch-btn watch-btn-lg" id="watch-play-pause" title="Play/Pause">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon icon-play">
<path d="M8 5v14l11-7z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon icon-pause hidden">
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
</svg>
</button>
<button class="watch-btn" id="watch-skip-fwd" title="Forward 10 seconds">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z" />
</svg>
<span class="skip-label">10</span>
</button>
<div class="watch-spacer"></div>
<button class="watch-btn" id="watch-mute" title="Mute">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon icon-vol">
<path
d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon icon-muted hidden">
<path
d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
</svg>
</button>
<input type="range" class="watch-volume" id="watch-volume" min="0" max="100" value="80">
<button class="watch-btn" id="watch-fullscreen" title="Fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
</svg>
</button>
</div>
</div>
<!-- Scroll Indicator -->
<div class="watch-scroll-hint" id="watch-scroll-hint">
<span>Scroll for details</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z" />
</svg>
</div>
</div>
<!-- Next Episode Panel -->
<div class="watch-next-episode hidden" id="watch-next-episode">
<div class="next-info">
<p class="next-label">Up Next</p>
<h4 id="next-episode-title"></h4>
</div>
<span class="next-countdown" id="next-countdown">10</span>
<button class="btn btn-primary btn-sm" id="next-play-now">Play Now</button>
<button class="btn btn-ghost btn-sm" id="next-cancel">Cancel</button>
</div>
</section>
<!-- Content Details Section (scrollable) -->
<section class="watch-details-section" id="watch-details">
<!-- Content Info -->
<div class="watch-content-info">
<img class="watch-poster" id="watch-poster" src="" alt="">
<div class="watch-meta">
<h2 id="watch-content-title"></h2>
<div class="watch-meta-row">
<span class="watch-year" id="watch-year"></span>
<span class="watch-rating" id="watch-rating"></span>
<span class="watch-duration" id="watch-duration"></span>
</div>
<p class="watch-description" id="watch-description"></p>
<div class="watch-actions">
<button class="btn btn-primary" id="watch-play-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M8 5v14l11-7z" />
</svg>
<span id="watch-play-btn-text">Play</span>
</button>
<button class="btn btn-ghost" id="watch-favorite-btn">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon icon-fav-outline">
<path
d="M16.5 3c-1.74 0-3.41.81-4.5 2.09C10.91 3.81 9.24 3 7.5 3 4.42 3 2 5.42 2 8.5c0 3.78 3.4 6.86 8.55 11.54L12 21.35l1.45-1.32C18.6 15.36 22 12.28 22 8.5 22 5.42 19.58 3 16.5 3zm-4.4 15.55l-.1.1-.1-.1C7.14 14.24 4 11.39 4 8.5 4 6.5 5.5 5 7.5 5c1.54 0 3.04.99 3.57 2.36h1.87C13.46 5.99 14.96 5 16.5 5c2 0 3.5 1.5 3.5 3.5 0 2.89-3.14 5.74-7.9 10.05z" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
class="icon icon-fav-filled hidden">
<path
d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
<span>Favorite</span>
</button>
</div>
</div>
</div>
<!-- Movies: Recommended Section -->
<div class="watch-recommended hidden" id="watch-recommended">
<h3>More Like This</h3>
<div class="watch-recommended-grid" id="watch-recommended-grid">
<!-- Movie cards inserted dynamically -->
</div>
</div>
<!-- Series: Seasons & Episodes -->
<div class="watch-episodes hidden" id="watch-episodes">
<h3>Episodes</h3>
<div class="watch-seasons" id="watch-seasons">
<!-- Season accordions inserted dynamically -->
</div>
</div>
</section>
</div>
</main>
</div>
@@ -580,7 +735,8 @@
<script src="/js/pages/MoviesPage.js?v=4"></script>
<script src="/js/pages/SeriesPage.js?v=1"></script>
<script src="/js/pages/Settings.js?v=2"></script>
<script src="/js/app.js?v=3"></script>
<script src="/js/pages/WatchPage.js?v=1"></script>
<script src="/js/app.js?v=4"></script>
</body>
</html>
+15 -14
View File
@@ -20,6 +20,7 @@ class App {
this.pages.movies = new MoviesPage(this);
this.pages.series = new SeriesPage(this);
this.pages.settings = new SettingsPage(this);
this.pages.watch = new WatchPage(this);
this.init();
}
@@ -27,11 +28,11 @@ class App {
async init() {
// Check authentication first
await this.checkAuth();
// Mobile menu toggle
const mobileMenuToggle = document.getElementById('mobile-menu-toggle');
const navbarMenu = document.getElementById('navbar-menu');
if (mobileMenuToggle && navbarMenu) {
mobileMenuToggle.addEventListener('click', () => {
mobileMenuToggle.classList.toggle('active');
@@ -122,13 +123,13 @@ class App {
async checkAuth() {
const token = localStorage.getItem('authToken');
if (!token) {
// No token, redirect to login (replace to avoid back button issues)
window.location.replace('/login.html');
return;
}
try {
// Verify token with server
const response = await fetch('/api/auth/me', {
@@ -136,13 +137,13 @@ class App {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
throw new Error('Invalid token');
}
this.currentUser = await response.json();
// Hide settings for viewers
if (this.currentUser.role === 'viewer') {
const settingsLink = document.querySelector('.nav-link[data-page="settings"]');
@@ -150,10 +151,10 @@ class App {
settingsLink.style.display = 'none';
}
}
// Add logout button to navbar
this.addLogoutButton();
} catch (err) {
console.error('Authentication error:', err);
localStorage.removeItem('authToken');
@@ -164,7 +165,7 @@ class App {
addLogoutButton() {
const navbar = document.querySelector('.navbar-menu');
if (!navbar || document.getElementById('logout-btn')) return;
const logoutLink = document.createElement('a');
logoutLink.href = '#';
logoutLink.className = 'nav-link';
@@ -175,10 +176,10 @@ class App {
</svg></span>
<span>Logout</span>
`;
logoutLink.addEventListener('click', async (e) => {
e.preventDefault();
const token = localStorage.getItem('authToken');
if (token) {
await fetch('/api/auth/logout', {
@@ -188,11 +189,11 @@ class App {
}
});
}
localStorage.removeItem('authToken');
window.location.replace('/login.html');
});
navbar.appendChild(logoutLink);
}
+12 -16
View File
@@ -341,23 +341,19 @@ class MoviesPage {
const result = await API.proxy.xtream.getStreamUrl(movie.sourceId, movie.stream_id, 'movie', container);
if (result && result.url) {
// Navigate to home and play
this.app.navigateTo('home');
// Play the movie
if (this.app.player) {
const channel = {
id: `movie:${movie.stream_id}`,
name: movie.name,
tvgLogo: movie.stream_icon || movie.cover,
sourceType: 'xtream',
// Play in dedicated Watch page
if (this.app.pages.watch) {
this.app.pages.watch.play({
type: 'movie',
id: movie.stream_id,
title: movie.name,
poster: movie.stream_icon || movie.cover,
description: movie.plot || '',
year: movie.year || movie.releaseDate?.substring(0, 4),
rating: movie.rating,
sourceId: movie.sourceId,
isVod: true // Mark as VOD for player handling
};
// For VOD, use direct URL (don't proxy through HLS)
// The video element can play mp4/mkv directly
this.app.player.play(channel, result.url);
categoryId: movie.category_id
}, result.url);
}
}
} catch (err) {
+27 -13
View File
@@ -366,6 +366,9 @@ class SeriesPage {
return;
}
// Store series info for WatchPage
this.currentSeriesInfo = info;
// Render seasons and episodes
let html = '';
const seasons = Object.keys(info.episodes).sort((a, b) => parseInt(a) - parseInt(b));
@@ -420,26 +423,37 @@ class SeriesPage {
const sourceId = parseInt(episodeEl.dataset.sourceId);
const container = episodeEl.dataset.container || 'mp4';
// Get season and episode number from the episode element context
const seasonGroup = episodeEl.closest('.season-group');
const seasonHeader = seasonGroup?.querySelector('.season-name')?.textContent || '';
const seasonMatch = seasonHeader.match(/Season (\d+)/);
const seasonNum = seasonMatch ? seasonMatch[1] : '1';
const episodeNum = episodeEl.querySelector('.episode-number')?.textContent?.replace('E', '') || '1';
try {
// Get stream URL for episode (use 'series' type)
const result = await API.proxy.xtream.getStreamUrl(sourceId, episodeId, 'series', container);
if (result && result.url) {
// Navigate to home and play
this.app.navigateTo('home');
// Play in dedicated Watch page
if (this.app.pages.watch) {
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || `Episode ${episodeNum}`;
// Play the episode
if (this.app.player) {
const episodeTitle = episodeEl.querySelector('.episode-title')?.textContent || 'Episode';
const channel = {
id: `episode:${episodeId}`,
name: `${this.currentSeries?.name || 'Series'} - ${episodeTitle}`,
sourceType: 'xtream',
this.app.pages.watch.play({
type: 'series',
id: episodeId,
title: this.currentSeries?.name || 'Series',
subtitle: `S${seasonNum} E${episodeNum} - ${episodeTitle}`,
poster: this.currentSeries?.cover,
description: this.currentSeries?.plot || '',
year: this.currentSeries?.year,
rating: this.currentSeries?.rating,
sourceId: sourceId,
isVod: true
};
this.app.player.play(channel, result.url);
seriesId: this.currentSeries?.series_id,
seriesInfo: this.currentSeriesInfo,
currentSeason: seasonNum,
currentEpisode: episodeNum
}, result.url);
}
}
} catch (err) {
+830
View File
@@ -0,0 +1,830 @@
/**
* Watch Page Controller
* Handles VOD (Movies/Series) playback with streaming service-style UI
*/
class WatchPage {
constructor(app) {
this.app = app;
// Video elements
this.video = document.getElementById('watch-video');
this.overlay = document.getElementById('watch-overlay');
// Top bar
this.backBtn = document.getElementById('watch-back-btn');
this.titleEl = document.getElementById('watch-title');
this.subtitleEl = document.getElementById('watch-subtitle');
// Controls
this.centerPlayBtn = document.getElementById('watch-center-play');
this.playPauseBtn = document.getElementById('watch-play-pause');
this.skipBackBtn = document.getElementById('watch-skip-back');
this.skipFwdBtn = document.getElementById('watch-skip-fwd');
this.muteBtn = document.getElementById('watch-mute');
this.volumeSlider = document.getElementById('watch-volume');
this.fullscreenBtn = document.getElementById('watch-fullscreen');
this.progressSlider = document.getElementById('watch-progress');
this.timeCurrent = document.getElementById('watch-time-current');
this.timeTotal = document.getElementById('watch-time-total');
this.scrollHint = document.getElementById('watch-scroll-hint');
// Next episode
this.nextEpisodePanel = document.getElementById('watch-next-episode');
this.nextEpisodeTitle = document.getElementById('next-episode-title');
this.nextCountdown = document.getElementById('next-countdown');
this.nextPlayNowBtn = document.getElementById('next-play-now');
this.nextCancelBtn = document.getElementById('next-cancel');
// Details section
this.posterEl = document.getElementById('watch-poster');
this.contentTitleEl = document.getElementById('watch-content-title');
this.yearEl = document.getElementById('watch-year');
this.ratingEl = document.getElementById('watch-rating');
this.durationEl = document.getElementById('watch-duration');
this.descriptionEl = document.getElementById('watch-description');
this.playBtn = document.getElementById('watch-play-btn');
this.playBtnText = document.getElementById('watch-play-btn-text');
this.favoriteBtn = document.getElementById('watch-favorite-btn');
// Recommended / Episodes
this.recommendedSection = document.getElementById('watch-recommended');
this.recommendedGrid = document.getElementById('watch-recommended-grid');
this.episodesSection = document.getElementById('watch-episodes');
this.seasonsContainer = document.getElementById('watch-seasons');
// State
this.hls = null;
this.content = null;
this.contentType = null; // 'movie' or 'series'
this.seriesInfo = null;
this.currentSeason = null;
this.currentEpisode = null;
this.isFavorite = false;
this.returnPage = null;
// Overlay timer
this.overlayTimeout = null;
this.overlayVisible = true;
// Next episode
this.nextEpisodeTimeout = null;
this.nextEpisodeCountdown = 10;
this.nextEpisodeInterval = null;
this.init();
}
init() {
// Back button
this.backBtn?.addEventListener('click', () => this.goBack());
// Play/Pause
this.centerPlayBtn?.addEventListener('click', () => this.togglePlay());
this.playPauseBtn?.addEventListener('click', () => this.togglePlay());
this.video?.addEventListener('click', () => this.togglePlay());
// Skip buttons
this.skipBackBtn?.addEventListener('click', () => this.skip(-10));
this.skipFwdBtn?.addEventListener('click', () => this.skip(10));
// Volume
this.muteBtn?.addEventListener('click', () => this.toggleMute());
this.volumeSlider?.addEventListener('input', (e) => this.setVolume(e.target.value));
// Fullscreen
this.fullscreenBtn?.addEventListener('click', () => this.toggleFullscreen());
// Progress bar
this.progressSlider?.addEventListener('input', (e) => this.seek(e.target.value));
// Video events
this.video?.addEventListener('timeupdate', () => this.updateProgress());
this.video?.addEventListener('loadedmetadata', () => this.onMetadataLoaded());
this.video?.addEventListener('play', () => this.onPlay());
this.video?.addEventListener('pause', () => this.onPause());
this.video?.addEventListener('ended', () => this.onEnded());
this.video?.addEventListener('error', (e) => this.onError(e));
// Overlay auto-hide
const watchSection = document.querySelector('.watch-video-section');
watchSection?.addEventListener('mousemove', () => this.showOverlay());
watchSection?.addEventListener('touchstart', () => this.showOverlay());
// Keyboard shortcuts
document.addEventListener('keydown', (e) => this.handleKeyboard(e));
// Details section buttons
this.playBtn?.addEventListener('click', () => this.scrollToVideo());
this.favoriteBtn?.addEventListener('click', () => this.toggleFavorite());
// Next episode buttons
this.nextPlayNowBtn?.addEventListener('click', () => this.playNextEpisode());
this.nextCancelBtn?.addEventListener('click', () => this.cancelNextEpisode());
// Hide scroll hint after scrolling
const watchPage = document.getElementById('page-watch');
watchPage?.addEventListener('scroll', () => {
if (watchPage.scrollTop > 50) {
this.scrollHint?.classList.add('hidden');
} else {
this.scrollHint?.classList.remove('hidden');
}
});
}
/**
* Main entry point - play content
* @param {Object} content - Movie or episode info
* @param {string} streamUrl - Stream URL
*/
async play(content, streamUrl) {
this.content = content;
this.contentType = content.type;
this.seriesInfo = content.seriesInfo || null;
this.currentSeason = content.currentSeason || null;
this.currentEpisode = content.currentEpisode || null;
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
// Reset state
this.cancelNextEpisode();
// Navigate to watch page
this.app.navigateTo('watch', true);
// Scroll to top
document.getElementById('page-watch')?.scrollTo(0, 0);
// Update title bar
this.titleEl.textContent = content.title || '';
this.subtitleEl.textContent = content.subtitle || '';
// Load video
await this.loadVideo(streamUrl);
// Populate details section
this.renderDetails();
// Load recommended (movies) or episodes (series)
if (content.type === 'movie') {
this.episodesSection?.classList.add('hidden');
this.recommendedSection?.classList.remove('hidden');
await this.loadRecommended(content.sourceId, content.categoryId);
} else {
this.recommendedSection?.classList.add('hidden');
this.episodesSection?.classList.remove('hidden');
this.renderEpisodes();
}
// Check favorite status
await this.checkFavorite();
// Show overlay initially
this.showOverlay();
}
async loadVideo(url) {
// Stop any existing playback
this.stop();
// Get settings for proxy/transcode
let settings = {};
try {
settings = await API.settings.get();
} catch (e) {
console.warn('Could not load settings');
}
// Detect stream type
const looksLikeHls = url.includes('.m3u8') || url.includes('m3u8');
const isRawTs = url.includes('.ts') && !url.includes('.m3u8');
const isDirectVideo = url.includes('.mp4') || url.includes('.mkv') || url.includes('.avi');
// Priority 1: Force Transcode - route through FFmpeg
if (settings.forceTranscode) {
console.log('[WatchPage] Force Transcode enabled');
const finalUrl = `/api/transcode?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
this.setVolumeFromStorage();
return;
}
// Priority 2: Force Remux for raw TS streams
if (settings.forceRemux && isRawTs) {
console.log('[WatchPage] Force Remux enabled');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
this.setVolumeFromStorage();
return;
}
// Determine if proxy is needed
const proxyRequiredDomains = ['pluto.tv'];
const needsProxy = settings.forceProxy || proxyRequiredDomains.some(domain => url.includes(domain));
const finalUrl = needsProxy ? `/api/proxy/stream?url=${encodeURIComponent(url)}` : url;
console.log('[WatchPage] Playing:', { url, needsProxy, looksLikeHls });
// Use HLS.js for HLS streams
if (looksLikeHls && Hls.isSupported()) {
this.hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
enableWorker: true,
});
this.hls.loadSource(finalUrl);
this.hls.attachMedia(this.video);
this.hls.on(Hls.Events.MANIFEST_PARSED, () => {
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
});
this.hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error('[WatchPage] HLS fatal error:', data);
// Try proxy on CORS error
if (!needsProxy && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
console.log('[WatchPage] Retrying via proxy...');
this.hls.loadSource(`/api/proxy/stream?url=${encodeURIComponent(url)}`);
this.hls.startLoad();
} else {
this.hls.destroy();
}
}
});
} else {
// Direct playback for mp4/mkv/avi
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
}
this.setVolumeFromStorage();
}
setVolumeFromStorage() {
const savedVolume = localStorage.getItem('nodecast-volume') || '80';
this.video.volume = parseInt(savedVolume) / 100;
if (this.volumeSlider) this.volumeSlider.value = savedVolume;
}
stop() {
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
if (this.video) {
this.video.pause();
this.video.src = '';
this.video.load();
}
}
// === Playback Controls ===
togglePlay() {
if (this.video.paused) {
this.video.play().catch(console.error);
} else {
this.video.pause();
}
}
skip(seconds) {
if (this.video) {
this.video.currentTime = Math.max(0, Math.min(this.video.currentTime + seconds, this.video.duration || 0));
}
}
seek(percent) {
if (this.video && this.video.duration) {
this.video.currentTime = (percent / 100) * this.video.duration;
}
}
toggleMute() {
if (this.video) {
this.video.muted = !this.video.muted;
this.updateVolumeUI();
}
}
setVolume(value) {
if (this.video) {
this.video.volume = value / 100;
this.video.muted = false;
localStorage.setItem('nodecast-volume', value);
this.updateVolumeUI();
}
}
toggleFullscreen() {
const container = document.querySelector('.watch-video-section');
if (!document.fullscreenElement) {
container?.requestFullscreen?.() || container?.webkitRequestFullscreen?.();
} else {
document.exitFullscreen?.() || document.webkitExitFullscreen?.();
}
}
// === UI Updates ===
updateProgress() {
if (!this.video || !this.video.duration) return;
const percent = (this.video.currentTime / this.video.duration) * 100;
this.progressSlider.value = percent;
this.timeCurrent.textContent = this.formatTime(this.video.currentTime);
}
onMetadataLoaded() {
// Duration is unreliable for transcoded streams, so we don't display it
}
onPlay() {
// Update play/pause button icons
this.playPauseBtn?.querySelector('.icon-play')?.classList.add('hidden');
this.playPauseBtn?.querySelector('.icon-pause')?.classList.remove('hidden');
this.centerPlayBtn?.classList.remove('show');
// Start overlay auto-hide
this.startOverlayTimer();
}
onPause() {
this.playPauseBtn?.querySelector('.icon-play')?.classList.remove('hidden');
this.playPauseBtn?.querySelector('.icon-pause')?.classList.add('hidden');
this.centerPlayBtn?.classList.add('show');
// Keep overlay visible when paused
this.showOverlay();
clearTimeout(this.overlayTimeout);
}
onEnded() {
// For series, show next episode panel
if (this.contentType === 'series' && this.seriesInfo) {
const nextEp = this.getNextEpisode();
if (nextEp) {
this.showNextEpisodePanel(nextEp);
}
}
}
onError(e) {
console.error('Video error:', e);
}
updateVolumeUI() {
const isMuted = this.video?.muted || this.video?.volume === 0;
this.muteBtn?.querySelector('.icon-vol')?.classList.toggle('hidden', isMuted);
this.muteBtn?.querySelector('.icon-muted')?.classList.toggle('hidden', !isMuted);
}
formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
}
// === Overlay Auto-Hide ===
showOverlay() {
this.overlay?.classList.remove('hidden');
this.overlayVisible = true;
this.startOverlayTimer();
}
hideOverlay() {
if (!this.video?.paused) {
this.overlay?.classList.add('hidden');
this.overlayVisible = false;
}
}
startOverlayTimer() {
clearTimeout(this.overlayTimeout);
this.overlayTimeout = setTimeout(() => this.hideOverlay(), 3000);
}
// === Keyboard Shortcuts ===
handleKeyboard(e) {
// Only handle when watch page is active
const watchPage = document.getElementById('page-watch');
if (!watchPage?.classList.contains('active')) return;
// Don't handle if typing in input
if (['INPUT', 'TEXTAREA'].includes(e.target.tagName)) return;
switch (e.key) {
case ' ':
case 'k':
e.preventDefault();
this.togglePlay();
break;
case 'ArrowLeft':
e.preventDefault();
this.skip(-10);
this.showOverlay();
break;
case 'ArrowRight':
e.preventDefault();
this.skip(10);
this.showOverlay();
break;
case 'ArrowUp':
e.preventDefault();
this.setVolume(Math.min(100, parseInt(this.volumeSlider.value) + 10));
this.volumeSlider.value = Math.min(100, parseInt(this.volumeSlider.value) + 10);
this.showOverlay();
break;
case 'ArrowDown':
e.preventDefault();
this.setVolume(Math.max(0, parseInt(this.volumeSlider.value) - 10));
this.volumeSlider.value = Math.max(0, parseInt(this.volumeSlider.value) - 10);
this.showOverlay();
break;
case 'f':
e.preventDefault();
this.toggleFullscreen();
break;
case 'm':
e.preventDefault();
this.toggleMute();
this.showOverlay();
break;
case 'Escape':
if (document.fullscreenElement) {
document.exitFullscreen();
} else {
this.goBack();
}
break;
}
}
// === Details Section ===
renderDetails() {
if (!this.content) return;
this.posterEl.src = this.content.poster || '/img/placeholder.png';
this.posterEl.alt = this.content.title || '';
this.contentTitleEl.textContent = this.content.title || '';
this.yearEl.textContent = this.content.year || '';
this.ratingEl.textContent = this.content.rating ? `★ ${this.content.rating}` : '';
this.descriptionEl.textContent = this.content.description || '';
// Update play button text
if (this.playBtnText) {
this.playBtnText.textContent = 'Play';
}
}
async checkFavorite() {
if (!this.content) return;
try {
const itemId = this.contentType === 'movie' ? this.content.id : this.content.seriesId;
const itemType = this.contentType === 'movie' ? 'movie' : 'series';
const result = await API.favorites.check(this.content.sourceId, itemId, itemType);
this.isFavorite = result?.isFavorite || false;
this.updateFavoriteUI();
} catch (e) {
console.warn('Could not check favorite status');
}
}
async toggleFavorite() {
if (!this.content) return;
const itemId = this.contentType === 'movie' ? this.content.id : this.content.seriesId;
const itemType = this.contentType === 'movie' ? 'movie' : 'series';
try {
if (this.isFavorite) {
await API.favorites.remove(this.content.sourceId, itemId, itemType);
this.isFavorite = false;
} else {
await API.favorites.add(this.content.sourceId, itemId, itemType);
this.isFavorite = true;
}
this.updateFavoriteUI();
} catch (e) {
console.error('Error toggling favorite:', e);
}
}
updateFavoriteUI() {
const outlineIcon = this.favoriteBtn?.querySelector('.icon-fav-outline');
const filledIcon = this.favoriteBtn?.querySelector('.icon-fav-filled');
outlineIcon?.classList.toggle('hidden', this.isFavorite);
filledIcon?.classList.toggle('hidden', !this.isFavorite);
}
scrollToVideo() {
document.getElementById('page-watch')?.scrollTo({ top: 0, behavior: 'smooth' });
if (this.video?.paused) {
this.video.play().catch(console.error);
}
}
// === Recommended Movies ===
async loadRecommended(sourceId, categoryId) {
if (!sourceId || !categoryId) {
this.recommendedSection?.classList.add('hidden');
return;
}
try {
const movies = await API.proxy.xtream.vodStreams(sourceId, categoryId);
if (!movies || movies.length === 0) {
this.recommendedSection?.classList.add('hidden');
return;
}
// Filter out current movie, take first 12
const filtered = movies
.filter(m => m.stream_id !== this.content?.id)
.slice(0, 12);
this.renderRecommendedGrid(filtered, sourceId);
} catch (e) {
console.error('Error loading recommended:', e);
this.recommendedSection?.classList.add('hidden');
}
}
renderRecommendedGrid(movies, sourceId) {
if (!this.recommendedGrid) return;
this.recommendedGrid.innerHTML = movies.map(movie => `
<div class="watch-recommended-card" data-id="${movie.stream_id}" data-source="${sourceId}">
<img src="${movie.stream_icon || movie.cover || '/img/placeholder.png'}"
alt="${movie.name}"
onerror="this.src='/img/placeholder.png'" loading="lazy">
<p>${movie.name}</p>
</div>
`).join('');
// Click handlers
this.recommendedGrid.querySelectorAll('.watch-recommended-card').forEach(card => {
card.addEventListener('click', () => this.playRecommendedMovie(card.dataset.id, parseInt(card.dataset.source)));
});
}
async playRecommendedMovie(streamId, sourceId) {
try {
// Fetch movie details
const movies = await API.proxy.xtream.vodStreams(sourceId);
const movie = movies?.find(m => m.stream_id == streamId);
if (!movie) return;
const container = movie.container_extension || 'mp4';
const result = await API.proxy.xtream.getStreamUrl(sourceId, streamId, 'movie', container);
if (result?.url) {
this.play({
type: 'movie',
id: movie.stream_id,
title: movie.name,
poster: movie.stream_icon || movie.cover,
description: movie.plot || '',
year: movie.year,
rating: movie.rating,
sourceId: sourceId,
categoryId: movie.category_id
}, result.url);
}
} catch (e) {
console.error('Error playing recommended movie:', e);
}
}
// === Series Episodes ===
renderEpisodes() {
if (!this.seriesInfo?.episodes || !this.seasonsContainer) return;
const seasons = Object.keys(this.seriesInfo.episodes).sort((a, b) => parseInt(a) - parseInt(b));
this.seasonsContainer.innerHTML = seasons.map(seasonNum => {
const episodes = this.seriesInfo.episodes[seasonNum];
const isCurrentSeason = parseInt(seasonNum) === parseInt(this.currentSeason);
return `
<div class="watch-season-group">
<div class="watch-season-header ${isCurrentSeason ? '' : 'collapsed'}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon">
<path d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6-6-6 1.41-1.41z"/>
</svg>
<span class="watch-season-name">Season ${seasonNum}</span>
<span class="watch-season-count">${episodes.length} episodes</span>
</div>
<div class="watch-episode-list">
${episodes.map(ep => {
const isActive = parseInt(seasonNum) === parseInt(this.currentSeason) &&
parseInt(ep.episode_num) === parseInt(this.currentEpisode);
return `
<div class="watch-episode-item ${isActive ? 'active' : ''}"
data-episode-id="${ep.id}"
data-season="${seasonNum}"
data-episode="${ep.episode_num}"
data-container="${ep.container_extension || 'mp4'}">
<span class="watch-episode-num">E${ep.episode_num}</span>
<span class="watch-episode-title">${ep.title || `Episode ${ep.episode_num}`}</span>
<span class="watch-episode-duration">${ep.duration || ''}</span>
</div>
`;
}).join('')}
</div>
</div>
`;
}).join('');
// Season header toggle
this.seasonsContainer.querySelectorAll('.watch-season-header').forEach(header => {
header.addEventListener('click', () => {
header.classList.toggle('collapsed');
});
});
// Episode click handlers
this.seasonsContainer.querySelectorAll('.watch-episode-item').forEach(ep => {
ep.addEventListener('click', () => this.playEpisodeFromList(ep));
});
}
async playEpisodeFromList(episodeEl) {
const episodeId = episodeEl.dataset.episodeId;
const seasonNum = episodeEl.dataset.season;
const episodeNum = episodeEl.dataset.episode;
const container = episodeEl.dataset.container || 'mp4';
try {
const result = await API.proxy.xtream.getStreamUrl(this.content.sourceId, episodeId, 'series', container);
if (result?.url) {
const episodeTitle = episodeEl.querySelector('.watch-episode-title')?.textContent || `Episode ${episodeNum}`;
this.play({
type: 'series',
id: episodeId,
title: this.content.title,
subtitle: `S${seasonNum} E${episodeNum} - ${episodeTitle}`,
poster: this.content.poster,
description: this.content.description,
year: this.content.year,
rating: this.content.rating,
sourceId: this.content.sourceId,
seriesId: this.content.seriesId,
seriesInfo: this.seriesInfo,
currentSeason: seasonNum,
currentEpisode: episodeNum
}, result.url);
}
} catch (e) {
console.error('Error playing episode:', e);
}
}
// === Next Episode ===
getNextEpisode() {
if (!this.seriesInfo?.episodes || !this.currentSeason || !this.currentEpisode) return null;
const seasons = Object.keys(this.seriesInfo.episodes).sort((a, b) => parseInt(a) - parseInt(b));
const currentSeasonEpisodes = this.seriesInfo.episodes[this.currentSeason] || [];
// Find next episode in current season
const currentEpIndex = currentSeasonEpisodes.findIndex(ep =>
parseInt(ep.episode_num) === parseInt(this.currentEpisode)
);
if (currentEpIndex >= 0 && currentEpIndex < currentSeasonEpisodes.length - 1) {
return {
...currentSeasonEpisodes[currentEpIndex + 1],
seasonNum: this.currentSeason
};
}
// Try next season
const currentSeasonIndex = seasons.indexOf(String(this.currentSeason));
if (currentSeasonIndex >= 0 && currentSeasonIndex < seasons.length - 1) {
const nextSeason = seasons[currentSeasonIndex + 1];
const nextSeasonEpisodes = this.seriesInfo.episodes[nextSeason];
if (nextSeasonEpisodes?.length > 0) {
return {
...nextSeasonEpisodes[0],
seasonNum: nextSeason
};
}
}
return null;
}
showNextEpisodePanel(nextEp) {
if (!this.nextEpisodePanel) return;
this.nextEpisodeTitle.textContent = `S${nextEp.seasonNum} E${nextEp.episode_num} - ${nextEp.title || `Episode ${nextEp.episode_num}`}`;
this.nextEpisodePanel.classList.remove('hidden');
this.nextEpisodePanel.nextEpisodeData = nextEp;
// Start countdown
this.nextEpisodeCountdown = 10;
this.nextCountdown.textContent = this.nextEpisodeCountdown;
this.nextEpisodeInterval = setInterval(() => {
this.nextEpisodeCountdown--;
this.nextCountdown.textContent = this.nextEpisodeCountdown;
if (this.nextEpisodeCountdown <= 0) {
this.playNextEpisode();
}
}, 1000);
}
async playNextEpisode() {
this.cancelNextEpisode();
const nextEp = this.nextEpisodePanel?.nextEpisodeData;
if (!nextEp) return;
try {
const container = nextEp.container_extension || 'mp4';
const result = await API.proxy.xtream.getStreamUrl(this.content.sourceId, nextEp.id, 'series', container);
if (result?.url) {
this.play({
type: 'series',
id: nextEp.id,
title: this.content.title,
subtitle: `S${nextEp.seasonNum} E${nextEp.episode_num} - ${nextEp.title || `Episode ${nextEp.episode_num}`}`,
poster: this.content.poster,
description: this.content.description,
year: this.content.year,
rating: this.content.rating,
sourceId: this.content.sourceId,
seriesId: this.content.seriesId,
seriesInfo: this.seriesInfo,
currentSeason: nextEp.seasonNum,
currentEpisode: nextEp.episode_num
}, result.url);
}
} catch (e) {
console.error('Error playing next episode:', e);
}
}
cancelNextEpisode() {
clearInterval(this.nextEpisodeInterval);
this.nextEpisodePanel?.classList.add('hidden');
if (this.nextEpisodePanel) {
this.nextEpisodePanel.nextEpisodeData = null;
}
}
// === Navigation ===
goBack() {
this.stop();
this.cancelNextEpisode();
// Navigate to the page we came from (stored in returnPage)
// We don't use history.back() because we used replaceHistory when navigating here
this.app.navigateTo(this.returnPage || 'movies');
}
show() {
// Called when page becomes visible
}
hide() {
// Called when page becomes hidden
this.stop();
this.cancelNextEpisode();
}
}
window.WatchPage = WatchPage;
+8 -9
View File
@@ -84,13 +84,12 @@ router.get('/xtream/:sourceId', async (req, res) => {
// Proxy auth check to upstream to ensure credentials are still valid
const cached = cache.get(`xtream:${source.id}:auth`);
const cached = cache.get('xtream', source.id, 'auth', 300000);
if (cached) return res.json(cached);
const api = xtreamApi.createFromSource(source);
const data = await api.authenticate();
cache.set(`xtream:${source.id}:auth`, data, 300); // 5 min cache
cache.set('xtream', source.id, 'auth', data);
res.json(data);
} catch (err) {
res.status(502).json({ error: 'Upstream error', details: err.message });
@@ -188,13 +187,13 @@ router.get('/xtream/:sourceId/series_info', async (req, res) => {
const seriesId = req.query.series_id;
if (!seriesId) return res.status(400).send('series_id required');
const cacheKey = `xtream:${source.id}:series_info:${seriesId}`;
const cached = cache.get(cacheKey);
const cacheKey = `series_info_${seriesId}`;
const cached = cache.get('xtream', source.id, cacheKey, 3600000);
if (cached) return res.json(cached);
const api = xtreamApi.createFromSource(source);
const data = await api.getSeriesInfo(seriesId);
cache.set(cacheKey, data, 3600); // 1 hour
cache.set('xtream', source.id, cacheKey, data);
res.json(data);
} catch (err) {
res.status(502).json({ error: 'Upstream error', details: err.message });
@@ -210,13 +209,13 @@ router.get('/xtream/:sourceId/vod_info', async (req, res) => {
const vodId = req.query.vod_id;
if (!vodId) return res.status(400).send('vod_id required');
const cacheKey = `xtream:${source.id}:vod_info:${vodId}`;
const cached = cache.get(cacheKey);
const cacheKey = `vod_info_${vodId}`;
const cached = cache.get('xtream', source.id, cacheKey, 3600000);
if (cached) return res.json(cached);
const api = xtreamApi.createFromSource(source);
const data = await api.getVodInfo(vodId);
cache.set(cacheKey, data, 3600); // 1 hour
cache.set('xtream', source.id, cacheKey, data);
res.json(data);
} catch (err) {
res.status(502).json({ error: 'Upstream error', details: err.message });
+21 -15
View File
@@ -25,34 +25,40 @@ router.get('/', (req, res) => {
const args = [
'-hide_banner',
'-loglevel', 'warning',
// Low-latency startup vs Reliability trade-off
// Increased to 5MB/10s to handle slow HLS manifests and streams with large headers
'-probesize', '5000000', // 5MB
'-analyzeduration', '10000000', // 10 seconds
// Faster startup - reduced probe/analyze for quicker first bytes
'-probesize', '2000000', // 2MB (reduced from 5MB)
'-analyzeduration', '3000000', // 3 seconds (reduced from 10s)
// Error resilience: generate timestamps, discard corrupt packets
'-fflags', '+genpts+discardcorrupt',
'-fflags', '+genpts+discardcorrupt+nobuffer',
// Ignore errors in stream and continue
'-err_detect', 'ignore_err',
// Limit max demux delay to prevent buffering issues with bad timestamps
'-max_delay', '5000000',
// Limit max demux delay to prevent buffering issues
'-max_delay', '2000000',
// Reconnect settings for network drops (useful for live streams)
'-reconnect', '1',
'-reconnect_streamed', '1',
'-reconnect_delay_max', '5',
'-reconnect_delay_max', '3',
'-i', url,
// Map only first video and audio stream (avoid subtitle streams causing issues)
'-map', '0:v:0',
'-map', '0:a:0?', // ? makes audio optional if not present
// Video: passthrough (no re-encoding = fast!)
'-c:v', 'copy',
// Audio: Transcode to browser-compatible AAC with consistent parameters
// Audio: Transcode to browser-compatible AAC
'-c:a', 'aac',
'-ac', '2', // Downmix to stereo (fixes 5.1 surround issues)
'-ar', '48000',
'-b:a', '256k', // Increased for surround sound
'-af', 'aresample=48000:async=1',
// Handle timestamp discontinuities at output
'-fps_mode', 'passthrough',
'-max_muxing_queue_size', '1024',
'-b:a', '192k',
// Handle async audio/video using async filter
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0',
// Timestamp handling
'-vsync', 'passthrough',
'-async', '1',
'-max_muxing_queue_size', '2048',
// Fragmented MP4 for streaming (browser-compatible)
'-f', 'mp4',
'-movflags', 'frag_keyframe+empty_moov+default_base_moof',
'-movflags', 'frag_keyframe+empty_moov+default_base_moof+faststart',
'-flush_packets', '1', // Send data immediately
'-' // Output to stdout
];