window.App = window.App || {}; App.player = App.player || {}; // Fully custom fullscreen video player. There is a single player state (no // separate "windowed" mode): opening any video fills the viewport with a // fixed-position overlay ("fake fullscreen" — a CSS overlay rather than the // real Fullscreen API, so the same HUD/gesture code works identically on // desktop, Android and iOS, where native fullscreen video can't host custom // HTML controls). (function() { const state = App.state; const HUD_IDLE_MS = 2500; const DISMISS_THRESHOLD_PX = 90; // Module-local player state, separate from App.state (which other // modules read/write for unrelated things). const cp = { container: null, video: null, formatOverride: null, // manually chosen fmt object, or null (auto) cleanups: [], historyPushed: false, idleTimer: null, originEl: null, hudHovered: false, // mouse resting on the controls (desktop) activeUrl: '', // media URL actually playing, for the format menu's tick attemptToken: 0, // bumps on every open()/format switch to void stale async callbacks fetchAbort: null // aborts the current attempt's own requests }; // Stops everything the current attempt has in flight. The token guards keep // stale *callbacks* from acting, but they don't stop the requests those // callbacks were waiting on: hls.js goes on pulling segments through the // proxy, the media element keeps its connection open, and the content-type // sniff keeps a whole upstream fetch alive on the server. When an attempt is // superseded -- most of all when the direct route wins the race and the // proxy has nothing left to do -- that work is pure waste at both ends. function cancelInFlight() { if (cp.fetchAbort) { cp.fetchAbort.abort(); cp.fetchAbort = null; } if (state.hlsPlayer) { state.hlsPlayer.stopLoad(); state.hlsPlayer.detachMedia(); state.hlsPlayer.destroy(); state.hlsPlayer = null; } const video = cp.video; if (video) { video.onerror = null; video.pause(); // Dropping the source is what closes the connection the media // element is holding; load() makes the element let go of it now // rather than whenever it next feels like it. video.removeAttribute('src'); video.load(); } } const addCleanup = (fn) => cp.cleanups.push(fn); const runCleanups = () => { cp.cleanups.forEach((fn) => { try { fn(); } catch (err) { /* ignore */ } }); cp.cleanups = []; }; const q = (selector) => cp.container ? cp.container.querySelector(selector) : null; // --------------------------------------------------------------------- // DOM construction // --------------------------------------------------------------------- function buildContainer() { let container = document.getElementById('custom-player'); if (!container) { container = document.createElement('div'); container.id = 'custom-player'; document.body.appendChild(container); } container.className = 'custom-player'; container.innerHTML = `

0:00
0:00
`; return container; } // --------------------------------------------------------------------- // Title marquee. Only one title is on screen here, so unlike the grid it // always scrolls when it overflows -- there's nothing to pick between. // --------------------------------------------------------------------- function measureTitle() { App.marquee.measure(q('.cp-title'), q('.cp-title-text')); } // --------------------------------------------------------------------- // HUD auto-hide (TikTok-style fade on inactivity) // --------------------------------------------------------------------- function clearIdleTimer() { if (cp.idleTimer) { clearTimeout(cp.idleTimer); cp.idleTimer = null; } } function scheduleHudHide() { clearIdleTimer(); cp.idleTimer = setTimeout(() => { cp.idleTimer = null; // Never fade out from under a mouse resting on the controls. if (cp.hudHovered) { scheduleHudHide(); return; } if (cp.container) cp.container.classList.add('cp-hud-idle'); // The quality menu lives outside .cp-hud (it must escape the bar's // overflow), so the idle fade doesn't reach it -- close it by hand // rather than leave it floating over a bare video. const menu = q('.cp-format-menu'); if (menu) menu.hidden = true; }, HUD_IDLE_MS); } function wakeHud() { if (!cp.container) return; cp.container.classList.remove('cp-hud-idle'); scheduleHudHide(); } // --------------------------------------------------------------------- // Flash feedback (skip amount, etc.) // --------------------------------------------------------------------- function flash(text) { const flashEl = q('.cp-flash'); if (!flashEl) return; flashEl.textContent = text; flashEl.classList.remove('is-visible'); void flashEl.offsetWidth; // restart the fade animation flashEl.classList.add('is-visible'); } // --------------------------------------------------------------------- // Timeline (draggable seek + buffered range) // --------------------------------------------------------------------- function updateBuffered(video) { const bufferedEl = q('.cp-timeline-buffered'); if (!bufferedEl) return; if (!video.buffered || !video.buffered.length || !isFinite(video.duration) || video.duration <= 0) { bufferedEl.style.width = '0%'; return; } const end = video.buffered.end(video.buffered.length - 1); bufferedEl.style.width = `${Math.min(100, (end / video.duration) * 100)}%`; } function setTimelinePosition(ratio) { const fill = q('.cp-timeline-fill'); const handle = q('.cp-timeline-handle'); const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`; if (fill) fill.style.width = pct; if (handle) handle.style.left = pct; } function bindTimeline(video) { const timeline = q('.cp-timeline'); const currentEl = q('.cp-time-current'); const durationEl = q('.cp-time-duration'); if (!timeline) return; let scrubbing = false; const onTimeUpdate = () => { if (!scrubbing && isFinite(video.duration) && video.duration > 0) { setTimelinePosition(video.currentTime / video.duration); } if (currentEl) currentEl.textContent = App.videos.formatDuration(video.currentTime) || '0:00'; updateBuffered(video); }; const onLoadedMeta = () => { if (durationEl) durationEl.textContent = App.videos.formatDuration(video.duration) || '0:00'; }; const onProgress = () => updateBuffered(video); video.addEventListener('timeupdate', onTimeUpdate); video.addEventListener('loadedmetadata', onLoadedMeta); video.addEventListener('progress', onProgress); addCleanup(() => { video.removeEventListener('timeupdate', onTimeUpdate); video.removeEventListener('loadedmetadata', onLoadedMeta); video.removeEventListener('progress', onProgress); }); const seekFromPointer = (clientX) => { if (!isFinite(video.duration) || video.duration <= 0) return; const rect = timeline.getBoundingClientRect(); const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0; const clamped = Math.min(1, Math.max(0, ratio)); video.currentTime = clamped * video.duration; setTimelinePosition(clamped); }; const onDown = (event) => { scrubbing = true; timeline.classList.add('is-scrubbing'); timeline.setPointerCapture(event.pointerId); seekFromPointer(event.clientX); event.preventDefault(); event.stopPropagation(); wakeHud(); }; const onMove = (event) => { if (!scrubbing) return; seekFromPointer(event.clientX); event.preventDefault(); event.stopPropagation(); }; const onUpEvt = (event) => { if (!scrubbing) return; scrubbing = false; timeline.classList.remove('is-scrubbing'); if (timeline.hasPointerCapture(event.pointerId)) timeline.releasePointerCapture(event.pointerId); event.stopPropagation(); }; timeline.addEventListener('pointerdown', onDown); timeline.addEventListener('pointermove', onMove); timeline.addEventListener('pointerup', onUpEvt); timeline.addEventListener('pointercancel', onUpEvt); addCleanup(() => { timeline.removeEventListener('pointerdown', onDown); timeline.removeEventListener('pointermove', onMove); timeline.removeEventListener('pointerup', onUpEvt); timeline.removeEventListener('pointercancel', onUpEvt); }); } // --------------------------------------------------------------------- // Favorite button (shared state with the grid/feed heart toggle) // --------------------------------------------------------------------- function bindFavorite(videoData) { const btn = q('.cp-fav-btn'); if (!btn || !App.favorites) return; const key = App.favorites.getKey(videoData); if (!key) { btn.hidden = true; return; } btn.dataset.favKey = key; App.favorites.setButtonState(btn, App.favorites.getSet().has(key)); const onClick = (event) => { event.stopPropagation(); App.favorites.toggle(videoData); }; btn.addEventListener('click', onClick); addCleanup(() => btn.removeEventListener('click', onClick)); } // --------------------------------------------------------------------- // Transport: play/pause, skip w/ escalation, mute/volume, PiP // --------------------------------------------------------------------- function bindTransport(video) { const playBtn = q('.cp-play-btn'); const playIcon = q('.cp-play-icon'); const skipBackBtn = q('.cp-skip-back-btn'); const skipFwdBtn = q('.cp-skip-fwd-btn'); const muteBtn = q('.cp-mute-btn'); const muteIcon = q('.cp-mute-icon'); const volumeRange = q('.cp-volume-range'); const pipBtn = q('.cp-pip-btn'); const replayBtn = q('.cp-replay-btn'); const updatePlayIcon = () => { if (playIcon) { playIcon.src = video.paused ? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/play.svg' : 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/pause.svg'; } if (playBtn) playBtn.setAttribute('aria-label', video.paused ? 'Play' : 'Pause'); }; const onPlay = () => { updatePlayIcon(); if (replayBtn) replayBtn.hidden = true; }; const onPause = updatePlayIcon; video.addEventListener('play', onPlay); video.addEventListener('pause', onPause); addCleanup(() => { video.removeEventListener('play', onPlay); video.removeEventListener('pause', onPause); }); if (playBtn) { const onPlayClick = (event) => { event.stopPropagation(); if (video.paused) { const p = video.play(); if (p && p.catch) p.catch(() => {}); } else { video.pause(); } wakeHud(); }; playBtn.addEventListener('click', onPlayClick); addCleanup(() => playBtn.removeEventListener('click', onPlayClick)); } const escalator = App.customPlayer.createSkipEscalator(); addCleanup(() => escalator.destroy()); const doSkip = (direction) => { const amount = App.customPlayer.skip(video, direction, escalator); const back = skipBackBtn && skipBackBtn.querySelector('.cp-skip-amount'); const fwd = skipFwdBtn && skipFwdBtn.querySelector('.cp-skip-amount'); if (direction === 'back' && back) back.textContent = String(amount); if (direction === 'forward' && fwd) fwd.textContent = String(amount); flash(`${direction === 'forward' ? '+' : '-'}${amount}s`); wakeHud(); }; App.player._doSkip = doSkip; // exposed for gesture wiring below if (skipBackBtn) { const onClick = (event) => { event.stopPropagation(); doSkip('back'); }; skipBackBtn.addEventListener('click', onClick); addCleanup(() => skipBackBtn.removeEventListener('click', onClick)); } if (skipFwdBtn) { const onClick = (event) => { event.stopPropagation(); doSkip('forward'); }; skipFwdBtn.addEventListener('click', onClick); addCleanup(() => skipFwdBtn.removeEventListener('click', onClick)); } const updateMuteIcon = () => { if (muteIcon) { muteIcon.src = (video.muted || video.volume === 0) ? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg' : 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg'; } if (volumeRange) volumeRange.value = video.muted ? 0 : video.volume; }; updateMuteIcon(); const onVolumeChange = updateMuteIcon; video.addEventListener('volumechange', onVolumeChange); addCleanup(() => video.removeEventListener('volumechange', onVolumeChange)); if (muteBtn) { const onClick = (event) => { event.stopPropagation(); video.muted = !video.muted; if (!video.muted && video.volume === 0) video.volume = 1; wakeHud(); }; muteBtn.addEventListener('click', onClick); addCleanup(() => muteBtn.removeEventListener('click', onClick)); } if (volumeRange) { const onInput = () => { video.volume = parseFloat(volumeRange.value); video.muted = video.volume === 0; wakeHud(); }; volumeRange.addEventListener('input', onInput); addCleanup(() => volumeRange.removeEventListener('input', onInput)); } if (pipBtn) { pipBtn.hidden = !App.customPlayer.supportsPiP(); const onClick = async (event) => { event.stopPropagation(); await App.customPlayer.togglePiP(video); }; pipBtn.addEventListener('click', onClick); addCleanup(() => pipBtn.removeEventListener('click', onClick)); } addCleanup(App.customPlayer.bindAutoPiP(video)); if (replayBtn) { const onClick = (event) => { event.stopPropagation(); replayBtn.hidden = true; video.currentTime = 0; const p = video.play(); if (p && p.catch) p.catch(() => {}); }; replayBtn.addEventListener('click', onClick); addCleanup(() => replayBtn.removeEventListener('click', onClick)); } const onEnded = () => { clearIdleTimer(); if (cp.container) cp.container.classList.remove('cp-hud-idle'); if (replayBtn) replayBtn.hidden = false; }; video.addEventListener('ended', onEnded); addCleanup(() => video.removeEventListener('ended', onEnded)); } // --------------------------------------------------------------------- // Gestures: tap wakes the HUD, double-tap left/right skips, right-column // vertical swipe adjusts volume, top-strip swipe-down dismisses. // --------------------------------------------------------------------- function bindGestures(video) { const surface = q('.cp-surface'); if (!surface) return; const destroy = App.customPlayer.attachGestures(surface, { // Always wakes (never explicitly hides) so a double-tap-to-skip // doesn't flicker the HUD off-then-on between its two taps; // hiding is left entirely to the idle-fade timer. onSingleTap: () => wakeHud(), onDoubleTapLeft: () => App.player._doSkip('back'), onDoubleTapRight: () => App.player._doSkip('forward'), onVolumeStart: () => (video.muted ? 0 : video.volume), onVolumeDrag: (value) => { video.volume = value; video.muted = value === 0; flash(`${Math.round(value * 100)}%`); }, onVolumeEnd: () => wakeHud(), onDismissDrag: (dy) => { const clamped = Math.max(0, dy); cp.container.style.transform = `translateY(${clamped}px)`; cp.container.style.opacity = String(Math.max(0.4, 1 - clamped / 400)); }, onDismissEnd: (dy) => { cp.container.style.transform = ''; cp.container.style.opacity = ''; if (dy > DISMISS_THRESHOLD_PX) { App.player.close(); } } }); addCleanup(destroy); } // --------------------------------------------------------------------- // Mouse presence (desktop): moving the mouse anywhere over the player // brings the HUD back, and it stays up while the pointer rests on the // controls. Touch input is deliberately excluded -- taps already wake the // HUD, and a finger dragging for volume or a dismiss swipe shouldn't count // as "the viewer went looking for the controls". // --------------------------------------------------------------------- function bindHoverHud() { const container = cp.container; if (!container) return; const isMouse = (event) => !event.pointerType || event.pointerType === 'mouse'; let lastWake = 0; const onMove = (event) => { if (!isMouse(event)) return; // wakeHud() re-arms the idle timer; once every 150ms is plenty and // keeps a fast mouse from churning timers on every pixel. const now = (window.performance && performance.now()) ? performance.now() : Date.now(); if (now - lastWake < 150) return; lastWake = now; wakeHud(); }; const onEnterHud = (event) => { if (!isMouse(event)) return; cp.hudHovered = true; wakeHud(); }; const onLeaveHud = (event) => { if (!isMouse(event)) return; cp.hudHovered = false; scheduleHudHide(); }; const onLeaveContainer = (event) => { if (!isMouse(event)) return; cp.hudHovered = false; }; container.addEventListener('pointermove', onMove); container.addEventListener('pointerleave', onLeaveContainer); // The bars, not .cp-hud itself: the HUD wrapper is pointer-events:none // (so it never eats gestures over the video) and only its children are // hit-testable. // The open quality menu counts as "the controls" too: a mouse resting // on it must not let the HUD fade the menu out from under the cursor. const bars = [q('.cp-top-bar'), q('.cp-bottom-bar'), q('.cp-format-menu')].filter(Boolean); bars.forEach((bar) => { bar.addEventListener('pointerenter', onEnterHud); bar.addEventListener('pointerleave', onLeaveHud); }); addCleanup(() => { cp.hudHovered = false; container.removeEventListener('pointermove', onMove); container.removeEventListener('pointerleave', onLeaveContainer); bars.forEach((bar) => { bar.removeEventListener('pointerenter', onEnterHud); bar.removeEventListener('pointerleave', onLeaveHud); }); }); } // --------------------------------------------------------------------- // Keyboard shortcuts (desktop) // --------------------------------------------------------------------- function bindKeyboard(video) { const onKeyDown = (event) => { if (!cp.container || !cp.container.classList.contains('open')) return; switch (event.key) { case ' ': case 'k': case 'K': event.preventDefault(); if (video.paused) video.play().catch(() => {}); else video.pause(); break; case 'ArrowLeft': App.player._doSkip('back'); break; case 'ArrowRight': App.player._doSkip('forward'); break; case 'ArrowUp': event.preventDefault(); video.volume = Math.min(1, video.volume + 0.1); video.muted = false; break; case 'ArrowDown': event.preventDefault(); video.volume = Math.max(0, video.volume - 0.1); break; case 'm': case 'M': video.muted = !video.muted; break; case 'Escape': App.player.close(); break; default: return; } wakeHud(); }; document.addEventListener('keydown', onKeyDown); addCleanup(() => document.removeEventListener('keydown', onKeyDown)); } // --------------------------------------------------------------------- // Close button + browser/system back // --------------------------------------------------------------------- function bindClose() { const closeBtn = q('.cp-close-btn'); if (closeBtn) { const onClick = (event) => { event.stopPropagation(); App.player.close(); }; closeBtn.addEventListener('click', onClick); addCleanup(() => closeBtn.removeEventListener('click', onClick)); } const onPopState = () => { if (cp.container && cp.container.classList.contains('open')) { cp.historyPushed = false; // the pushed state was just consumed by the browser App.player.close({ fromPopState: true }); } }; window.addEventListener('popstate', onPopState); addCleanup(() => window.removeEventListener('popstate', onPopState)); // Only push once per "session": open() tears down and rebinds on // reentrancy (see open()) without closing first, so a second open() // while already open must not stack a second history entry that // close()'s single history.back() could never fully unwind. if (!cp.historyPushed) { history.pushState({ customPlayerOpen: true }, '', location.href); cp.historyPushed = true; } } // --------------------------------------------------------------------- // Buffering + error UI // --------------------------------------------------------------------- function bindBufferingIndicator(video) { const spinner = q('.cp-spinner'); if (!spinner) return; const show = () => { spinner.classList.add('is-visible'); }; const hide = () => { spinner.classList.remove('is-visible'); }; video.addEventListener('waiting', show); video.addEventListener('playing', hide); video.addEventListener('canplay', hide); video.addEventListener('pause', hide); addCleanup(() => { video.removeEventListener('waiting', show); video.removeEventListener('playing', hide); video.removeEventListener('canplay', hide); video.removeEventListener('pause', hide); }); } function showBuffering(show) { const spinner = q('.cp-spinner'); if (spinner) spinner.classList.toggle('is-visible', show); } function showError(message, onRetry, sourceUrl) { showBuffering(false); const errorEl = q('.cp-error'); const textEl = q('.cp-error-text'); const retryBtn = q('.cp-retry-btn'); const openBtn = q('.cp-open-btn'); if (!errorEl) return; if (textEl) textEl.textContent = message || 'Playback failed.'; errorEl.hidden = false; if (retryBtn) { retryBtn.onclick = (event) => { event.stopPropagation(); errorEl.hidden = true; onRetry(); }; } if (openBtn) { if (sourceUrl) { openBtn.href = sourceUrl; openBtn.hidden = false; } else { openBtn.hidden = true; openBtn.removeAttribute('href'); } } } function hideError() { const errorEl = q('.cp-error'); if (errorEl) errorEl.hidden = true; } // --------------------------------------------------------------------- // Source resolution + HLS/native fallback chain // --------------------------------------------------------------------- function resolveSources(videoData) { if (cp.formatOverride) { const source = App.videos.resolveSourceForFormat(videoData, cp.formatOverride); return source ? [source] : []; } if (App.videos && typeof App.videos.resolveStreamSources === 'function') { return App.videos.resolveStreamSources(videoData); } return []; } function playSources(videoData, opts) { const video = cp.video; const token = ++cp.attemptToken; // Every route into here supersedes whatever was playing or loading: the // direct route winning its race, a quality switch, a retry, a re-open. // Void the old attempt's callbacks, then stop its requests. cancelInFlight(); const resumeAt = (opts && opts.resumeAt) || 0; // Captured once per call rather than read from the shared `cp` // object later: if open() is ever re-entered for a different video // before this attempt settles, cp.originEl will have moved on to the // new card, and a stale callback reading it live would either mark // the wrong card loaded or (via the token guard below) never clear // this card's spinner at all. const originEl = (opts && opts.originEl) || null; const sources = resolveSources(videoData); const clearLoading = () => { if (originEl) originEl.classList.remove('is-loading'); }; const sourceUrl = (videoData && (videoData.url || (videoData.meta && videoData.meta.url))) || ''; if (!sources.length) { clearLoading(); showError('Unable to play this stream.', () => playSources(videoData, opts), sourceUrl); return; } const directProven = (url) => !!(App.videos && App.videos.isDirectProven && App.videos.isDirectProven(url)); const plan = []; sources.forEach((resolved) => { if (directProven(resolved.url)) plan.push({ resolved, direct: true }); plan.push({ resolved, direct: false }); }); // Whether a CDN will serve the browser directly is asked here, at play // time, about this video's own media URL -- not in advance about the // listing's. One provider can spread its media across several CDNs, so // there is no single answer to pre-compute, and any answer taken from // another video may not hold for this one. // // The question runs *alongside* the proxied playback rather than ahead // of it, so it never delays anything: the proxy is already carrying the // video while the direct route is being tested. If the answer comes // back before any frame has been decoded, the attempt restarts on the // direct URL -- nothing is on screen yet, so there is nothing to // interrupt. If playback has already begun, the answer is kept, and the // next video from that CDN starts direct without asking again. const raceDirect = function(resolved) { if (!App.videos || typeof App.videos.probeDirect !== 'function') return; if (!resolved.url || resolved.isLive) return; // An origin that demands a Referer can never be fetched directly by // a browser, so there is nothing to find out. if (resolved.refererRequired) return; if (directProven(resolved.url)) return; App.videos.probeDirect(resolved.url).then((ok) => { if (!ok || token !== cp.attemptToken) return; // readyState >= HAVE_CURRENT_DATA means a frame is up; leave a // playing video alone rather than trading a visible stall for a // saved hop. if (!cp.video || cp.video.readyState >= 2) return; // Direct won. Restarting cancels the proxy's fetch on the way // in (see cancelInFlight), so the losing route stops pulling // bytes instead of running to completion behind the winner. playSources(videoData, Object.assign({}, opts, { resumeAt: resumeAt })); }); }; const attempt = async (index) => { if (token !== cp.attemptToken) return; const entry = plan[index]; const resolved = entry.resolved; const hasNext = index + 1 < plan.length; let playbackStarted = false; let settled = false; const advanceOrFail = (message) => { if (settled || token !== cp.attemptToken) return; settled = true; if (hasNext) attempt(index + 1); else if (!(opts && opts.refreshed) && App.videos && typeof App.videos.refreshFormats === 'function') { // Every candidate failed. Media URLs are signed with an // expiry, so the most likely cause is that these ones went // stale (a tab left open, or formats resolved a while ago), // not that the video is gone. Re-resolve and try once more // before telling the viewer it can't be played. App.videos.refreshFormats(videoData).then((meta) => { if (token !== cp.attemptToken) return; // A manually picked format points at one of the URLs // that just failed, so the retry goes back to automatic // selection over the freshly resolved list. cp.formatOverride = null; const retryOpts = Object.assign({}, opts, { refreshed: true, resumeAt }); if (meta) playSources(videoData, retryOpts); else { clearLoading(); showError(message, () => playSources(videoData, retryOpts), sourceUrl); } }); } else { clearLoading(); showError(message, () => playSources(videoData, opts), sourceUrl); } }; let streamUrl = entry.direct ? resolved.url : App.videos.buildStreamUrlFromSource(resolved); const kind = App.videos.classifySource(resolved); let isHls = kind.isHls; let isDirectMedia = kind.isDirectMedia; cancelInFlight(); const attemptAbort = new AbortController(); cp.fetchAbort = attemptAbort; // Going out through the proxy: find out in parallel whether this // CDN would have taken the browser directly. if (!entry.direct) raceDirect(resolved); // Last resort only: a HEAD through the proxy is a whole upstream // connection (handshake included) before the first byte of video is // ever requested, so it runs only when neither the URL nor the // extractor's protocol says what this source is. if (!isHls && !isDirectMedia && !entry.direct) { try { const headResp = await fetch(streamUrl, { method: 'HEAD', signal: attemptAbort.signal }); 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) { // Best-effort sniff only -- including the abort that // cancelInFlight fires, which lands here rather than at the // guard below. } // Outside the catch on purpose: an aborted sniff means this // attempt has been superseded, and swallowing that with the // failure of a best-effort sniff would let a dead attempt walk // on and attach a stream to the player that replaced it. if (token !== cp.attemptToken) return; } const startPlayback = () => { if (playbackStarted) return; playbackStarted = true; // Whichever candidate got this far is the one on screen -- not // necessarily the one the ranking (or the viewer) asked for. cp.activeUrl = resolved.url || ''; clearLoading(); hideError(); showBuffering(false); if (resumeAt > 0) { const seek = () => { try { video.currentTime = resumeAt; } catch (err) { /* ignore */ } }; if (video.readyState >= 1) seek(); else video.addEventListener('loadedmetadata', seek, { once: true }); } const p = video.play(); if (p && p.catch) p.catch(() => {}); }; if (!window.Hls && (isHls || !isDirectMedia)) { try { await App.ensureHls(); } catch (err) { /* fall back to native below */ } if (token !== cp.attemptToken) return; } 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, () => startPlayback()); startPlayback(); state.hlsPlayer.on(window.Hls.Events.ERROR, (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 advanceOrFail('HLS is not supported in this browser.'); } } else { startNative(); } video.onerror = () => { if (!usingHls && canUseHls && !hlsTried && !isDirectMedia) { if (startHls(true)) return; } advanceOrFail('Video failed to load.'); }; }; showBuffering(true); attempt(0); } // --------------------------------------------------------------------- // Public API // --------------------------------------------------------------------- App.player.open = function(source, opts) { // Reentrant call (a second video opened before the first settled): // tear down the previous session's video/listeners in place, but // don't pop the history entry bindClose() already pushed for it -- // it's reused below instead of stacking a second one that close()'s // single history.back() could never fully unwind. Also clears the // abandoned session's own loading spinner, since its card would // otherwise never hear about the takeover. const reopening = !!(cp.container && cp.container.classList.contains('open')); if (reopening) { cp.attemptToken++; cancelInFlight(); clearIdleTimer(); if (cp.originEl) cp.originEl.classList.remove('is-loading'); } runCleanups(); cp.originEl = opts && opts.originEl ? opts.originEl : null; if (cp.originEl) cp.originEl.classList.add('is-loading'); cp.container = buildContainer(); cp.video = q('.cp-video'); cp.formatOverride = null; cp.activeUrl = ''; const isLive = !!(source && typeof source === 'object' && (source.isLive || (source.meta && source.meta.isLive))); cp.container.classList.toggle('is-live', isLive); const titleText = q('.cp-title-text'); if (titleText) { titleText.textContent = (source && (source.title || (source.meta && source.meta.title))) || ''; requestAnimationFrame(measureTitle); } // Ambient backdrop: a blurred copy of the poster fills any letterbox // bars behind the contained video. const surface = q('.cp-surface'); if (surface) { let poster = (source && (source.thumb || (source.meta && (source.meta.thumbnail || source.meta.thumb)))) || ''; if (!poster && cp.originEl) { const img = cp.originEl.querySelector('img'); if (img) poster = img.currentSrc || img.src || ''; } if (poster) surface.style.setProperty('--poster', `url("${poster.replace(/"/g, '%22')}")`); else surface.style.removeProperty('--poster'); } bindFavorite(source); bindTimeline(cp.video); bindTransport(cp.video); cp.source = source; const bindFormats = () => App.customPlayer.bindFormatMenu(q('.cp-format-btn'), q('.cp-format-menu'), source, (fmt) => { cp.formatOverride = fmt; const resumeAt = cp.video.currentTime || 0; playSources(source, { resumeAt, originEl: cp.originEl }); }, { getCurrentUrl: () => cp.activeUrl, onOpen: wakeHud }); let destroyFormatMenu = bindFormats(); addCleanup(() => destroyFormatMenu()); const meta = (source && typeof source === 'object') ? (source.meta || source) : null; const hasFormats = !!(meta && Array.isArray(meta.formats) && meta.formats.length); // Items that arrive without formats -- a listing card clicked before its // hover-resolve finished, or a favorite (which deliberately stores no // resolved formats, since their URLs expire) -- carry only a page URL, // and they'd also show an empty quality menu. Resolve them first: the // page-URL fallback makes /api/stream re-run yt-dlp on every single // request, which is slow and fails outright on some sites, whereas a // resolved format is a real media URL with the extractor's headers -- // the same path a hovered card plays through. let deferredStart = false; if (!hasFormats && App.videos && typeof App.videos.ensureFormats === 'function') { deferredStart = true; App.videos.ensureFormats(source).then((resolved) => { // A later open() (or a close) may have taken over in the // meantime; that session owns the player now. if (cp.source !== source) return; if (resolved) { destroyFormatMenu(); destroyFormatMenu = bindFormats(); } playSources(source, { originEl: cp.originEl }); }); } bindGestures(cp.video); bindHoverHud(); bindKeyboard(cp.video); bindClose(); bindBufferingIndicator(cp.video); cp.container.classList.add('open'); cp.container.setAttribute('aria-hidden', 'false'); document.body.style.overflow = 'hidden'; wakeHud(); // Already-resolved sources start immediately; unresolved ones start from // the ensureFormats() callback above (the spinner is already up). if (!deferredStart) playSources(source, { originEl: cp.originEl }); }; App.player.close = function(opts) { if (!cp.container || !cp.container.classList.contains('open')) return; cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks // Closing the player must also stop what it was fetching -- otherwise a // proxied stream keeps being pulled, and the server keeps an upstream // connection open, for a video nobody is watching any more. cancelInFlight(); clearIdleTimer(); runCleanups(); cp.container.classList.remove('open', 'cp-hud-idle', 'is-live'); cp.container.style.transform = ''; cp.container.style.opacity = ''; cp.container.setAttribute('aria-hidden', 'true'); document.body.style.overflow = 'auto'; if (cp.historyPushed && !(opts && opts.fromPopState)) { cp.historyPushed = false; history.back(); } else { cp.historyPushed = false; } if (cp.originEl) { cp.originEl.classList.remove('is-loading'); cp.originEl = null; } cp.data = null; cp.source = null; // voids a still-pending format resolve for this open cp.formatOverride = null; cp.activeUrl = ''; }; })();