window.App = window.App || {}; App.player = App.player || {}; (function() { const state = App.state; // Playback heuristics for full-screen behavior on mobile/TV browsers. function isMobilePlayback() { if (navigator.userAgentData && typeof navigator.userAgentData.mobile === 'boolean') { return navigator.userAgentData.mobile; } const ua = navigator.userAgent || ''; if (/iPhone|iPad|iPod|Android/i.test(ua)) return true; return window.matchMedia('(pointer: coarse)').matches && window.matchMedia('(max-width: 900px)').matches; } function isTvPlayback() { const ua = navigator.userAgent || ''; return /SMART-TV|SmartTV|Smart TV|Internet\.TV|HbbTV|NetCast|Web0S|webOS|Tizen|AppleTV|Apple TV|GoogleTV|Android TV|AFTB|AFTS|AFTM|AFTT|AFTQ|AFTK|AFTN|AFTMM|AFTKR|Roku|DTV|BRAVIA|VIZIO|SHIELD|PhilipsTV|Hisense|VIDAA|TOSHIBA/i.test(ua); } function getMobileVideoHost() { let host = document.getElementById('mobile-video-host'); if (!host) { host = document.createElement('div'); host.id = 'mobile-video-host'; document.body.appendChild(host); } return host; } App.player.open = async function(source, opts) { const modal = document.getElementById('video-modal'); const video = document.getElementById('player'); const originEl = opts && opts.originEl ? opts.originEl : null; const clearLoading = () => { if (originEl) { originEl.classList.remove('is-loading'); } }; if (originEl) { originEl.classList.add('is-loading'); } if (!modal || !video) { clearLoading(); return; } const useMobileFullscreen = isMobilePlayback() || isTvPlayback(); if (!state.playerHome) { state.playerHome = video.parentElement; } // Resolve an ordered list of candidate sources (best first). When a // source's URL fails to load we fall back to the next one. let sources = []; if (App.videos && typeof App.videos.resolveStreamSources === 'function') { sources = App.videos.resolveStreamSources(source); } else { let resolved = { url: '', referer: '' }; if (App.videos && typeof App.videos.resolveStreamSource === 'function') { resolved = App.videos.resolveStreamSource(source); } else if (typeof source === 'string') { resolved.url = source; } else if (source && typeof source === 'object') { resolved.url = source.url || ''; } if (resolved.url) sources = [resolved]; } if (!sources.length) { if (App.ui && App.ui.showError) { App.ui.showError('Unable to play this stream.'); } clearLoading(); return; } // Expand the candidate sources into an ordered playback plan. When a // source has been proven (in the background) to play directly, try the // raw upstream URL first and keep the proxy as the immediate fallback; // unproven sources go straight through the proxy. const directProven = (url) => !!(App.videos && App.videos.isDirectProven && App.videos.isDirectProven(url)); const playbackPlan = []; sources.forEach((resolved) => { if (directProven(resolved.url)) { playbackPlan.push({ resolved, direct: true }); } playbackPlan.push({ resolved, direct: false }); }); if (useMobileFullscreen) { const host = getMobileVideoHost(); if (video.parentElement !== host) { host.appendChild(video); } state.playerMode = 'mobile'; video.removeAttribute('playsinline'); video.removeAttribute('webkit-playsinline'); video.playsInline = false; } else { if (state.playerHome && video.parentElement !== state.playerHome) { state.playerHome.appendChild(video); } state.playerMode = 'modal'; video.setAttribute('playsinline', ''); video.setAttribute('webkit-playsinline', ''); video.playsInline = true; } const requestFullscreen = () => { if (state.playerMode !== 'mobile') return; if (typeof video.webkitEnterFullscreen === 'function') { try { video.webkitEnterFullscreen(); } catch (err) { // Ignore if fullscreen is not allowed. } return; } if (video.requestFullscreen) { video.requestFullscreen().catch(() => {}); } }; const failPlayback = (message) => { clearLoading(); if (App.ui && App.ui.showError) { App.ui.showError(message); } App.player.close(); }; // Attempts to play a single source. On a fatal failure it advances to // the next candidate, or reports an error once the list is exhausted. const attempt = async (index) => { const entry = playbackPlan[index]; const resolved = entry.resolved; const hasNext = index + 1 < playbackPlan.length; let playbackStarted = false; let settled = false; // Advances to the next source (or fails) exactly once per attempt, // guarding against overlapping error callbacks. const advanceOrFail = (message) => { if (settled) return; settled = true; if (hasNext) { attempt(index + 1); } else { failPlayback(message); } }; // Proven-direct entries hit the upstream URL straight from the // browser; everything else is wrapped in the backend stream proxy. let streamUrl; if (entry.direct) { streamUrl = resolved.url; } else { streamUrl = App.videos.buildStreamUrlFromSource(resolved); } let isHls = /\.m3u8($|\?)/i.test(resolved.url); let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url); // Live cam streams resolve (server-side) to HLS; treat them as HLS up // front so we skip the content-type HEAD probe and go straight to it. if (resolved.isLive) { isHls = true; isDirectMedia = false; } // Drop the previous attempt's error handler and player instance // before rebinding so stale callbacks don't double-advance. video.onerror = null; if (state.hlsPlayer) { state.hlsPlayer.stopLoad(); state.hlsPlayer.detachMedia(); state.hlsPlayer.destroy(); state.hlsPlayer = null; } // Reset the video element before re-binding a new source. video.pause(); video.removeAttribute('src'); video.load(); if (!isHls && !entry.direct) { try { const headResp = await fetch(streamUrl, { method: 'HEAD' }); const contentType = headResp.headers.get('Content-Type') || ''; if (contentType.includes('application/vnd.apple.mpegurl')) { isHls = true; } else if (contentType.startsWith('video/') || contentType.startsWith('audio/')) { isDirectMedia = true; } } catch (err) { console.warn('Failed to detect stream type', err); } } const startPlayback = () => { if (playbackStarted) return; playbackStarted = true; clearLoading(); const playPromise = video.play(); if (playPromise && typeof playPromise.catch === 'function') { playPromise.catch(() => {}); } if (state.playerMode === 'mobile') { if (video.readyState >= 1) { requestFullscreen(); } else { video.addEventListener('loadedmetadata', requestFullscreen, { once: true }); } } }; // Confirmed direct media (mp4/webm/…) never needs hls.js; everything // else might, so pull it in now that the type has been sniffed. if (!window.Hls && (isHls || !isDirectMedia)) { try { await App.ensureHls(); } catch (err) { // Fall back to native playback below. } } const canUseHls = !!(window.Hls && window.Hls.isSupported()); const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl')); let hlsTried = false; let nativeTried = false; let usingHls = false; const startNative = () => { if (nativeTried) return; nativeTried = true; usingHls = false; video.src = streamUrl; startPlayback(); }; const startHls = (allowFallback) => { if (!canUseHls || hlsTried) return false; hlsTried = true; usingHls = true; state.hlsPlayer = new window.Hls(); state.hlsPlayer.loadSource(streamUrl); state.hlsPlayer.attachMedia(video); state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, function() { startPlayback(); }); startPlayback(); state.hlsPlayer.on(window.Hls.Events.ERROR, function(event, data) { if (data && data.fatal) { const shouldFallback = allowFallback && !nativeTried && !isHls; if (state.hlsPlayer) { state.hlsPlayer.destroy(); state.hlsPlayer = null; } if (shouldFallback) { startNative(); return; } advanceOrFail('Unable to play this stream.'); } }); return true; }; if (prefersHls) { if (!startHls(true)) { if (video.canPlayType('application/vnd.apple.mpegurl')) { startNative(); } else if (hasNext) { advanceOrFail('HLS is not supported in this browser.'); return; } else { console.error("HLS not supported in this browser."); failPlayback('HLS is not supported in this browser.'); return; } } } else { startNative(); } video.onerror = () => { if (!usingHls && canUseHls && !hlsTried && !isDirectMedia) { if (startHls(true)) return; } advanceOrFail('Video failed to load.'); }; }; attempt(0); if (state.playerMode === 'modal') { // Ambient backdrop: a blurred copy of the poster fills the letterbox // behind the contained video for a richer, less sterile player. const modalContent = modal.querySelector('.modal-content'); if (modalContent) { let poster = (source && (source.thumb || (source.meta && (source.meta.thumbnail || source.meta.thumb)))) || ''; if (!poster && originEl) { const img = originEl.querySelector('img'); if (img) poster = img.currentSrc || img.src || ''; } if (poster) modalContent.style.setProperty('--poster', `url("${poster.replace(/"/g, '%22')}")`); else modalContent.style.removeProperty('--poster'); } const reveal = () => { modal.style.display = 'flex'; }; const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (!reduce && document.startViewTransition) { document.startViewTransition(reveal); } else { reveal(); } document.body.style.overflow = 'hidden'; } else { modal.style.display = 'none'; document.body.style.overflow = 'auto'; if (!state.onFullscreenChange) { state.onFullscreenChange = () => { if (state.playerMode === 'mobile' && !document.fullscreenElement) { App.player.close(); } }; } document.addEventListener('fullscreenchange', state.onFullscreenChange); if (!state.onWebkitEndFullscreen) { state.onWebkitEndFullscreen = () => { if (state.playerMode === 'mobile') { App.player.close(); } }; } video.addEventListener('webkitendfullscreen', state.onWebkitEndFullscreen); } }; App.player.close = function() { const modal = document.getElementById('video-modal'); const video = document.getElementById('player'); if (!modal || !video) return; if (state.hlsPlayer) { state.hlsPlayer.destroy(); state.hlsPlayer = null; } if (document.fullscreenElement && document.exitFullscreen) { document.exitFullscreen().catch(() => {}); } if (state.onFullscreenChange) { document.removeEventListener('fullscreenchange', state.onFullscreenChange); } if (state.onWebkitEndFullscreen) { video.removeEventListener('webkitendfullscreen', state.onWebkitEndFullscreen); } video.onerror = null; video.pause(); video.src = ''; modal.style.display = 'none'; document.body.style.overflow = 'auto'; if (state.playerHome && video.parentElement !== state.playerHome) { state.playerHome.appendChild(video); } state.playerMode = 'modal'; }; })();