Files
jacuzzi/frontend/js/player.js
Simon d508263946 Don't re-resolve a favorite whose URL is already the media file
Some channels hand back the media URL itself as an item's url. Since the
favorites fix, opening one of those sent it to /api/resolve first, so
yt-dlp fetched the media just to report the URL we already had. On a
signed link (`?secure=<ts>-<token>`) that is a second request against
something that may be single-use or IP-bound, and the request that
matters -- the playback fetch -- is then refused. Such URLs now play
directly, with no resolve round trip, as they did before.

Alongside that, three things that make expiry survivable:

/api/stream, after its existing referer-less retry, now retries a 403
completely bare (Range only). Signed CDN links are routinely served to a
plain browser request and refused when it carries extras -- a
`Sec-Fetch-Mode: navigate` on a media subresource, say, which is what
yt-dlp's generic extractor hands back and no real player would send.

When every source fails, the player re-resolves once and retries instead
of giving up, since the likeliest cause is that signed URLs went stale in
a long-open tab rather than the video being gone. A manual quality pick
is dropped for that retry, as it names one of the URLs that just failed.

Favorites stored by older versions still carry a `meta` blob of resolved
formats, long expired; it's now stripped on read so nothing can reach for
one.

Verified: a favorite whose url is a .mp4 plays with zero /api/resolve
calls, straight from that URL; playback, prefetch, feed paging, HUD,
rotation, momentum and the version check all still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
2026-09-05 20:41:31 +00:00

982 lines
43 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,
hudHovered: false, // mouse resting on the controls (desktop)
attemptToken: 0 // bumps on every open()/format switch to void stale async callbacks
};
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" hidden></div>
`;
return container;
}
// ---------------------------------------------------------------------
// Title marquee (mirrors App.videos' card marquee math, scoped locally
// since the player title isn't a `.video-card`).
// ---------------------------------------------------------------------
function measureTitle() {
const wrap = q('.cp-title');
const text = q('.cp-title-text');
if (!wrap || !text) return;
const overflow = text.scrollWidth - wrap.clientWidth;
if (overflow > 4) {
const distance = overflow + 12;
const MARQUEE_SPEED = 28;
const MARQUEE_MIN_DURATION = 6;
const duration = Math.max(MARQUEE_MIN_DURATION, distance / MARQUEE_SPEED);
text.style.setProperty('--marquee-distance', `${distance}px`);
text.style.setProperty('--marquee-duration', `${duration.toFixed(2)}s`);
wrap.classList.add('has-marquee');
} else {
wrap.classList.remove('has-marquee');
text.style.removeProperty('--marquee-distance');
}
}
// ---------------------------------------------------------------------
// 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');
}, 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.
const bars = [q('.cp-top-bar'), q('.cp-bottom-bar')].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;
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 });
});
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);
let isHls = /\.m3u8($|\?)/i.test(resolved.url);
let isDirectMedia = /\.(mp4|m4v|m4s|webm|ts|mov)($|\?)/i.test(resolved.url);
if (resolved.isLive) { isHls = true; isDirectMedia = false; }
video.onerror = null;
if (state.hlsPlayer) {
state.hlsPlayer.stopLoad();
state.hlsPlayer.detachMedia();
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
video.pause();
video.removeAttribute('src');
video.load();
if (!isHls && !entry.direct) {
try {
const headResp = await fetch(streamUrl, { method: 'HEAD' });
if (token !== cp.attemptToken) return;
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.
}
}
const startPlayback = () => {
if (playbackStarted) return;
playbackStarted = true;
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++;
if (state.hlsPlayer) {
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
if (cp.video) {
cp.video.onerror = null;
cp.video.pause();
}
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;
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 });
});
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
if (state.hlsPlayer) {
state.hlsPlayer.destroy();
state.hlsPlayer = null;
}
if (cp.video) {
cp.video.onerror = null;
cp.video.pause();
cp.video.removeAttribute('src');
cp.video.load();
}
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;
};
})();