diff --git a/frontend/css/style.css b/frontend/css/style.css
index 3d954d5..4737d80 100644
--- a/frontend/css/style.css
+++ b/frontend/css/style.css
@@ -1825,7 +1825,10 @@ body.theme-light .favorite-btn {
}
.cp-format-option {
- padding: 9px 18px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 9px 18px 9px 14px;
border: none;
background: transparent;
color: var(--text-primary);
@@ -1835,12 +1838,27 @@ body.theme-light .favorite-btn {
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 {
background: var(--bg-tertiary);
}
.cp-format-option.is-active {
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
diff --git a/frontend/js/customPlayer.js b/frontend/js/customPlayer.js
index 5ab6b15..d255761 100644
--- a/frontend/js/customPlayer.js
+++ b/frontend/js/customPlayer.js
@@ -91,8 +91,12 @@ App.customPlayer = App.customPlayer || {};
const height = App.videos.coerceNumber(fmt.height);
const fps = App.videos.coerceNumber(fmt.fps);
if (height) parts.push(`${height}p${fps > 30 ? Math.round(fps) : ''}`);
- const ext = (fmt.ext || fmt.video_ext || '').toString();
- if (ext) parts.push(ext);
+ // The container (mp4/webm) tells the viewer nothing useful about a
+ // 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) {
const vcodec = (fmt.vcodec || '').toString();
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
// 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.
- 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() {};
+ const getCurrentUrl = (opts && opts.getCurrentUrl) || null;
const options = App.customPlayer.buildFormatOptions(videoData);
if (!options.length) {
btn.hidden = true;
@@ -126,8 +137,27 @@ App.customPlayer = App.customPlayer || {};
btn.hidden = false;
menu.hidden = true;
menu.innerHTML = options.map((opt, i) =>
- ``
+ ``
).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 = [];
menu.querySelectorAll('.cp-format-option').forEach((optBtn) => {
const onClick = (event) => {
@@ -135,8 +165,7 @@ App.customPlayer = App.customPlayer || {};
const idx = parseInt(optBtn.dataset.index, 10);
const opt = options[idx];
menu.hidden = true;
- menu.querySelectorAll('.cp-format-option').forEach((b) => b.classList.remove('is-active'));
- optBtn.classList.add('is-active');
+ markActive(optBtn);
if (opt) onSelect(opt.fmt);
};
optBtn.addEventListener('click', onClick);
@@ -144,6 +173,13 @@ App.customPlayer = App.customPlayer || {};
});
const onBtnClick = (event) => {
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;
};
btn.addEventListener('click', onBtnClick);
diff --git a/frontend/js/feed.js b/frontend/js/feed.js
index eddf0dd..9897fe3 100644
--- a/frontend/js/feed.js
+++ b/frontend/js/feed.js
@@ -52,7 +52,11 @@ App.feed = App.feed || {};
if (hudIdleTimer) clearTimeout(hudIdleTimer);
hudIdleTimer = setTimeout(() => {
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);
};
@@ -293,7 +297,8 @@ App.feed = App.feed || {};
slide.classList.remove('is-loaded');
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();
cleanups.push(() => destroyFormatMenu());
// A slide can go active before its formats have been resolved (feed items
@@ -357,6 +362,8 @@ App.feed = App.feed || {};
markSlideFailed(slide);
return;
}
+ // What's actually on screen, so the quality menu can tick it.
+ slide._activeUrl = resolved.url;
const streamUrl = App.videos.buildStreamUrlFromSource(resolved);
const isHls = resolved.isLive ? true : /\.m3u8($|\?)/i.test(resolved.url);
@@ -440,7 +447,7 @@ App.feed = App.feed || {};
-