Files
KiraTV-APP-NodeCast-Modific…/public/js/pages/WatchPage.js
T
2026-07-13 15:20:15 +00:00

1671 lines
64 KiB
JavaScript

/**
* 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');
// iOS: ensure inline playback (not fullscreen by default)
if (this.video) {
this.video.setAttribute('playsinline', '');
this.video.setAttribute('webkit-playsinline', '');
}
// 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');
this.loadingSpinner = document.getElementById('watch-loading');
// 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');
// Captions
this.captionsBtn = document.getElementById('watch-captions-btn');
this.captionsMenu = document.getElementById('watch-captions-menu');
this.captionsList = document.getElementById('watch-captions-list');
// Transcode Status
this.transcodeStatusEx = document.getElementById('watch-transcode-status');
this.qualityBadgeEl = document.getElementById('watch-quality-badge');
// 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;
this.captionsMenuOpen = false;
this.sourceStreamUrl = null;
this.knownDurationSec = 0;
this.playbackBaseOffsetSec = 0;
this.isSessionMode = false;
this._heartbeatTimer = null;
// Overlay timer
this.overlayTimeout = null;
this.overlayVisible = true;
// Next episode
this.nextEpisodeTimeout = null;
this.nextEpisodeCountdown = 10;
this.nextEpisodeInterval = null;
this.nextEpisodeShowing = false;
this.nextEpisodeDismissed = false;
// Watch history
this.historyInterval = null;
this.init();
}
init() {
// iOS Safari: detect and compensate for floating bottom toolbar
const updateIosUiBottom = () => {
let uiBottom = 0;
if (window.visualViewport) {
const vv = window.visualViewport;
uiBottom = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
}
document.documentElement.style.setProperty('--ios-ui-bottom', uiBottom + 'px');
};
updateIosUiBottom();
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', updateIosUiBottom);
window.visualViewport.addEventListener('scroll', updateIosUiBottom);
} else {
window.addEventListener('resize', updateIosUiBottom);
}
// iOS: use custom --vh unit to avoid 100vh issues with dynamic toolbar
const isIOS = /iP(hone|ad|od)/.test(navigator.userAgent);
const watchVideoSection = document.querySelector('.watch-video-section');
if (isIOS && watchVideoSection) {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
watchVideoSection.style.height = 'calc(var(--vh) * 100)';
}
// Apply safe area + iOS toolbar padding to overlay
if (this.overlay) {
this.overlay.style.paddingBottom = 'calc(env(safe-area-inset-bottom, 0px) + var(--ios-ui-bottom, 0px) + 12px)';
}
// 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());
// Picture-in-Picture
const pipBtn = document.getElementById('watch-pip');
pipBtn?.addEventListener('click', () => this.togglePictureInPicture());
// Overflow Menu
const overflowBtn = document.getElementById('watch-overflow');
const overflowMenu = document.getElementById('watch-overflow-menu');
overflowBtn?.addEventListener('click', (e) => {
e.stopPropagation();
overflowMenu?.classList.toggle('hidden');
});
// Copy Stream URL
const copyUrlBtn = document.getElementById('watch-copy-url');
copyUrlBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.copyStreamUrl();
overflowMenu?.classList.add('hidden');
});
// Close overflow menu when clicking outside
document.addEventListener('click', (e) => {
if (overflowMenu && !overflowMenu.classList.contains('hidden') &&
!overflowMenu.contains(e.target) && e.target !== overflowBtn) {
overflowMenu.classList.add('hidden');
}
});
// 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));
this.video?.addEventListener('waiting', () => this.showLoading());
this.video?.addEventListener('canplay', () => this.hideLoading());
// Overlay auto-hide + click to toggle play
const watchSection = document.querySelector('.watch-video-section');
watchSection?.addEventListener('mousemove', () => this.showOverlay());
watchSection?.addEventListener('touchstart', () => this.showOverlay());
watchSection?.addEventListener('click', (e) => {
this.showOverlay();
// Only toggle play if clicking on video area (not controls)
if (e.target === this.video || e.target === watchSection ||
e.target.classList.contains('watch-overlay') || e.target === this.overlay) {
this.togglePlay();
}
});
// 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());
// Captions toggle
this.captionsBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleCaptionsMenu();
});
// Close captions menu when clicking outside
document.addEventListener('click', (e) => {
if (this.captionsMenuOpen && !this.captionsMenu?.contains(e.target) && e.target !== this.captionsBtn) {
this.closeCaptionsMenu();
}
});
// 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');
}
});
// On page close/refresh: stop heartbeat so the 3-min idle timeout kicks in fast
window.addEventListener('beforeunload', () => {
this._stopHeartbeat();
});
// Also stop session when the page becomes hidden (tab switch, minimize)
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
this._stopHeartbeat();
} else if (this.currentSessionId) {
this._startHeartbeat(this.currentSessionId);
}
});
}
/**
* 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.resumeTime = content.resumeTime || 0;
this.containerExtension = content.containerExtension || 'mp4';
this.knownDurationSec = this.parseDurationSeconds(content.durationSec || content.duration || content.runtime || '');
this.playbackBaseOffsetSec = 0;
this.isSessionMode = false;
this.returnPage = content.type === 'movie' ? 'movies' : 'series';
// Stop any Live TV playback before starting movie/series
this.app?.player?.stop?.();
// Reset state
this.cancelNextEpisode();
this.nextEpisodeDismissed = false;
// 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 || '';
if (this.timeTotal) {
this.timeTotal.textContent = this.knownDurationSec > 0 ? this.formatTime(this.knownDurationSec) : '0:00';
}
// If duration is missing, fetch it from vod_info in the background
if (this.knownDurationSec === 0 && content.sourceId && (content.vodId || content.id)) {
this._fetchVodDuration(content.sourceId, content.vodId || content.id);
}
// Load video
await this.loadVideo(streamUrl);
// Show Now Playing indicator in navbar
this.showNowPlaying(content.title);
// 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();
// Start watch history tracking
this.startHistoryTracking();
}
/**
* Show Now Playing indicator in navbar
*/
showNowPlaying(title) {
const indicator = document.getElementById('now-playing-indicator');
const textEl = document.getElementById('now-playing-text');
if (indicator && textEl) {
textEl.textContent = title || 'Reproduciendo';
indicator.classList.remove('hidden');
}
}
/**
* Hide Now Playing indicator in navbar
*/
hideNowPlaying() {
const indicator = document.getElementById('now-playing-indicator');
if (indicator) {
indicator.classList.add('hidden');
}
}
/**
* Start a HLS transcode session
*/
async startTranscodeSession(url, options = {}) {
try {
console.log('[WatchPage] Starting HLS transcode session...', options);
const res = await fetch('/api/transcode/session', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url,
seekOffset: this.resumeTime, // Pass resume point to backend
...options
})
});
if (!res.ok) throw new Error('Failed to start session');
const session = await res.json();
this.currentSessionId = session.sessionId;
this.isSessionMode = true;
this._startHeartbeat(session.sessionId);
// Use server-probed duration if we don't already have one
if (session.durationSec > 0 && this.knownDurationSec === 0) {
this.knownDurationSec = session.durationSec;
console.log(`[WatchPage] Duration from FFprobe: ${session.durationSec}s`);
if (this.timeTotal) {
this.timeTotal.textContent = this.formatTime(session.durationSec);
}
}
return session.playlistUrl;
} catch (err) {
console.error('[WatchPage] Session start failed:', err);
this.isSessionMode = true; // Still session-like for duration purposes
return `/api/transcode?url=${encodeURIComponent(url)}`;
}
}
/**
* Stop and cleanup current transcode session
*/
async stopTranscodeSession() {
this._stopHeartbeat();
if (this.currentSessionId) {
console.log('[WatchPage] Stopping transcode session:', this.currentSessionId);
const sid = this.currentSessionId;
this.currentSessionId = null;
try {
// sendBeacon works even when the page is being unloaded
if (navigator.sendBeacon) {
navigator.sendBeacon(`/api/transcode/${sid}/stop`, '{}');
}
// Also DELETE (for normal navigation)
fetch(`/api/transcode/${sid}`, { method: 'DELETE' }).catch(() => {});
} catch (err) {
console.error('Failed to stop session:', err);
}
}
}
async updateTranscodeStatus(mode, text) {
if (!this.transcodeStatusEx) return;
this.transcodeStatusEx.className = 'transcode-status'; // Reset classes
if (mode === 'hidden') {
this.transcodeStatusEx.classList.add('hidden');
return;
}
this.transcodeStatusEx.textContent = text || mode;
this.transcodeStatusEx.classList.add(mode);
// Ensure it's visible
this.transcodeStatusEx.classList.remove('hidden');
}
/**
* Get quality label from video height
*/
getQualityLabel(height) {
if (height >= 2160) return '4K';
if (height >= 1440) return '1440p';
if (height >= 1080) return '1080p';
if (height >= 720) return '720p';
if (height >= 480) return '480p';
if (height > 0) return `${height}p`;
return null;
}
/**
* Update quality badge display
*/
updateQualityBadge() {
if (!this.qualityBadgeEl) return;
if (this.currentStreamInfo?.height > 0) {
this.qualityBadgeEl.textContent = this.getQualityLabel(this.currentStreamInfo.height);
this.qualityBadgeEl.classList.remove('hidden');
} else {
this.qualityBadgeEl.classList.add('hidden');
}
}
async loadVideo(url) {
// Store the URL for copy functionality
this.currentUrl = url;
this.sourceStreamUrl = url;
// Stop any existing playback
this.stop();
// Show loading spinner
this.showLoading();
if (typeof window.kiratvPlayVodInNativePlayer === 'function' && window.kiratvDesktop?.isDesktopApp) {
this.hideLoading();
const absUrl = window.kiratvAbsolutePlaybackUrl(url);
try {
const result = await window.kiratvPlayVodInNativePlayer(absUrl);
if (!result?.ok) {
console.error('[KiraTV Desktop] VOD mpv:', result?.error);
this.updateTranscodeStatus('hidden');
} else {
this.updateTranscodeStatus('direct', 'mpv nativo');
}
} catch (e) {
console.error('[KiraTV Desktop] VOD', e);
}
return;
}
// 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 0: Auto Transcode (Smart) - probe first, then decide
if (settings.autoTranscode) {
console.log('[WatchPage] Auto Transcode enabled. Probing stream...');
try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
const info = await probeRes.json();
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
// Store early probe info for quality display
this.currentStreamInfo = info;
this.updateQualityBadge();
if (info.needsTranscode || settings.upscaleEnabled) {
console.log(`[WatchPage] Auto: Using HLS transcode session (${settings.upscaleEnabled ? 'Upscaling' : 'Incompatible audio/video'})`);
// Heuristic: If video is h264/compat, copy video. Usage: Audio fix.
// BUT: If upscaling is enabled, we MUST encode.
const videoMode = (info.video && info.video.includes('h264') && !settings.upscaleEnabled) ? 'copy' : 'encode';
const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : (settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)');
const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding';
this.updateTranscodeStatus(statusMode, statusText);
const playlistUrl = await this.startTranscodeSession(url, {
videoMode,
seekOffset: this.resumeTime, // Ensure seekOffset is passed
videoCodec: info.video,
audioCodec: info.audio,
audioChannels: info.audioChannels
});
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
} else if (info.needsRemux) {
// Remux (container swap) currently doesn't use session logic, uses direct stream
// TODO: Move remux to session logic if seeking is needed for TS files
console.log('[WatchPage] Auto: Using remux (.ts container)');
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
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;
}
// Compatible - fall through to normal playback
console.log('[WatchPage] Auto: Using normal playback (compatible)');
} catch (err) {
console.warn('[WatchPage] Probe failed:', err.message);
const vodLike =
isDirectVideo ||
url.includes('/movie/') ||
url.includes('/series/');
if (vodLike) {
console.warn(
'[WatchPage] Auto: VOD-like URL after probe failure — HLS encode fallback (evita reproducir remux Jellyfin sin transcodificar)'
);
this.updateTranscodeStatus('transcoding', 'Transcoding (auto, probe fallback)');
try {
const playlistUrl = await this.startTranscodeSession(url, {
videoMode: 'encode',
seekOffset: this.resumeTime,
});
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
} catch (fallbackErr) {
console.error('[WatchPage] Encode fallback failed:', fallbackErr);
}
}
// Live u otro contenido: seguir reproducción directa habitual
}
}
// Priority 1: Force Video Transcode (Full) or Upscaling
if (settings.forceVideoTranscode || settings.upscaleEnabled) {
const statusText = settings.upscaleEnabled ? 'Upscaling' : 'Transcoding (Video)';
const statusMode = settings.upscaleEnabled ? 'upscaling' : 'transcoding';
console.log(`[WatchPage] ${statusText} enabled. Starting session (encode)...`);
this.updateTranscodeStatus(statusMode, statusText);
const playlistUrl = await this.startTranscodeSession(url, {
videoMode: 'encode',
seekOffset: this.resumeTime
});
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
}
if (settings.forceTranscode) {
console.log('[WatchPage] Force Audio Transcode enabled. Starting session (copy)...');
this.updateTranscodeStatus('transcoding', 'Transcoding (Audio)');
// Probe to get video codec for HEVC tag handling
let videoCodec = 'unknown';
try {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
const info = await probeRes.json();
videoCodec = info.video;
} catch (e) { console.warn('Probe failed for force audio, assuming h264'); }
const playlistUrl = await this.startTranscodeSession(url, {
videoMode: 'copy',
videoCodec,
seekOffset: this.resumeTime
});
this.playbackBaseOffsetSec = this.resumeTime || 0;
this.playHls(playlistUrl);
this.setVolumeFromStorage();
return;
}
// Priority 2: Force Remux for raw TS streams
if (settings.forceRemux && isRawTs) {
console.log('[WatchPage] Force Remux enabled');
this.updateTranscodeStatus('remuxing', 'Remux (Force)');
const finalUrl = `/api/remux?url=${encodeURIComponent(url)}`;
this.video.src = finalUrl;
this.playbackBaseOffsetSec = 0;
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.updateTranscodeStatus('direct', 'Direct HLS');
this.playbackBaseOffsetSec = 0;
this.playHls(finalUrl);
} else {
// Direct playback for mp4/mkv/avi
this.updateTranscodeStatus('direct', 'Direct Play');
this.playbackBaseOffsetSec = 0;
this.video.src = finalUrl;
this.video.play().catch(e => {
if (e.name !== 'AbortError') console.error('[WatchPage] Autoplay error:', e);
});
}
this.setVolumeFromStorage();
}
/**
* Play HLS stream using Hls.js
*/
playHls(url) {
if (this.hls) {
this.hls.destroy();
}
this.hls = new Hls({
maxBufferLength: 30,
maxMaxBufferLength: 60,
startLevel: -1,
enableWorker: true,
});
this.hls.loadSource(url);
this.hls.attachMedia(this.video);
// Listen for subtitle track updates
this.hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (event, data) => {
console.log('[WatchPage] Subtitle tracks updated:', data.subtitleTracks);
// Wait a moment for native text tracks to populate
setTimeout(() => this.updateCaptionsTracks(), 100);
});
this.hls.on(Hls.Events.SUBTITLE_TRACK_SWITCH, (event, data) => {
console.log('[WatchPage] Subtitle track switched:', data);
});
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 (only if not already proxied/transcoded)
// Note: Transcoded streams are local, so no CORS issues usually
if (!url.startsWith('/api/') && (data.type === Hls.ErrorTypes.NETWORK_ERROR)) {
console.log('[WatchPage] Retrying via proxy...');
this.playHls(`/api/proxy/stream?url=${encodeURIComponent(this.currentUrl)}`);
} else {
this.hls.destroy();
}
}
});
}
setVolumeFromStorage() {
const savedVolume = localStorage.getItem('kiratv-volume') || '80';
this.video.volume = parseInt(savedVolume) / 100;
if (this.volumeSlider) this.volumeSlider.value = savedVolume;
}
stop() {
// Stop history tracking and save final progress
this.stopHistoryTracking();
this.saveProgress();
if (typeof window !== 'undefined' && window.kiratvDesktop?.stopExternalMpv) {
window.kiratvDesktop.stopExternalMpv().catch(() => {});
}
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
window.kiratvDesktopClearEmbeddedChrome();
}
// Cleanup transcode session if exists
this.stopTranscodeSession();
this.updateTranscodeStatus('hidden');
// Hide quality badge
this.currentStreamInfo = null;
if (this.qualityBadgeEl) {
this.qualityBadgeEl.classList.add('hidden');
}
if (this.hls) {
this.hls.destroy();
this.hls = null;
}
if (this.video) {
this.video.pause();
// En modo escritorio el vídeo va a mpv: limpiar src dispararía un error
// inofensivo pero confuso (MEDIA_ELEMENT_ERROR: Empty src attribute).
if (!window.kiratvDesktop?.isDesktopApp) {
this.video.src = '';
this.video.load();
}
}
this.hideNowPlaying();
}
// === Playback Controls ===
togglePlay() {
if (this.video.paused) {
this.video.play().catch(console.error);
} else {
this.video.pause();
}
}
skip(seconds) {
if (!this.video) return;
const totalDuration = this.getTotalDuration();
const currentAbsolute = this.getAbsoluteCurrentTime();
if (totalDuration > 0) {
const target = Math.max(0, Math.min(currentAbsolute + seconds, totalDuration));
this.seekToAbsolute(target);
} else {
this.video.currentTime = Math.max(0, this.video.currentTime + seconds);
}
}
seek(percent) {
const totalDuration = this.getTotalDuration();
if (!this.video || !totalDuration) return;
const target = (percent / 100) * totalDuration;
this.seekToAbsolute(target);
}
seekToAbsolute(targetSec) {
if (!this.video) return;
if (this.isSessionMode && this.sourceStreamUrl) {
// Session-based HLS: the playlist only covers a window from the last seekOffset.
// Re-create the session from the new target position.
const offset = Math.max(0, Math.floor(targetSec));
this.playbackBaseOffsetSec = offset;
this.resumeTime = offset; // startTranscodeSession reads this for seekOffset
this.loadVideo(this.sourceStreamUrl);
return;
}
this.video.currentTime = Math.max(0, targetSec);
}
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('kiratv-volume', value);
this.updateVolumeUI();
}
}
toggleFullscreen() {
const container = document.querySelector('.watch-video-section');
const isFullscreen = document.fullscreenElement || document.webkitFullscreenElement;
if (isFullscreen) {
if (document.exitFullscreen) {
document.exitFullscreen();
} else if (document.webkitExitFullscreen) {
document.webkitExitFullscreen();
}
} else {
if (container?.requestFullscreen) {
container.requestFullscreen();
} else if (container?.webkitRequestFullscreen) {
container.webkitRequestFullscreen();
} else if (this.video?.webkitEnterFullscreen) {
// iOS Safari: use native video fullscreen
this.video.webkitEnterFullscreen();
}
}
}
async togglePictureInPicture() {
try {
// Standard PiP API (Chrome, Edge, Firefox)
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
} else if (document.pictureInPictureEnabled && this.video.readyState >= 2) {
await this.video.requestPictureInPicture();
}
// Safari fallback using webkitPresentationMode
else if (typeof this.video.webkitSetPresentationMode === 'function') {
const mode = this.video.webkitPresentationMode;
this.video.webkitSetPresentationMode(mode === 'picture-in-picture' ? 'inline' : 'picture-in-picture');
}
} catch (err) {
if (err.name !== 'NotAllowedError') {
console.error('Picture-in-Picture error:', err);
}
}
}
/**
* Copy current stream URL to clipboard
*/
copyStreamUrl() {
if (!this.currentUrl) {
console.warn('[WatchPage] No stream URL to copy');
return;
}
let streamUrl = this.currentUrl;
// If it's a relative URL, make it absolute
if (streamUrl.startsWith('/')) {
streamUrl = window.location.origin + streamUrl;
}
const showPromptFallback = () => {
prompt('Copy this URL:', streamUrl);
};
// navigator.clipboard is only available in secure contexts (HTTPS/localhost)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(streamUrl).then(() => {
// Show brief feedback
const btn = document.getElementById('watch-copy-url');
if (btn) {
btn.textContent = '✓ Copied!';
setTimeout(() => {
btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/></svg> Copy Stream URL`;
}, 1500);
}
console.log('[WatchPage] Stream URL copied:', streamUrl);
}).catch(() => {
showPromptFallback();
});
} else {
// Fallback for insecure contexts (HTTP)
showPromptFallback();
}
}
// === UI Updates ===
updateProgress() {
if (!this.video) return;
const totalDuration = this.getTotalDuration();
if (!totalDuration) return;
const currentAbs = this.getAbsoluteCurrentTime();
const percent = (currentAbs / totalDuration) * 100;
this.progressSlider.value = Math.max(0, Math.min(100, percent));
this.timeCurrent.textContent = this.formatTime(currentAbs);
// Show "Up Next" panel early for series (like streaming services do during credits)
// Only show if auto-play next episode is enabled
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing && !this.nextEpisodeDismissed) {
const duration = totalDuration;
const currentTime = currentAbs;
// Only proceed if we have reliable duration data
if (isFinite(duration) && duration >= 180 && currentTime >= 120) {
const timeRemaining = duration - currentTime;
const creditsThreshold = 10; // seconds before end to show "Up Next"
if (timeRemaining <= creditsThreshold && timeRemaining > 0) {
const nextEp = this.getNextEpisode();
if (nextEp) {
this.nextEpisodeShowing = true;
this.showNextEpisodePanel(nextEp);
}
}
}
}
}
onMetadataLoaded() {
// Detect resolution
if (this.video && this.video.videoHeight > 0) {
this.currentStreamInfo = {
width: this.video.videoWidth,
height: this.video.videoHeight
};
this.updateQualityBadge();
}
// Handle resumption (only for direct video, NOT session mode).
// In session mode FFmpeg already starts from the seekOffset, so
// video.currentTime=0 already represents the correct absolute position.
if (!this.isSessionMode && this.resumeTime > 0 && this.video) {
const duration = this.video.duration;
// Only resume if not near the end (95%)
if (!duration || this.resumeTime < duration * 0.95) {
console.log(`[WatchPage] Resuming at ${this.resumeTime}s`);
this.video.currentTime = this.resumeTime;
}
this.resumeTime = 0; // Reset after use
} else if (this.isSessionMode) {
this.resumeTime = 0; // Clear so it's not applied again
}
// Update total duration label using known VOD runtime when browser reports Infinity/NaN
const totalDuration = this.getTotalDuration();
if (this.timeTotal && totalDuration > 0) {
this.timeTotal.textContent = this.formatTime(totalDuration);
}
}
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 not already showing and auto-play is enabled
const autoPlayEnabled = this.app?.player?.settings?.autoPlayNextEpisode;
if (autoPlayEnabled && this.contentType === 'series' && this.seriesInfo && !this.nextEpisodeShowing) {
const nextEp = this.getNextEpisode();
if (nextEp) {
this.nextEpisodeShowing = true;
this.showNextEpisodePanel(nextEp);
}
}
}
onError(e) {
const error = this.video?.error;
if (!error || !error.code) return;
// Ignorar error por src vacío (ocurre al limpiar el elemento tras parar)
if (error.code === 4 && (!this.video.src || this.video.src === window.location.href)) return;
console.error('[WatchPage] Video error:', error.code, error.message);
}
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')}`;
}
parseDurationSeconds(value) {
if (!value) return 0;
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
const str = String(value).trim();
if (!str) return 0;
// HH:MM:SS or MM:SS (allow any number of digits in the first segment)
if (/^\d+:\d{1,2}(:\d{1,2})?$/.test(str)) {
const parts = str.split(':').map(n => parseInt(n, 10));
if (parts.length === 3) return parts[0] * 3600 + parts[1] * 60 + parts[2];
if (parts.length === 2) return parts[0] * 60 + parts[1];
}
// Plain seconds string
const asNum = parseInt(str, 10);
return Number.isFinite(asNum) ? Math.max(0, asNum) : 0;
}
_startHeartbeat(sessionId) {
this._stopHeartbeat();
// Ping every 90 seconds — well within the 3-minute idle timeout on the worker
this._heartbeatTimer = setInterval(async () => {
if (!sessionId) return;
try {
await fetch(`/api/transcode/${sessionId}/heartbeat`, { method: 'POST' });
} catch (_) { /* ignore */ }
}, 90_000);
}
_stopHeartbeat() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
}
/**
* Fetch VOD/episode duration from the Xtream vod_info endpoint in the background.
* Updates knownDurationSec and the UI time label once the response arrives.
*/
async _fetchVodDuration(sourceId, vodId) {
try {
const res = await fetch(`/api/proxy/xtream/${sourceId}/vod_info?vod_id=${vodId}`);
if (!res.ok) return;
const data = await res.json();
const raw = data?.info?.duration_secs
|| data?.info?.duration
|| data?.movie_data?.duration_secs
|| data?.info?.runtime;
const secs = this.parseDurationSeconds(raw);
if (secs > 0 && secs > this.knownDurationSec) {
console.log(`[WatchPage] VOD duration from vod_info: ${secs}s`);
this.knownDurationSec = secs;
if (this.timeTotal) {
this.timeTotal.textContent = this.formatTime(secs);
}
}
} catch (e) {
// Non-critical — silently ignore
}
}
getTotalDuration() {
// In session mode (HLS generated by our worker), video.duration only
// reflects the segments generated so far, NOT the full movie length.
// Always prefer the known VOD duration in this case.
if (this.isSessionMode && this.knownDurationSec > 0) {
return this.knownDurationSec;
}
const mediaDuration = this.video?.duration;
if (Number.isFinite(mediaDuration) && mediaDuration > 0) {
const total = mediaDuration + (this.playbackBaseOffsetSec || 0);
// Even outside session mode, pick whichever is larger
return (this.knownDurationSec > total) ? this.knownDurationSec : total;
}
return this.knownDurationSec || 0;
}
getAbsoluteCurrentTime() {
if (!this.video) return 0;
return (this.playbackBaseOffsetSec || 0) + (this.video.currentTime || 0);
}
// === Loading Spinner ===
showLoading() {
this.loadingSpinner?.classList.add('show');
this.centerPlayBtn?.classList.remove('show');
}
hideLoading() {
this.loadingSpinner?.classList.remove('show');
}
// === Captions ===
toggleCaptionsMenu() {
if (this.captionsMenuOpen) {
this.closeCaptionsMenu();
} else {
this.updateCaptionsTracks();
this.captionsMenu?.classList.remove('hidden');
this.captionsMenuOpen = true;
}
}
closeCaptionsMenu() {
this.captionsMenu?.classList.add('hidden');
this.captionsMenuOpen = false;
}
updateCaptionsTracks() {
if (!this.captionsList || !this.video) return;
// Build list of available text tracks
const tracks = this.video.textTracks;
let html = '<button class="captions-option" data-index="-1">Off</button>';
for (let i = 0; i < tracks.length; i++) {
const track = tracks[i];
if (track.kind === 'subtitles' || track.kind === 'captions') {
const label = track.label || track.language || `Track ${i + 1}`;
const isActive = track.mode === 'showing';
html += `<button class="captions-option ${isActive ? 'active' : ''}" data-index="${i}">${label}</button>`;
}
}
// Check if any track is active, if not mark "Off" as active
let anyActive = false;
for (let i = 0; i < tracks.length; i++) {
if (tracks[i].mode === 'showing') anyActive = true;
}
if (!anyActive) {
html = html.replace('class="captions-option"', 'class="captions-option active"');
}
this.captionsList.innerHTML = html;
// Add click handlers
this.captionsList.querySelectorAll('.captions-option').forEach(btn => {
btn.addEventListener('click', () => this.selectCaptionTrack(parseInt(btn.dataset.index)));
});
}
selectCaptionTrack(index) {
if (!this.video) return;
const tracks = this.video.textTracks;
// Disable all tracks
for (let i = 0; i < tracks.length; i++) {
tracks[i].mode = 'hidden';
}
// Enable selected track
if (index >= 0 && index < tracks.length) {
tracks[index].mode = 'showing';
}
// Update UI
this.updateCaptionsTracks();
this.closeCaptionsMenu();
}
// === 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;
const isChannel = this.content.type === 'channel' || !this.content.type; // Default to channel if unknown
const fallback = isChannel ? '/img/placeholder.png' : '/img/poster-placeholder.jpg';
this.posterEl.onerror = () => {
this.posterEl.onerror = null;
this.posterEl.src = fallback;
};
this.posterEl.src = this.content.poster || fallback;
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.onerror=null;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() {
// Save next episode data BEFORE canceling (cancel clears the data)
const nextEp = this.nextEpisodePanel?.nextEpisodeData;
this.cancelNextEpisode();
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');
this.nextEpisodeShowing = false;
this.nextEpisodeDismissed = true; // Prevent re-triggering
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
// Don't stop playback here - allow background playback
this.cancelNextEpisode();
}
// ============================================================
// Watch History Tracking
// ============================================================
startHistoryTracking() {
this.stopHistoryTracking(); // Clear existing if any
this.historyInterval = setInterval(() => this.saveProgress(), 10000); // 10s
}
stopHistoryTracking() {
if (this.historyInterval) {
clearInterval(this.historyInterval);
this.historyInterval = null;
}
}
async saveProgress() {
if (!this.content || !this.video || this.video.paused) return;
const progress = Math.floor(this.video.currentTime);
const duration = Math.floor(this.video.duration);
if (isNaN(progress) || isNaN(duration) || duration <= 0) return;
try {
const data = {
title: this.content.title || 'Unknown Title',
subtitle: this.content.subtitle || (this.content.type === 'movie' ? 'Película' : 'Serie'),
poster: this.content.poster,
sourceId: this.content.sourceId,
containerExtension: this.containerExtension,
// Series-specific fields for next episode functionality
seriesId: this.content.seriesId || null,
currentSeason: this.currentSeason || null,
currentEpisode: this.currentEpisode || null
};
await window.API.request('POST', '/history', {
id: this.content.id,
type: this.content.type === 'movie' ? 'movie' : 'episode',
sourceId: this.content.sourceId,
progress,
duration,
data
});
} catch (err) {
console.warn('[History] Failed to save progress:', err);
}
}
}
window.WatchPage = WatchPage;