Tick the playing quality, and hide the menu with the HUD

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
This commit is contained in:
Simon
2026-09-05 22:57:04 +00:00
parent d508263946
commit 52d7802491
4 changed files with 87 additions and 13 deletions

View File

@@ -1825,7 +1825,10 @@ body.theme-light .favorite-btn {
} }
.cp-format-option { .cp-format-option {
padding: 9px 18px; display: flex;
align-items: center;
gap: 10px;
padding: 9px 18px 9px 14px;
border: none; border: none;
background: transparent; background: transparent;
color: var(--text-primary); color: var(--text-primary);
@@ -1835,12 +1838,27 @@ body.theme-light .favorite-btn {
white-space: nowrap; white-space: nowrap;
} }
/* A fixed-width tick column so every label starts on the same x, with only
the playing format's tick actually inked. */
.cp-format-option::before {
content: '\2713';
width: 1em;
flex: none;
opacity: 0;
font-size: 12px;
}
.cp-format-option:hover { .cp-format-option:hover {
background: var(--bg-tertiary); background: var(--bg-tertiary);
} }
.cp-format-option.is-active { .cp-format-option.is-active {
color: var(--accent); color: var(--accent);
font-weight: 600;
}
.cp-format-option.is-active::before {
opacity: 1;
} }
/* `.cp-error`/`.cp-replay-btn`/`.cp-format-menu` above set `display` on the /* `.cp-error`/`.cp-replay-btn`/`.cp-format-menu` above set `display` on the

View File

@@ -91,8 +91,12 @@ App.customPlayer = App.customPlayer || {};
const height = App.videos.coerceNumber(fmt.height); const height = App.videos.coerceNumber(fmt.height);
const fps = App.videos.coerceNumber(fmt.fps); const fps = App.videos.coerceNumber(fmt.fps);
if (height) parts.push(`${height}p${fps > 30 ? Math.round(fps) : ''}`); if (height) parts.push(`${height}p${fps > 30 ? Math.round(fps) : ''}`);
const ext = (fmt.ext || fmt.video_ext || '').toString(); // The container (mp4/webm) tells the viewer nothing useful about a
if (ext) parts.push(ext); // quality choice. The extractor's own note does -- but only when it
// says something the quality doesn't already ("HDR", "source", a
// codec), so drop one that merely restates it ("1080p", "1080p60").
const note = (fmt.format_note || '').toString().trim();
if (note && note.toLowerCase() !== (parts[0] || '').toLowerCase()) parts.push(note);
if (!parts.length) { if (!parts.length) {
const vcodec = (fmt.vcodec || '').toString(); const vcodec = (fmt.vcodec || '').toString();
parts.push(vcodec && vcodec !== 'none' ? vcodec : 'Auto'); parts.push(vcodec && vcodec !== 'none' ? vcodec : 'Auto');
@@ -114,8 +118,15 @@ App.customPlayer = App.customPlayer || {};
// calling onSelect(fmt) when the user picks one. Hides the button when // calling onSelect(fmt) when the user picks one. Hides the button when
// there's nothing to pick from. Shared by the standalone player and the // there's nothing to pick from. Shared by the standalone player and the
// reels feed so both present an identical menu. Returns a destroy() fn. // reels feed so both present an identical menu. Returns a destroy() fn.
App.customPlayer.bindFormatMenu = function(btn, menu, videoData, onSelect) { // `options.getCurrentUrl` (optional) returns the URL the player is actually
// feeding to the media element right now; the matching entry is marked
// active every time the menu opens. Reading it live rather than at bind
// time keeps the mark honest when playback moved on by itself -- an
// automatic pick, a fallback to the next candidate after a failure, or a
// re-resolve -- not just when the viewer chose from this menu.
App.customPlayer.bindFormatMenu = function(btn, menu, videoData, onSelect, opts) {
if (!btn || !menu) return function destroy() {}; if (!btn || !menu) return function destroy() {};
const getCurrentUrl = (opts && opts.getCurrentUrl) || null;
const options = App.customPlayer.buildFormatOptions(videoData); const options = App.customPlayer.buildFormatOptions(videoData);
if (!options.length) { if (!options.length) {
btn.hidden = true; btn.hidden = true;
@@ -126,8 +137,27 @@ App.customPlayer = App.customPlayer || {};
btn.hidden = false; btn.hidden = false;
menu.hidden = true; menu.hidden = true;
menu.innerHTML = options.map((opt, i) => menu.innerHTML = options.map((opt, i) =>
`<button class="cp-format-option" type="button" data-index="${i}">${opt.label}</button>` `<button class="cp-format-option" type="button" role="menuitemradio" aria-checked="false" data-index="${i}">${opt.label}</button>`
).join(''); ).join('');
const markActive = (activeBtn) => {
menu.querySelectorAll('.cp-format-option').forEach((b) => {
const isActive = b === activeBtn;
b.classList.toggle('is-active', isActive);
b.setAttribute('aria-checked', isActive ? 'true' : 'false');
});
};
const syncActive = () => {
const current = getCurrentUrl ? (getCurrentUrl() || '') : '';
let match = null;
if (current) {
options.forEach((opt, i) => {
if (!match && opt.fmt && opt.fmt.url === current) {
match = menu.querySelector(`.cp-format-option[data-index="${i}"]`);
}
});
}
markActive(match);
};
const cleanups = []; const cleanups = [];
menu.querySelectorAll('.cp-format-option').forEach((optBtn) => { menu.querySelectorAll('.cp-format-option').forEach((optBtn) => {
const onClick = (event) => { const onClick = (event) => {
@@ -135,8 +165,7 @@ App.customPlayer = App.customPlayer || {};
const idx = parseInt(optBtn.dataset.index, 10); const idx = parseInt(optBtn.dataset.index, 10);
const opt = options[idx]; const opt = options[idx];
menu.hidden = true; menu.hidden = true;
menu.querySelectorAll('.cp-format-option').forEach((b) => b.classList.remove('is-active')); markActive(optBtn);
optBtn.classList.add('is-active');
if (opt) onSelect(opt.fmt); if (opt) onSelect(opt.fmt);
}; };
optBtn.addEventListener('click', onClick); optBtn.addEventListener('click', onClick);
@@ -144,6 +173,13 @@ App.customPlayer = App.customPlayer || {};
}); });
const onBtnClick = (event) => { const onBtnClick = (event) => {
event.stopPropagation(); event.stopPropagation();
if (menu.hidden) {
syncActive();
// Opening the menu restarts the HUD's idle countdown: the menu
// hides with the HUD, and the viewer needs the full window to
// read the list, not whatever was left of the previous one.
if (opts && opts.onOpen) opts.onOpen();
}
menu.hidden = !menu.hidden; menu.hidden = !menu.hidden;
}; };
btn.addEventListener('click', onBtnClick); btn.addEventListener('click', onBtnClick);

View File

@@ -52,7 +52,11 @@ App.feed = App.feed || {};
if (hudIdleTimer) clearTimeout(hudIdleTimer); if (hudIdleTimer) clearTimeout(hudIdleTimer);
hudIdleTimer = setTimeout(() => { hudIdleTimer = setTimeout(() => {
hudIdleTimer = null; hudIdleTimer = null;
if (state.feedOpen) document.body.classList.add('feed-hud-idle'); if (!state.feedOpen) return;
document.body.classList.add('feed-hud-idle');
// The quality menu only fades with the rest of the HUD if we close
// it: it's an opened popover, not a permanently mounted control.
document.querySelectorAll('.feed-format-menu').forEach((menu) => { menu.hidden = true; });
}, HUD_IDLE_MS); }, HUD_IDLE_MS);
}; };
@@ -293,7 +297,8 @@ App.feed = App.feed || {};
slide.classList.remove('is-loaded'); slide.classList.remove('is-loaded');
loadSlideSource(slide, videoData, true); loadSlideSource(slide, videoData, true);
}; };
const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick); const bindFormats = () => App.customPlayer.bindFormatMenu(formatBtn, formatMenu, videoData, onFormatPick,
{ getCurrentUrl: () => slide._activeUrl || '', onOpen: wakeHud });
let destroyFormatMenu = bindFormats(); let destroyFormatMenu = bindFormats();
cleanups.push(() => destroyFormatMenu()); cleanups.push(() => destroyFormatMenu());
// A slide can go active before its formats have been resolved (feed items // A slide can go active before its formats have been resolved (feed items
@@ -357,6 +362,8 @@ App.feed = App.feed || {};
markSlideFailed(slide); markSlideFailed(slide);
return; return;
} }
// What's actually on screen, so the quality menu can tick it.
slide._activeUrl = resolved.url;
const streamUrl = App.videos.buildStreamUrlFromSource(resolved); const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url); const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
@@ -440,7 +447,7 @@ App.feed = App.feed || {};
<img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt=""> <img class="icon-svg" src="https://cdn.jsdelivr.net/npm/heroicons@2.0.13/24/outline/arrow-top-right-on-square.svg" alt="">
</button> </button>
<button class="cp-format-btn feed-format-btn" type="button" aria-label="Quality" hidden>HD</button> <button class="cp-format-btn feed-format-btn" type="button" aria-label="Quality" hidden>HD</button>
<div class="cp-format-menu feed-format-menu" hidden></div> <div class="cp-format-menu feed-format-menu" role="menu" hidden></div>
<div class="cp-flash feed-flash" aria-hidden="true"></div> <div class="cp-flash feed-flash" aria-hidden="true"></div>
<div class="feed-info"> <div class="feed-info">
<h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4> <h4 class="feed-title"><span class="feed-title-text">${v.title || ''}</span></h4>

View File

@@ -24,6 +24,7 @@ App.player = App.player || {};
idleTimer: null, idleTimer: null,
originEl: null, originEl: null,
hudHovered: false, // mouse resting on the controls (desktop) 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 attemptToken: 0 // bumps on every open()/format switch to void stale async callbacks
}; };
@@ -104,7 +105,7 @@ App.player = App.player || {};
</div> </div>
</div> </div>
</div> </div>
<div class="cp-format-menu" hidden></div> <div class="cp-format-menu" role="menu" hidden></div>
`; `;
return container; return container;
} }
@@ -152,6 +153,11 @@ App.player = App.player || {};
return; return;
} }
if (cp.container) cp.container.classList.add('cp-hud-idle'); 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); }, HUD_IDLE_MS);
} }
@@ -497,7 +503,9 @@ App.player = App.player || {};
// The bars, not .cp-hud itself: the HUD wrapper is pointer-events:none // 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 // (so it never eats gestures over the video) and only its children are
// hit-testable. // hit-testable.
const bars = [q('.cp-top-bar'), q('.cp-bottom-bar')].filter(Boolean); // 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) => { bars.forEach((bar) => {
bar.addEventListener('pointerenter', onEnterHud); bar.addEventListener('pointerenter', onEnterHud);
bar.addEventListener('pointerleave', onLeaveHud); bar.addEventListener('pointerleave', onLeaveHud);
@@ -756,6 +764,9 @@ App.player = App.player || {};
const startPlayback = () => { const startPlayback = () => {
if (playbackStarted) return; if (playbackStarted) return;
playbackStarted = true; 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(); clearLoading();
hideError(); hideError();
showBuffering(false); showBuffering(false);
@@ -864,6 +875,7 @@ App.player = App.player || {};
cp.container = buildContainer(); cp.container = buildContainer();
cp.video = q('.cp-video'); cp.video = q('.cp-video');
cp.formatOverride = null; cp.formatOverride = null;
cp.activeUrl = '';
const isLive = !!(source && typeof source === 'object' && const isLive = !!(source && typeof source === 'object' &&
(source.isLive || (source.meta && source.meta.isLive))); (source.isLive || (source.meta && source.meta.isLive)));
@@ -897,7 +909,7 @@ App.player = App.player || {};
cp.formatOverride = fmt; cp.formatOverride = fmt;
const resumeAt = cp.video.currentTime || 0; const resumeAt = cp.video.currentTime || 0;
playSources(source, { resumeAt, originEl: cp.originEl }); playSources(source, { resumeAt, originEl: cp.originEl });
}); }, { getCurrentUrl: () => cp.activeUrl, onOpen: wakeHud });
let destroyFormatMenu = bindFormats(); let destroyFormatMenu = bindFormats();
addCleanup(() => destroyFormatMenu()); addCleanup(() => destroyFormatMenu());
const meta = (source && typeof source === 'object') ? (source.meta || source) : null; const meta = (source && typeof source === 'object') ? (source.meta || source) : null;
@@ -977,5 +989,6 @@ App.player = App.player || {};
cp.data = null; cp.data = null;
cp.source = null; // voids a still-pending format resolve for this open cp.source = null; // voids a still-pending format resolve for this open
cp.formatOverride = null; cp.formatOverride = null;
cp.activeUrl = '';
}; };
})(); })();