Added streaming service style watch page for movies and series playback
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user