The quality menu now marks the format that is actually on screen when it opens, read live from the player rather than recorded at bind time, so the tick follows an automatic pick or a fallback after a failed candidate, not only a manual choice. Labels drop the container (mp4 told the viewer nothing about a quality choice) and gain the extractor's format_note when it says something the quality doesn't already. The menu sits outside .cp-hud so it can escape the bar's overflow, which means the idle fade never reached it -- the player and the reels feed now close it along with the rest of the HUD. Opening it restarts the idle countdown (the feed's window is only a second), and on desktop a mouse resting on the open menu holds the HUD up, same as the bars. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
995 lines
44 KiB
JavaScript
995 lines
44 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)
|
|
activeUrl: '', // media URL actually playing, for the format menu's tick
|
|
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" role="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');
|
|
// The quality menu lives outside .cp-hud (it must escape the bar's
|
|
// overflow), so the idle fade doesn't reach it -- close it by hand
|
|
// rather than leave it floating over a bare video.
|
|
const menu = q('.cp-format-menu');
|
|
if (menu) menu.hidden = true;
|
|
}, HUD_IDLE_MS);
|
|
}
|
|
|
|
function wakeHud() {
|
|
if (!cp.container) return;
|
|
cp.container.classList.remove('cp-hud-idle');
|
|
scheduleHudHide();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Flash feedback (skip amount, etc.)
|
|
// ---------------------------------------------------------------------
|
|
function flash(text) {
|
|
const flashEl = q('.cp-flash');
|
|
if (!flashEl) return;
|
|
flashEl.textContent = text;
|
|
flashEl.classList.remove('is-visible');
|
|
void flashEl.offsetWidth; // restart the fade animation
|
|
flashEl.classList.add('is-visible');
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Timeline (draggable seek + buffered range)
|
|
// ---------------------------------------------------------------------
|
|
function updateBuffered(video) {
|
|
const bufferedEl = q('.cp-timeline-buffered');
|
|
if (!bufferedEl) return;
|
|
if (!video.buffered || !video.buffered.length || !isFinite(video.duration) || video.duration <= 0) {
|
|
bufferedEl.style.width = '0%';
|
|
return;
|
|
}
|
|
const end = video.buffered.end(video.buffered.length - 1);
|
|
bufferedEl.style.width = `${Math.min(100, (end / video.duration) * 100)}%`;
|
|
}
|
|
|
|
function setTimelinePosition(ratio) {
|
|
const fill = q('.cp-timeline-fill');
|
|
const handle = q('.cp-timeline-handle');
|
|
const pct = `${Math.min(1, Math.max(0, ratio)) * 100}%`;
|
|
if (fill) fill.style.width = pct;
|
|
if (handle) handle.style.left = pct;
|
|
}
|
|
|
|
function bindTimeline(video) {
|
|
const timeline = q('.cp-timeline');
|
|
const currentEl = q('.cp-time-current');
|
|
const durationEl = q('.cp-time-duration');
|
|
if (!timeline) return;
|
|
let scrubbing = false;
|
|
|
|
const onTimeUpdate = () => {
|
|
if (!scrubbing && isFinite(video.duration) && video.duration > 0) {
|
|
setTimelinePosition(video.currentTime / video.duration);
|
|
}
|
|
if (currentEl) currentEl.textContent = App.videos.formatDuration(video.currentTime) || '0:00';
|
|
updateBuffered(video);
|
|
};
|
|
const onLoadedMeta = () => {
|
|
if (durationEl) durationEl.textContent = App.videos.formatDuration(video.duration) || '0:00';
|
|
};
|
|
const onProgress = () => updateBuffered(video);
|
|
video.addEventListener('timeupdate', onTimeUpdate);
|
|
video.addEventListener('loadedmetadata', onLoadedMeta);
|
|
video.addEventListener('progress', onProgress);
|
|
addCleanup(() => {
|
|
video.removeEventListener('timeupdate', onTimeUpdate);
|
|
video.removeEventListener('loadedmetadata', onLoadedMeta);
|
|
video.removeEventListener('progress', onProgress);
|
|
});
|
|
|
|
const seekFromPointer = (clientX) => {
|
|
if (!isFinite(video.duration) || video.duration <= 0) return;
|
|
const rect = timeline.getBoundingClientRect();
|
|
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
|
|
const clamped = Math.min(1, Math.max(0, ratio));
|
|
video.currentTime = clamped * video.duration;
|
|
setTimelinePosition(clamped);
|
|
};
|
|
const onDown = (event) => {
|
|
scrubbing = true;
|
|
timeline.classList.add('is-scrubbing');
|
|
timeline.setPointerCapture(event.pointerId);
|
|
seekFromPointer(event.clientX);
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
wakeHud();
|
|
};
|
|
const onMove = (event) => {
|
|
if (!scrubbing) return;
|
|
seekFromPointer(event.clientX);
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
};
|
|
const onUpEvt = (event) => {
|
|
if (!scrubbing) return;
|
|
scrubbing = false;
|
|
timeline.classList.remove('is-scrubbing');
|
|
if (timeline.hasPointerCapture(event.pointerId)) timeline.releasePointerCapture(event.pointerId);
|
|
event.stopPropagation();
|
|
};
|
|
timeline.addEventListener('pointerdown', onDown);
|
|
timeline.addEventListener('pointermove', onMove);
|
|
timeline.addEventListener('pointerup', onUpEvt);
|
|
timeline.addEventListener('pointercancel', onUpEvt);
|
|
addCleanup(() => {
|
|
timeline.removeEventListener('pointerdown', onDown);
|
|
timeline.removeEventListener('pointermove', onMove);
|
|
timeline.removeEventListener('pointerup', onUpEvt);
|
|
timeline.removeEventListener('pointercancel', onUpEvt);
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Favorite button (shared state with the grid/feed heart toggle)
|
|
// ---------------------------------------------------------------------
|
|
function bindFavorite(videoData) {
|
|
const btn = q('.cp-fav-btn');
|
|
if (!btn || !App.favorites) return;
|
|
const key = App.favorites.getKey(videoData);
|
|
if (!key) { btn.hidden = true; return; }
|
|
btn.dataset.favKey = key;
|
|
App.favorites.setButtonState(btn, App.favorites.getSet().has(key));
|
|
const onClick = (event) => {
|
|
event.stopPropagation();
|
|
App.favorites.toggle(videoData);
|
|
};
|
|
btn.addEventListener('click', onClick);
|
|
addCleanup(() => btn.removeEventListener('click', onClick));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Transport: play/pause, skip w/ escalation, mute/volume, PiP
|
|
// ---------------------------------------------------------------------
|
|
function bindTransport(video) {
|
|
const playBtn = q('.cp-play-btn');
|
|
const playIcon = q('.cp-play-icon');
|
|
const skipBackBtn = q('.cp-skip-back-btn');
|
|
const skipFwdBtn = q('.cp-skip-fwd-btn');
|
|
const muteBtn = q('.cp-mute-btn');
|
|
const muteIcon = q('.cp-mute-icon');
|
|
const volumeRange = q('.cp-volume-range');
|
|
const pipBtn = q('.cp-pip-btn');
|
|
const replayBtn = q('.cp-replay-btn');
|
|
|
|
const updatePlayIcon = () => {
|
|
if (playIcon) {
|
|
playIcon.src = video.paused
|
|
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/play.svg'
|
|
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/pause.svg';
|
|
}
|
|
if (playBtn) playBtn.setAttribute('aria-label', video.paused ? 'Play' : 'Pause');
|
|
};
|
|
const onPlay = () => { updatePlayIcon(); if (replayBtn) replayBtn.hidden = true; };
|
|
const onPause = updatePlayIcon;
|
|
video.addEventListener('play', onPlay);
|
|
video.addEventListener('pause', onPause);
|
|
addCleanup(() => {
|
|
video.removeEventListener('play', onPlay);
|
|
video.removeEventListener('pause', onPause);
|
|
});
|
|
|
|
if (playBtn) {
|
|
const onPlayClick = (event) => {
|
|
event.stopPropagation();
|
|
if (video.paused) {
|
|
const p = video.play();
|
|
if (p && p.catch) p.catch(() => {});
|
|
} else {
|
|
video.pause();
|
|
}
|
|
wakeHud();
|
|
};
|
|
playBtn.addEventListener('click', onPlayClick);
|
|
addCleanup(() => playBtn.removeEventListener('click', onPlayClick));
|
|
}
|
|
|
|
const escalator = App.customPlayer.createSkipEscalator();
|
|
addCleanup(() => escalator.destroy());
|
|
|
|
const doSkip = (direction) => {
|
|
const amount = App.customPlayer.skip(video, direction, escalator);
|
|
const back = skipBackBtn && skipBackBtn.querySelector('.cp-skip-amount');
|
|
const fwd = skipFwdBtn && skipFwdBtn.querySelector('.cp-skip-amount');
|
|
if (direction === 'back' && back) back.textContent = String(amount);
|
|
if (direction === 'forward' && fwd) fwd.textContent = String(amount);
|
|
flash(`${direction === 'forward' ? '+' : '-'}${amount}s`);
|
|
wakeHud();
|
|
};
|
|
App.player._doSkip = doSkip; // exposed for gesture wiring below
|
|
|
|
if (skipBackBtn) {
|
|
const onClick = (event) => { event.stopPropagation(); doSkip('back'); };
|
|
skipBackBtn.addEventListener('click', onClick);
|
|
addCleanup(() => skipBackBtn.removeEventListener('click', onClick));
|
|
}
|
|
if (skipFwdBtn) {
|
|
const onClick = (event) => { event.stopPropagation(); doSkip('forward'); };
|
|
skipFwdBtn.addEventListener('click', onClick);
|
|
addCleanup(() => skipFwdBtn.removeEventListener('click', onClick));
|
|
}
|
|
|
|
const updateMuteIcon = () => {
|
|
if (muteIcon) {
|
|
muteIcon.src = (video.muted || video.volume === 0)
|
|
? 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-x-mark.svg'
|
|
: 'https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/speaker-wave.svg';
|
|
}
|
|
if (volumeRange) volumeRange.value = video.muted ? 0 : video.volume;
|
|
};
|
|
updateMuteIcon();
|
|
const onVolumeChange = updateMuteIcon;
|
|
video.addEventListener('volumechange', onVolumeChange);
|
|
addCleanup(() => video.removeEventListener('volumechange', onVolumeChange));
|
|
|
|
if (muteBtn) {
|
|
const onClick = (event) => {
|
|
event.stopPropagation();
|
|
video.muted = !video.muted;
|
|
if (!video.muted && video.volume === 0) video.volume = 1;
|
|
wakeHud();
|
|
};
|
|
muteBtn.addEventListener('click', onClick);
|
|
addCleanup(() => muteBtn.removeEventListener('click', onClick));
|
|
}
|
|
if (volumeRange) {
|
|
const onInput = () => {
|
|
video.volume = parseFloat(volumeRange.value);
|
|
video.muted = video.volume === 0;
|
|
wakeHud();
|
|
};
|
|
volumeRange.addEventListener('input', onInput);
|
|
addCleanup(() => volumeRange.removeEventListener('input', onInput));
|
|
}
|
|
|
|
if (pipBtn) {
|
|
pipBtn.hidden = !App.customPlayer.supportsPiP();
|
|
const onClick = async (event) => {
|
|
event.stopPropagation();
|
|
await App.customPlayer.togglePiP(video);
|
|
};
|
|
pipBtn.addEventListener('click', onClick);
|
|
addCleanup(() => pipBtn.removeEventListener('click', onClick));
|
|
}
|
|
addCleanup(App.customPlayer.bindAutoPiP(video));
|
|
|
|
if (replayBtn) {
|
|
const onClick = (event) => {
|
|
event.stopPropagation();
|
|
replayBtn.hidden = true;
|
|
video.currentTime = 0;
|
|
const p = video.play();
|
|
if (p && p.catch) p.catch(() => {});
|
|
};
|
|
replayBtn.addEventListener('click', onClick);
|
|
addCleanup(() => replayBtn.removeEventListener('click', onClick));
|
|
}
|
|
|
|
const onEnded = () => {
|
|
clearIdleTimer();
|
|
if (cp.container) cp.container.classList.remove('cp-hud-idle');
|
|
if (replayBtn) replayBtn.hidden = false;
|
|
};
|
|
video.addEventListener('ended', onEnded);
|
|
addCleanup(() => video.removeEventListener('ended', onEnded));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Gestures: tap wakes the HUD, double-tap left/right skips, right-column
|
|
// vertical swipe adjusts volume, top-strip swipe-down dismisses.
|
|
// ---------------------------------------------------------------------
|
|
function bindGestures(video) {
|
|
const surface = q('.cp-surface');
|
|
if (!surface) return;
|
|
const destroy = App.customPlayer.attachGestures(surface, {
|
|
// Always wakes (never explicitly hides) so a double-tap-to-skip
|
|
// doesn't flicker the HUD off-then-on between its two taps;
|
|
// hiding is left entirely to the idle-fade timer.
|
|
onSingleTap: () => wakeHud(),
|
|
onDoubleTapLeft: () => App.player._doSkip('back'),
|
|
onDoubleTapRight: () => App.player._doSkip('forward'),
|
|
onVolumeStart: () => (video.muted ? 0 : video.volume),
|
|
onVolumeDrag: (value) => {
|
|
video.volume = value;
|
|
video.muted = value === 0;
|
|
flash(`${Math.round(value * 100)}%`);
|
|
},
|
|
onVolumeEnd: () => wakeHud(),
|
|
onDismissDrag: (dy) => {
|
|
const clamped = Math.max(0, dy);
|
|
cp.container.style.transform = `translateY(${clamped}px)`;
|
|
cp.container.style.opacity = String(Math.max(0.4, 1 - clamped / 400));
|
|
},
|
|
onDismissEnd: (dy) => {
|
|
cp.container.style.transform = '';
|
|
cp.container.style.opacity = '';
|
|
if (dy > DISMISS_THRESHOLD_PX) {
|
|
App.player.close();
|
|
}
|
|
}
|
|
});
|
|
addCleanup(destroy);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Mouse presence (desktop): moving the mouse anywhere over the player
|
|
// brings the HUD back, and it stays up while the pointer rests on the
|
|
// controls. Touch input is deliberately excluded -- taps already wake the
|
|
// HUD, and a finger dragging for volume or a dismiss swipe shouldn't count
|
|
// as "the viewer went looking for the controls".
|
|
// ---------------------------------------------------------------------
|
|
function bindHoverHud() {
|
|
const container = cp.container;
|
|
if (!container) return;
|
|
const isMouse = (event) => !event.pointerType || event.pointerType === 'mouse';
|
|
|
|
let lastWake = 0;
|
|
const onMove = (event) => {
|
|
if (!isMouse(event)) return;
|
|
// wakeHud() re-arms the idle timer; once every 150ms is plenty and
|
|
// keeps a fast mouse from churning timers on every pixel.
|
|
const now = (window.performance && performance.now()) ? performance.now() : Date.now();
|
|
if (now - lastWake < 150) return;
|
|
lastWake = now;
|
|
wakeHud();
|
|
};
|
|
const onEnterHud = (event) => {
|
|
if (!isMouse(event)) return;
|
|
cp.hudHovered = true;
|
|
wakeHud();
|
|
};
|
|
const onLeaveHud = (event) => {
|
|
if (!isMouse(event)) return;
|
|
cp.hudHovered = false;
|
|
scheduleHudHide();
|
|
};
|
|
const onLeaveContainer = (event) => {
|
|
if (!isMouse(event)) return;
|
|
cp.hudHovered = false;
|
|
};
|
|
|
|
container.addEventListener('pointermove', onMove);
|
|
container.addEventListener('pointerleave', onLeaveContainer);
|
|
// The bars, not .cp-hud itself: the HUD wrapper is pointer-events:none
|
|
// (so it never eats gestures over the video) and only its children are
|
|
// hit-testable.
|
|
// The open quality menu counts as "the controls" too: a mouse resting
|
|
// on it must not let the HUD fade the menu out from under the cursor.
|
|
const bars = [q('.cp-top-bar'), q('.cp-bottom-bar'), q('.cp-format-menu')].filter(Boolean);
|
|
bars.forEach((bar) => {
|
|
bar.addEventListener('pointerenter', onEnterHud);
|
|
bar.addEventListener('pointerleave', onLeaveHud);
|
|
});
|
|
addCleanup(() => {
|
|
cp.hudHovered = false;
|
|
container.removeEventListener('pointermove', onMove);
|
|
container.removeEventListener('pointerleave', onLeaveContainer);
|
|
bars.forEach((bar) => {
|
|
bar.removeEventListener('pointerenter', onEnterHud);
|
|
bar.removeEventListener('pointerleave', onLeaveHud);
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Keyboard shortcuts (desktop)
|
|
// ---------------------------------------------------------------------
|
|
function bindKeyboard(video) {
|
|
const onKeyDown = (event) => {
|
|
if (!cp.container || !cp.container.classList.contains('open')) return;
|
|
switch (event.key) {
|
|
case ' ':
|
|
case 'k':
|
|
case 'K':
|
|
event.preventDefault();
|
|
if (video.paused) video.play().catch(() => {}); else video.pause();
|
|
break;
|
|
case 'ArrowLeft':
|
|
App.player._doSkip('back');
|
|
break;
|
|
case 'ArrowRight':
|
|
App.player._doSkip('forward');
|
|
break;
|
|
case 'ArrowUp':
|
|
event.preventDefault();
|
|
video.volume = Math.min(1, video.volume + 0.1);
|
|
video.muted = false;
|
|
break;
|
|
case 'ArrowDown':
|
|
event.preventDefault();
|
|
video.volume = Math.max(0, video.volume - 0.1);
|
|
break;
|
|
case 'm':
|
|
case 'M':
|
|
video.muted = !video.muted;
|
|
break;
|
|
case 'Escape':
|
|
App.player.close();
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
wakeHud();
|
|
};
|
|
document.addEventListener('keydown', onKeyDown);
|
|
addCleanup(() => document.removeEventListener('keydown', onKeyDown));
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Close button + browser/system back
|
|
// ---------------------------------------------------------------------
|
|
function bindClose() {
|
|
const closeBtn = q('.cp-close-btn');
|
|
if (closeBtn) {
|
|
const onClick = (event) => { event.stopPropagation(); App.player.close(); };
|
|
closeBtn.addEventListener('click', onClick);
|
|
addCleanup(() => closeBtn.removeEventListener('click', onClick));
|
|
}
|
|
const onPopState = () => {
|
|
if (cp.container && cp.container.classList.contains('open')) {
|
|
cp.historyPushed = false; // the pushed state was just consumed by the browser
|
|
App.player.close({ fromPopState: true });
|
|
}
|
|
};
|
|
window.addEventListener('popstate', onPopState);
|
|
addCleanup(() => window.removeEventListener('popstate', onPopState));
|
|
// Only push once per "session": open() tears down and rebinds on
|
|
// reentrancy (see open()) without closing first, so a second open()
|
|
// while already open must not stack a second history entry that
|
|
// close()'s single history.back() could never fully unwind.
|
|
if (!cp.historyPushed) {
|
|
history.pushState({ customPlayerOpen: true }, '', location.href);
|
|
cp.historyPushed = true;
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Buffering + error UI
|
|
// ---------------------------------------------------------------------
|
|
function bindBufferingIndicator(video) {
|
|
const spinner = q('.cp-spinner');
|
|
if (!spinner) return;
|
|
const show = () => { spinner.classList.add('is-visible'); };
|
|
const hide = () => { spinner.classList.remove('is-visible'); };
|
|
video.addEventListener('waiting', show);
|
|
video.addEventListener('playing', hide);
|
|
video.addEventListener('canplay', hide);
|
|
video.addEventListener('pause', hide);
|
|
addCleanup(() => {
|
|
video.removeEventListener('waiting', show);
|
|
video.removeEventListener('playing', hide);
|
|
video.removeEventListener('canplay', hide);
|
|
video.removeEventListener('pause', hide);
|
|
});
|
|
}
|
|
|
|
function showBuffering(show) {
|
|
const spinner = q('.cp-spinner');
|
|
if (spinner) spinner.classList.toggle('is-visible', show);
|
|
}
|
|
|
|
function showError(message, onRetry, sourceUrl) {
|
|
showBuffering(false);
|
|
const errorEl = q('.cp-error');
|
|
const textEl = q('.cp-error-text');
|
|
const retryBtn = q('.cp-retry-btn');
|
|
const openBtn = q('.cp-open-btn');
|
|
if (!errorEl) return;
|
|
if (textEl) textEl.textContent = message || 'Playback failed.';
|
|
errorEl.hidden = false;
|
|
if (retryBtn) {
|
|
retryBtn.onclick = (event) => {
|
|
event.stopPropagation();
|
|
errorEl.hidden = true;
|
|
onRetry();
|
|
};
|
|
}
|
|
if (openBtn) {
|
|
if (sourceUrl) {
|
|
openBtn.href = sourceUrl;
|
|
openBtn.hidden = false;
|
|
} else {
|
|
openBtn.hidden = true;
|
|
openBtn.removeAttribute('href');
|
|
}
|
|
}
|
|
}
|
|
|
|
function hideError() {
|
|
const errorEl = q('.cp-error');
|
|
if (errorEl) errorEl.hidden = true;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Source resolution + HLS/native fallback chain
|
|
// ---------------------------------------------------------------------
|
|
function resolveSources(videoData) {
|
|
if (cp.formatOverride) {
|
|
const source = App.videos.resolveSourceForFormat(videoData, cp.formatOverride);
|
|
return source ? [source] : [];
|
|
}
|
|
if (App.videos && typeof App.videos.resolveStreamSources === 'function') {
|
|
return App.videos.resolveStreamSources(videoData);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
function playSources(videoData, opts) {
|
|
const video = cp.video;
|
|
const token = ++cp.attemptToken;
|
|
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;
|
|
// Whichever candidate got this far is the one on screen -- not
|
|
// necessarily the one the ranking (or the viewer) asked for.
|
|
cp.activeUrl = resolved.url || '';
|
|
clearLoading();
|
|
hideError();
|
|
showBuffering(false);
|
|
if (resumeAt > 0) {
|
|
const seek = () => { try { video.currentTime = resumeAt; } catch (err) { /* ignore */ } };
|
|
if (video.readyState >= 1) seek();
|
|
else video.addEventListener('loadedmetadata', seek, { once: true });
|
|
}
|
|
const p = video.play();
|
|
if (p && p.catch) p.catch(() => {});
|
|
};
|
|
|
|
if (!window.Hls && (isHls || !isDirectMedia)) {
|
|
try { await App.ensureHls(); } catch (err) { /* fall back to native below */ }
|
|
if (token !== cp.attemptToken) return;
|
|
}
|
|
|
|
const canUseHls = !!(window.Hls && window.Hls.isSupported());
|
|
const prefersHls = isHls || (canUseHls && !isDirectMedia && !video.canPlayType('application/vnd.apple.mpegurl'));
|
|
let hlsTried = false;
|
|
let nativeTried = false;
|
|
let usingHls = false;
|
|
|
|
const startNative = () => {
|
|
if (nativeTried) return;
|
|
nativeTried = true;
|
|
usingHls = false;
|
|
video.src = streamUrl;
|
|
startPlayback();
|
|
};
|
|
|
|
const startHls = (allowFallback) => {
|
|
if (!canUseHls || hlsTried) return false;
|
|
hlsTried = true;
|
|
usingHls = true;
|
|
state.hlsPlayer = new window.Hls();
|
|
state.hlsPlayer.loadSource(streamUrl);
|
|
state.hlsPlayer.attachMedia(video);
|
|
state.hlsPlayer.on(window.Hls.Events.MANIFEST_PARSED, () => startPlayback());
|
|
startPlayback();
|
|
state.hlsPlayer.on(window.Hls.Events.ERROR, (event, data) => {
|
|
if (data && data.fatal) {
|
|
const shouldFallback = allowFallback && !nativeTried && !isHls;
|
|
if (state.hlsPlayer) {
|
|
state.hlsPlayer.destroy();
|
|
state.hlsPlayer = null;
|
|
}
|
|
if (shouldFallback) { startNative(); return; }
|
|
advanceOrFail('Unable to play this stream.');
|
|
}
|
|
});
|
|
return true;
|
|
};
|
|
|
|
if (prefersHls) {
|
|
if (!startHls(true)) {
|
|
if (video.canPlayType('application/vnd.apple.mpegurl')) startNative();
|
|
else advanceOrFail('HLS is not supported in this browser.');
|
|
}
|
|
} else {
|
|
startNative();
|
|
}
|
|
|
|
video.onerror = () => {
|
|
if (!usingHls && canUseHls && !hlsTried && !isDirectMedia) {
|
|
if (startHls(true)) return;
|
|
}
|
|
advanceOrFail('Video failed to load.');
|
|
};
|
|
};
|
|
|
|
showBuffering(true);
|
|
attempt(0);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------
|
|
App.player.open = function(source, opts) {
|
|
// Reentrant call (a second video opened before the first settled):
|
|
// tear down the previous session's video/listeners in place, but
|
|
// don't pop the history entry bindClose() already pushed for it --
|
|
// it's reused below instead of stacking a second one that close()'s
|
|
// single history.back() could never fully unwind. Also clears the
|
|
// abandoned session's own loading spinner, since its card would
|
|
// otherwise never hear about the takeover.
|
|
const reopening = !!(cp.container && cp.container.classList.contains('open'));
|
|
if (reopening) {
|
|
cp.attemptToken++;
|
|
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;
|
|
cp.activeUrl = '';
|
|
|
|
const isLive = !!(source && typeof source === 'object' &&
|
|
(source.isLive || (source.meta && source.meta.isLive)));
|
|
cp.container.classList.toggle('is-live', isLive);
|
|
|
|
const titleText = q('.cp-title-text');
|
|
if (titleText) {
|
|
titleText.textContent = (source && (source.title || (source.meta && source.meta.title))) || '';
|
|
requestAnimationFrame(measureTitle);
|
|
}
|
|
|
|
// Ambient backdrop: a blurred copy of the poster fills any letterbox
|
|
// bars behind the contained video.
|
|
const surface = q('.cp-surface');
|
|
if (surface) {
|
|
let poster = (source && (source.thumb || (source.meta && (source.meta.thumbnail || source.meta.thumb)))) || '';
|
|
if (!poster && cp.originEl) {
|
|
const img = cp.originEl.querySelector('img');
|
|
if (img) poster = img.currentSrc || img.src || '';
|
|
}
|
|
if (poster) surface.style.setProperty('--poster', `url("${poster.replace(/"/g, '%22')}")`);
|
|
else surface.style.removeProperty('--poster');
|
|
}
|
|
|
|
bindFavorite(source);
|
|
bindTimeline(cp.video);
|
|
bindTransport(cp.video);
|
|
|
|
cp.source = source;
|
|
const bindFormats = () => App.customPlayer.bindFormatMenu(q('.cp-format-btn'), q('.cp-format-menu'), source, (fmt) => {
|
|
cp.formatOverride = fmt;
|
|
const resumeAt = cp.video.currentTime || 0;
|
|
playSources(source, { resumeAt, originEl: cp.originEl });
|
|
}, { getCurrentUrl: () => cp.activeUrl, onOpen: wakeHud });
|
|
let destroyFormatMenu = bindFormats();
|
|
addCleanup(() => destroyFormatMenu());
|
|
const meta = (source && typeof source === 'object') ? (source.meta || source) : null;
|
|
const hasFormats = !!(meta && Array.isArray(meta.formats) && meta.formats.length);
|
|
// Items that arrive without formats -- a listing card clicked before its
|
|
// hover-resolve finished, or a favorite (which deliberately stores no
|
|
// resolved formats, since their URLs expire) -- carry only a page URL,
|
|
// and they'd also show an empty quality menu. Resolve them first: the
|
|
// page-URL fallback makes /api/stream re-run yt-dlp on every single
|
|
// request, which is slow and fails outright on some sites, whereas a
|
|
// resolved format is a real media URL with the extractor's headers --
|
|
// the same path a hovered card plays through.
|
|
let deferredStart = false;
|
|
if (!hasFormats && App.videos && typeof App.videos.ensureFormats === 'function') {
|
|
deferredStart = true;
|
|
App.videos.ensureFormats(source).then((resolved) => {
|
|
// A later open() (or a close) may have taken over in the
|
|
// meantime; that session owns the player now.
|
|
if (cp.source !== source) return;
|
|
if (resolved) {
|
|
destroyFormatMenu();
|
|
destroyFormatMenu = bindFormats();
|
|
}
|
|
playSources(source, { originEl: cp.originEl });
|
|
});
|
|
}
|
|
bindGestures(cp.video);
|
|
bindHoverHud();
|
|
bindKeyboard(cp.video);
|
|
bindClose();
|
|
bindBufferingIndicator(cp.video);
|
|
|
|
cp.container.classList.add('open');
|
|
cp.container.setAttribute('aria-hidden', 'false');
|
|
document.body.style.overflow = 'hidden';
|
|
wakeHud();
|
|
|
|
// Already-resolved sources start immediately; unresolved ones start from
|
|
// the ensureFormats() callback above (the spinner is already up).
|
|
if (!deferredStart) playSources(source, { originEl: cp.originEl });
|
|
};
|
|
|
|
App.player.close = function(opts) {
|
|
if (!cp.container || !cp.container.classList.contains('open')) return;
|
|
cp.attemptToken++; // void any in-flight attempt()/HEAD-probe callbacks
|
|
|
|
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;
|
|
cp.activeUrl = '';
|
|
};
|
|
})();
|