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 || {}; - +

${v.title || ''}

diff --git a/frontend/js/player.js b/frontend/js/player.js index f358a86..4859170 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -24,6 +24,7 @@ App.player = App.player || {}; 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 }; @@ -104,7 +105,7 @@ App.player = App.player || {};
- + `; return container; } @@ -152,6 +153,11 @@ App.player = App.player || {}; 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); } @@ -497,7 +503,9 @@ App.player = App.player || {}; // The bars, not .cp-hud itself: the HUD wrapper is pointer-events:none // (so it never eats gestures over the video) and only its children are // hit-testable. - const bars = [q('.cp-top-bar'), q('.cp-bottom-bar')].filter(Boolean); + // 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); @@ -756,6 +764,9 @@ App.player = App.player || {}; 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); @@ -864,6 +875,7 @@ App.player = App.player || {}; 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))); @@ -897,7 +909,7 @@ App.player = App.player || {}; 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; @@ -977,5 +989,6 @@ App.player = App.player || {}; cp.data = null; cp.source = null; // voids a still-pending format resolve for this open cp.formatOverride = null; + cp.activeUrl = ''; }; })();