Files
jacuzzi/frontend/js/player.js
Simon 59f7c33ebd Wake the player HUD on mouse movement (desktop)
The fullscreen player only revealed its HUD on a tap, a key press or a
transport action, so on a PC the controls stayed hidden while the mouse
moved over the video -- the reels feed already woke its own HUD on
mousemove, so this was the odd one out.

Any mouse movement over the player now wakes it, and it stays up while
the pointer rests on the top or bottom bar rather than fading out from
under the cursor. Touch and pen are excluded on purpose: taps already
wake the HUD, and a finger dragging for volume or a dismiss swipe isn't
someone looking for the controls. The listeners go on the bars rather
than .cp-hud, which is pointer-events:none so it never eats gestures
over the video.

Verified with synthesized mouse input in headless Chrome: idle 3.2s ->
hidden; move over the video -> shown; still 3.2s -> hidden again; onto
the controls -> shown and still shown after 3.4s resting there; back over
the video and still -> hidden.

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

961 lines
41 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 {
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;
};
})();