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