Add modern UI enhancements over the classic redesign

Layered progressive polish on the warm classic + brass theme:
- Card entrance animation on first mount (virtualizer-aware)
- Cursor-tracking brass spotlight border on cards
- Thumbnail skeleton shimmer until the poster paints
- Hover video preview after a short dwell (only when formats resolved)
- View Transition + blurred-poster ambient backdrop on player open
- Favorite heart pop + expanding ring on add
- ⌘K command palette (search, theme, density, reels, source/channel)
- Scroll-progress bar + back-to-top FAB
- Grid density toggle (comfortable/compact)
- Reels HUD: serif title, brass scrubber, muted-state pulse

All new motion respects prefers-reduced-motion; no JS/HTML structure
changes to the core grid/feed. New glue lives in frontend/js/enhance.js.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Simon
2026-06-30 08:25:37 +00:00
parent 382a637b95
commit 138c3224de
11 changed files with 715 additions and 51 deletions

View File

@@ -548,6 +548,7 @@ App.videos = App.videos || {};
// ---------------------------------------------------------------------
App.virtualGrid = (function() {
const mounted = new Map(); // loadedVideos index -> card element
const revealed = new Set(); // indices that have played their entrance once
const layout = []; // index -> { top, left, width, height, col, posInCol }
const heightCache = new Map(); // shape signature -> estimated px height
let colItems = []; // col -> ordered list of item indices in that column
@@ -575,7 +576,10 @@ App.videos = App.videos || {};
el.style.padding = '0';
const inner = el.clientWidth - padX * 2;
if (inner <= 0) return false;
cols = mobile ? 2 : Math.max(1, Math.floor((inner + gap) / (260 + gap)));
// Density toggle (see enhance.js / settings) tunes the minimum card
// width, so "compact" packs more columns at the same viewport width.
const minCardW = document.body.dataset.density === 'compact' ? 210 : 260;
cols = mobile ? 2 : Math.max(1, Math.floor((inner + gap) / (minCardW + gap)));
colWidth = (inner - gap * (cols - 1)) / cols;
return true;
};
@@ -682,14 +686,22 @@ App.videos = App.videos || {};
if (!v || !l) return;
const card = App.videos.buildCard(v);
place(card, l);
// Entrance animation only the first time an index appears, so cards
// don't re-animate every time they scroll back into the window.
if (!revealed.has(i)) {
revealed.add(i);
card.classList.add('is-revealing');
card.addEventListener('animationend', () => card.classList.remove('is-revealing'), { once: true });
}
grid().appendChild(card);
mounted.set(i, card);
// Once the thumbnail loads, drop the 16:9 placeholder so it shows at
// its true aspect ratio, then correct this card's height.
// its true aspect ratio, clear the shimmer, then correct the height.
const img = card.querySelector('img');
if (img) {
const reveal = () => {
img.style.aspectRatio = 'auto';
img.classList.add('is-loaded');
if (mounted.get(i) === card) correct(i);
};
if (img.complete && img.naturalHeight > 0) requestAnimationFrame(reveal);
@@ -807,6 +819,7 @@ App.videos = App.videos || {};
const reset = function() {
mounted.forEach((card, i) => unmount(i));
mounted.clear();
revealed.clear(); // new result set should animate in again
layout.length = 0;
colBottoms = [];
colItems = [];
@@ -1190,6 +1203,12 @@ App.videos = App.videos || {};
return App.videos.buildStreamUrlFromSource(App.videos.resolveStreamSource(videoOrUrl, options));
};
// Lets enhancement layers (e.g. hover preview) recover the video object that
// backs a mounted card without reaching into the virtualizer internals.
App.videos.getVideoForCard = function(card) {
return cardVideo.get(card);
};
App.videos.downloadVideo = function(video) {
if (!video) return;
const streamUrl = App.videos.buildStreamUrl(video, { applyPreferredQuality: false });