/** * 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 = ` 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 = ''; 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 += ``; } } // 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 => ` `).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() { // 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;