diff --git a/public/css/main.css b/public/css/main.css index d9b5231..8d59600 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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; + } } \ No newline at end of file diff --git a/public/index.html b/public/index.html index 10c9307..80a89fd 100644 --- a/public/index.html +++ b/public/index.html @@ -540,6 +540,161 @@ + + +
+ +
+ + + +
+ +
+ +
+

+

+
+
+ + + + + +
+
+ 0:00 + +
+
+ + + +
+ + + +
+
+ + +
+ Scroll for details + + + +
+
+ + + +
+ + +
+ + + + + + + + +
+
+ @@ -580,7 +735,8 @@ - + + \ No newline at end of file diff --git a/public/js/app.js b/public/js/app.js index 937127a..c17b725 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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 { Logout `; - + 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); } diff --git a/public/js/pages/MoviesPage.js b/public/js/pages/MoviesPage.js index cfef0f4..37b44b4 100644 --- a/public/js/pages/MoviesPage.js +++ b/public/js/pages/MoviesPage.js @@ -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) { diff --git a/public/js/pages/SeriesPage.js b/public/js/pages/SeriesPage.js index e53c9c6..7f01299 100644 --- a/public/js/pages/SeriesPage.js +++ b/public/js/pages/SeriesPage.js @@ -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) { diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js new file mode 100644 index 0000000..3555951 --- /dev/null +++ b/public/js/pages/WatchPage.js @@ -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 => ` + + `).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 ` +
+
+ + + + Season ${seasonNum} + ${episodes.length} episodes +
+
+ ${episodes.map(ep => { + const isActive = parseInt(seasonNum) === parseInt(this.currentSeason) && + parseInt(ep.episode_num) === parseInt(this.currentEpisode); + return ` +
+ E${ep.episode_num} + ${ep.title || `Episode ${ep.episode_num}`} + ${ep.duration || ''} +
+ `; + }).join('')} +
+
+ `; + }).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; diff --git a/server/routes/proxy.js b/server/routes/proxy.js index 45acecf..442b16c 100644 --- a/server/routes/proxy.js +++ b/server/routes/proxy.js @@ -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 }); diff --git a/server/routes/transcode.js b/server/routes/transcode.js index 5ff6e70..16182a4 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -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 ];