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