Files
jacuzzi/frontend/js/player.js
Simon b4bc90372d Load a video before showing the player, not after
Opening a video put an empty black player up at once and spun at the
viewer until the first frame arrived -- which, between resolving the
formats and the first bytes of a stream, is routinely a couple of seconds
of nothing to look at.

The session now starts off-screen: everything is built and loading, but the
card that was clicked keeps its own spinner, the page stays where it was,
and the player appears when the video has something to show. Failures
reveal it too, since the error and its retry live inside the player, and so
does a five-second timeout -- a stream that is merely slow is better
watched from inside a player that can be closed than from a card that looks
stuck.

The video is muted while it loads out of sight and restored on the way in,
because nothing should be heard from a player that isn't there. It is
hidden with opacity rather than display:none: iOS won't load a display:none
video, and the HUD's children opt back into pointer events, so they are
told not to as well -- an invisible close button must not eat a card's
click.

isActive() replaces the checks that asked "is the player open?" and meant
"is there a session": a pending one owns the same history entry, the same
card spinner and the same requests, so Escape cancels it, the back gesture
cancels it, another video takes over from it, and an update can't reload
the page out from under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
2026-09-21 16:39:03 +00:00

1158 lines
52 KiB
JavaScript

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,
originToken: null, // stamp proving originEl is still the card we opened
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
pending: false, // loading off-screen, not shown yet (see reveal)
revealTimer: null,
mutedBeforeReveal: null
};
// A session exists: either on screen, or still loading in the background
// before it gets there. Everything that used to ask "is the player open?"
// means this -- a pending session owns the same history entry, the same
// card spinner and the same in-flight requests as a shown one.
function isActive() {
return !!(cp.container && (cp.pending || cp.container.classList.contains('open')));
}
App.player.isActive = isActive;
// 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();
}
}
// The card the player was opened from is owned by the grid, which pools and
// reuses its cards (see resetCard in videos.js). By the time the player lets
// go, that element may be showing a different video -- so it is stamped at
// open, and every later touch checks the stamp still matches. A recycled
// card has had it wiped, and simply stops answering.
let originSeq = 0;
const claimOrigin = function(el) {
if (!el) return null;
const token = String(++originSeq);
el.dataset.playerToken = token;
return token;
};
const withOrigin = function(el, token, fn) {
// `token` must be truthy in its own right: dataset yields undefined for
// a missing attribute, so without this an unstamped token would match
// every card that has no stamp -- including one the grid has recycled,
// which is precisely the case this guard exists to catch.
if (el && token && el.dataset.playerToken === token) fn(el);
};
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 = `
<div class="cp-surface">
<video class="cp-video" playsinline webkit-playsinline></video>
<div class="cp-flash" aria-hidden="true"></div>
<div class="cp-spinner" aria-hidden="true"><div class="cp-spinner-ring"></div></div>
<div class="cp-error" hidden>
<p class="cp-error-text"></p>
<div class="cp-error-actions">
<button class="cp-retry-btn" type="button">Retry</button>
<a class="cp-open-btn" type="button" target="_blank" rel="noopener noreferrer" hidden>Open in new tab</a>
</div>
</div>
<button class="cp-replay-btn" type="button" aria-label="Replay" hidden>
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-path.svg" alt="Replay">
</button>
</div>
<div class="cp-hud">
<div class="cp-top-bar">
<button class="cp-close-btn" type="button" aria-label="Close">✕</button>
<h4 class="cp-title"><span class="cp-title-text"></span></h4>
<button class="cp-fav-btn favorite-btn" type="button"></button>
</div>
<div class="cp-bottom-bar">
<div class="cp-timeline" role="slider" aria-label="Seek">
<div class="cp-timeline-track">
<div class="cp-timeline-buffered"></div>
<div class="cp-timeline-fill"></div>
<div class="cp-timeline-handle"></div>
</div>
</div>
<div class="cp-time-row">
<span class="cp-time cp-time-current">0:00</span>
<div class="cp-transport">
<button class="cp-skip-back-btn" type="button" aria-label="Skip back">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/backward.svg" alt="">
<span class="cp-skip-amount">5</span>
</button>
<button class="cp-play-btn" type="button" aria-label="Pause">
<img class="icon-svg cp-play-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/pause.svg" alt="">
</button>
<button class="cp-skip-fwd-btn" type="button" aria-label="Skip forward">
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/forward.svg" alt="">
<span class="cp-skip-amount">5</span>
</button>
</div>
<span class="cp-time cp-time-duration">0:00</span>
<div class="cp-secondary-controls">
<button class="cp-mute-btn" type="button" aria-label="Mute">
<img class="icon-svg cp-mute-icon" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg" alt="">
</button>
<input class="cp-volume-range" type="range" min="0" max="1" step="0.05" value="1" aria-label="Volume">
<button class="cp-format-btn" type="button" aria-label="Quality" hidden>HD</button>
<button class="cp-pip-btn" type="button" aria-label="Picture in picture" hidden>
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt="">
</button>
</div>
</div>
</div>
</div>
<div class="cp-format-menu" role="menu" hidden></div>
`;
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.has(videoData));
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 (!isActive()) return;
// Nothing else is worth doing to a video nobody can see yet, but
// changing your mind about it is.
if (cp.pending && event.key !== 'Escape') 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 (isActive()) {
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);
}
// ---------------------------------------------------------------------
// Going on screen
//
// Opening a video used to put an empty black player up immediately and
// spin at the viewer until the first frame arrived -- which, between
// resolving the formats and the first bytes of a stream, is routinely a
// couple of seconds of nothing. So the session now starts off-screen:
// the card that was clicked keeps its own spinner, the page stays where
// it was, and the player appears only once the video has real data to
// show. Failures reveal it too -- the error and its retry live inside the
// player -- and so does the timeout below, because a stream that is merely
// slow is better watched from inside the player (which can be closed) than
// from a card that looks stuck.
// ---------------------------------------------------------------------
const REVEAL_TIMEOUT_MS = 5000;
function reveal() {
if (!cp.container) return;
if (cp.revealTimer) {
clearTimeout(cp.revealTimer);
cp.revealTimer = null;
}
cp.pending = false;
// The card has handed over, whether this is the first reveal or a
// second video opened over the top of the first.
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
if (cp.container.classList.contains('open')) return;
cp.container.classList.remove('is-preloading');
cp.container.classList.add('open');
cp.container.setAttribute('aria-hidden', 'false');
document.body.style.overflow = 'hidden';
// Muted while it was loading out of sight; it is in sight now.
if (cp.mutedBeforeReveal !== null && cp.video) {
cp.video.muted = cp.mutedBeforeReveal;
cp.mutedBeforeReveal = null;
}
wakeHud();
}
// The first data is the cue: `loadeddata` means a frame can be drawn, and
// `playing` covers the sources that get there without one (audio-only, and
// anything whose first frame lands before the listener is attached).
function bindReveal(video) {
const onData = () => reveal();
video.addEventListener('loadeddata', onData);
video.addEventListener('playing', onData);
addCleanup(() => {
video.removeEventListener('loadeddata', onData);
video.removeEventListener('playing', onData);
});
if (video.readyState >= 2) reveal();
}
function showError(message, onRetry, sourceUrl) {
// Whatever went wrong, it says so in the player -- which the viewer
// can only read if the player is on screen.
reveal();
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 originToken = originEl ? originEl.dataset.playerToken : null;
const sources = resolveSources(videoData);
const clearLoading = () => {
withOrigin(originEl, originToken, (el) => el.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 || '';
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 = isActive();
if (reopening) {
cp.attemptToken++;
cancelInFlight();
clearIdleTimer();
withOrigin(cp.originEl, cp.originToken, (el) => el.classList.remove('is-loading'));
}
runCleanups();
cp.originEl = opts && opts.originEl ? opts.originEl : null;
cp.originToken = claimOrigin(cp.originEl);
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);
bindReveal(cp.video);
// Loading out of sight: laid out (so the media element behaves as it
// would on screen -- iOS in particular will not load a display:none
// video) but transparent and untouchable, so the page underneath is
// still the page the viewer is using.
if (!cp.container.classList.contains('open')) {
cp.pending = true;
cp.container.classList.add('is-preloading');
// Nothing should be heard from a player that isn't there yet.
cp.mutedBeforeReveal = cp.video.muted;
cp.video.muted = true;
if (cp.revealTimer) clearTimeout(cp.revealTimer);
cp.revealTimer = setTimeout(reveal, REVEAL_TIMEOUT_MS);
}
// 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 (!isActive()) 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();
if (cp.revealTimer) {
clearTimeout(cp.revealTimer);
cp.revealTimer = null;
}
cp.pending = false;
cp.mutedBeforeReveal = null;
cp.container.classList.remove('open', 'is-preloading', '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;
}
withOrigin(cp.originEl, cp.originToken, (el) => {
el.classList.remove('is-loading');
delete el.dataset.playerToken;
});
cp.originEl = null;
cp.originToken = null;
cp.data = null;
cp.source = null; // voids a still-pending format resolve for this open
cp.formatOverride = null;
cp.activeUrl = '';
};
})();