From 138c3224de9a91fdc3ae3dc73c1dae9bd3aad622 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 30 Jun 2026 08:25:37 +0000 Subject: [PATCH] Add modern UI enhancements over the classic redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 1 + frontend/css/style.css | 408 ++++++++++++++++++++++++++++++++++----- frontend/index.html | 20 +- frontend/js/enhance.js | 252 ++++++++++++++++++++++++ frontend/js/favorites.js | 10 + frontend/js/feed.js | 3 + frontend/js/main.js | 1 + frontend/js/player.js | 20 +- frontend/js/storage.js | 9 + frontend/js/ui.js | 19 ++ frontend/js/videos.js | 23 ++- 11 files changed, 715 insertions(+), 51 deletions(-) create mode 100644 frontend/js/enhance.js diff --git a/.gitignore b/.gitignore index 8cd93c6..70aa409 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ */__pycache__/* .tmp frontend/dist/* +.playwright-mcp/* diff --git a/frontend/css/style.css b/frontend/css/style.css index cb1c9e3..1b67e5c 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -1,66 +1,89 @@ * { margin: 0; padding: 0; box-sizing: border-box; } :root { - --bg-primary: #000000; - --bg-secondary: rgba(0, 0, 0, 0.85); - --bg-tertiary: rgba(255, 255, 255, 0.05); - --text-primary: #ffffff; - --text-secondary: rgba(255, 255, 255, 0.8); - --accent: #ffffff; - --accent-hover: #e6e6e6; - --border: rgba(255, 255, 255, 0.1); - --border-hover: rgba(255, 255, 255, 0.22); - --shadow: rgba(0, 0, 0, 0.4); + /* Warm "classic" dark: deep charcoal with a hint of brown, never pure black. */ + --bg-primary: #14110d; + --bg-secondary: rgba(28, 24, 18, 0.86); + --bg-tertiary: rgba(244, 232, 212, 0.06); + --text-primary: #f4ece0; + --text-secondary: rgba(244, 236, 224, 0.62); + /* Modern brass accent — the single chromatic note against the neutrals. */ + --accent: #c9a567; + --accent-hover: #d9b87f; + --border: rgba(244, 232, 212, 0.12); + --border-hover: rgba(201, 165, 103, 0.45); + --shadow: rgba(8, 6, 3, 0.5); --font-body: 'Sora', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; - --font-display: 'Space Grotesk', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; - --font-logo: 'Lobster', 'Space Grotesk', 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif; - --radius-lg: 16px; - --radius-md: 12px; - --radius-sm: 8px; + --font-display: 'Fraunces', 'Iowan Old Style', 'Palatino Linotype', Georgia, serif; + --font-logo: 'Fraunces', 'Iowan Old Style', 'Palatino Linotype', Georgia, serif; + --radius-lg: 14px; + --radius-md: 10px; + --radius-sm: 7px; --grid-max: 1480px; - --focus-ring: rgba(255, 255, 255, 0.18); + --focus-ring: rgba(201, 165, 103, 0.32); color-scheme: dark; } html, body { height: 100%; } +html { scroll-behavior: smooth; } body { margin: 0; background: - radial-gradient(1200px 600px at 12% -10%, rgba(255, 255, 255, 0.08), transparent 60%), - radial-gradient(900px 500px at 110% 0%, rgba(255, 255, 255, 0.06), transparent 55%), + radial-gradient(1100px 560px at 14% -12%, rgba(201, 165, 103, 0.10), transparent 60%), + radial-gradient(900px 520px at 108% 4%, rgba(201, 165, 103, 0.06), transparent 58%), var(--bg-primary); + background-attachment: fixed; color: var(--text-primary); font-family: var(--font-body); overflow-x: hidden; font-size: 14px; line-height: 1.5; + -webkit-font-smoothing: antialiased; + text-rendering: optimizeLegibility; } body.drawer-open { overflow: hidden; } +/* Subtle film grain so large flat surfaces don't read as sterile vector flats. */ +body::before { + content: ''; + position: fixed; + inset: 0; + z-index: 50; + pointer-events: none; + opacity: 0.035; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +body.theme-light::before { + opacity: 0.05; + mix-blend-mode: multiply; +} + body:not(.theme-light) { color-scheme: dark; } body.theme-light { - --bg-primary: #ffffff; - --bg-secondary: rgba(0, 0, 0, 0.08); - --bg-tertiary: rgba(0, 0, 0, 0.07); - --text-primary: #000000; - --text-secondary: rgba(0, 0, 0, 0.7); - --accent: #000000; - --accent-hover: #1a1a1a; - --border: rgba(0, 0, 0, 0.08); - --border-hover: rgba(0, 0, 0, 0.18); - --shadow: rgba(0, 0, 0, 0.1); - --focus-ring: rgba(0, 0, 0, 0.12); + /* Warm ivory "paper" with a warm-ink text — editorial, not stark white/black. */ + --bg-primary: #f6f1e7; + --bg-secondary: rgba(255, 252, 245, 0.86); + --bg-tertiary: rgba(43, 33, 18, 0.05); + --text-primary: #241d12; + --text-secondary: rgba(36, 29, 18, 0.62); + --accent: #9a7338; + --accent-hover: #835f2c; + --border: rgba(43, 33, 18, 0.12); + --border-hover: rgba(154, 115, 56, 0.5); + --shadow: rgba(74, 56, 28, 0.14); + --focus-ring: rgba(154, 115, 56, 0.28); color-scheme: light; background: - radial-gradient(1200px 600px at 12% -10%, rgba(0, 0, 0, 0.05), transparent 60%), - radial-gradient(900px 500px at 110% 0%, rgba(0, 0, 0, 0.03), transparent 55%), + radial-gradient(1100px 560px at 14% -12%, rgba(154, 115, 56, 0.08), transparent 60%), + radial-gradient(900px 520px at 108% 4%, rgba(154, 115, 56, 0.05), transparent 58%), var(--bg-primary); } @@ -71,7 +94,7 @@ body.theme-light { justify-content: space-between; align-items: center; padding: 0 clamp(16px, 3vw, 36px); - background: linear-gradient(180deg, rgba(0, 0, 0, 0.8), rgba(0, 0, 0, 0.55)); + background: linear-gradient(180deg, rgba(20, 17, 13, 0.86), rgba(20, 17, 13, 0.62)); position: sticky; top: 0; z-index: 100; @@ -82,17 +105,17 @@ body.theme-light { } body.theme-light .top-bar { - background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 255, 255, 0.82)); + background: linear-gradient(180deg, rgba(246, 241, 231, 0.94), rgba(246, 241, 231, 0.78)); } .logo { - font-size: 26px; - font-weight: 400; - letter-spacing: 0.3px; - color: var(--accent); + font-size: 28px; + font-weight: 500; + letter-spacing: -0.01em; + color: var(--text-primary); font-family: var(--font-logo); + font-optical-sizing: auto; flex-shrink: 0; - text-shadow: 0 2px 8px var(--shadow); } .search-container { @@ -181,29 +204,35 @@ body.theme-light .top-bar { color: var(--accent); } +.icon-btn:active { + transform: scale(0.92); +} + :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -/* CDN-served icon images (Heroicons) */ +/* CDN-served icon images (Heroicons). Black SVG sources, tinted via filter: + neutral to match text by default, brass on hover for the accent moment. */ .icon-svg { width: 24px; height: 24px; display: block; - filter: invert(100%) saturate(0%); + filter: invert(92%) sepia(6%) saturate(220%) hue-rotate(2deg) brightness(101%); } +/* Brass tint approximated from a pure-black source. */ .icon-btn:hover .icon-svg { - filter: none; + filter: invert(68%) sepia(42%) saturate(560%) hue-rotate(1deg) brightness(94%) contrast(86%); } body.theme-light .icon-svg { - filter: invert(0%) saturate(0%); + filter: invert(12%) sepia(20%) saturate(620%) hue-rotate(2deg) brightness(95%); } body.theme-light .icon-btn:hover .icon-svg { - filter: none; + filter: invert(46%) sepia(36%) saturate(720%) hue-rotate(1deg) brightness(82%) contrast(88%); } /* Hamburger Menu */ @@ -485,6 +514,11 @@ body.theme-light .input-row input:focus { .btn-secondary:hover { background: var(--bg-tertiary); + border-color: var(--border-hover); +} + +.btn-secondary:active { + transform: translateY(1px); } .sources-list { @@ -909,15 +943,16 @@ body.theme-light .setting-item select option { } .video-card h4 { - font-size: 14px; + font-size: 15px; font-weight: 500; - padding: 12px; - line-height: 1.4; + padding: 12px 12px 10px; + line-height: 1.3; color: var(--text-primary); margin: 0; flex: 1; font-family: var(--font-display); - letter-spacing: -0.2px; + font-optical-sizing: auto; + letter-spacing: -0.01em; white-space: nowrap; overflow: hidden; position: relative; @@ -1721,3 +1756,282 @@ body.feed-hud-idle .feed-fav-btn, body.feed-hud-idle .mode-toggle-btn { opacity: 0; } + +/* ===================================================================== + Modern enhancements (see enhance.js) + ===================================================================== */ + +/* --- Card entrance: cards rise/fade in the first time they mount -------- */ +@keyframes card-rise { + from { opacity: 0; transform: translateY(18px) scale(0.985); } + to { opacity: 1; transform: none; } +} + +.video-card.is-revealing { + animation: card-rise 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) both; +} + +/* --- Cursor-tracking brass spotlight border on hover ------------------- */ +.video-card::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; + padding: 1px; + background: radial-gradient(180px circle at var(--mx, 50%) var(--my, 50%), + rgba(201, 165, 103, 0.65), rgba(201, 165, 103, 0) 60%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + mask-composite: exclude; + opacity: 0; + transition: opacity 0.25s ease; + pointer-events: none; + z-index: 3; +} + +.video-card:hover::after { + opacity: 1; +} + +/* --- Thumbnail skeleton shimmer until the image paints ----------------- */ +@keyframes thumb-shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} + +.video-card img:not(.is-loaded) { + background-image: linear-gradient(100deg, + transparent 30%, rgba(255, 255, 255, 0.08) 50%, transparent 70%); + background-size: 200% 100%; + background-repeat: no-repeat; + animation: thumb-shimmer 1.3s ease-in-out infinite; +} + +body.theme-light .video-card img:not(.is-loaded) { + background-image: linear-gradient(100deg, + transparent 30%, rgba(43, 33, 18, 0.06) 50%, transparent 70%); +} + +/* --- Hover video preview (sits over the poster, under the buttons) ----- */ +.card-preview { + position: absolute; + top: 0; + left: 0; + width: 100%; + object-fit: cover; + background: #000; + z-index: 0; + opacity: 0; + transition: opacity 0.3s ease; +} + +.video-card.is-previewing .card-preview { + opacity: 1; +} + +/* --- Favorite heart: pop + expanding brass ring on add ----------------- */ +@keyframes heart-pop { + 0% { transform: scale(1); } + 35% { transform: scale(1.35); } + 100% { transform: scale(1); } +} + +@keyframes heart-ring { + from { opacity: 0.8; transform: scale(0.6); } + to { opacity: 0; transform: scale(1.7); } +} + +.favorite-btn.just-favorited { + animation: heart-pop 0.45s ease; +} + +.favorite-btn.just-favorited::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid var(--accent); + animation: heart-ring 0.5s ease forwards; + pointer-events: none; +} + +/* --- Scroll progress bar ---------------------------------------------- */ +.scroll-progress { + position: fixed; + top: 0; + left: 0; + height: 2px; + width: 0; + background: linear-gradient(90deg, var(--accent), var(--accent-hover)); + z-index: 200; + transition: width 0.1s linear; + pointer-events: none; +} + +/* --- Back-to-top FAB --------------------------------------------------- */ +.back-to-top { + position: fixed; + right: 24px; + bottom: 154px; + width: 46px; + height: 46px; + border-radius: 50%; + border: 1px solid var(--border); + background: var(--bg-secondary); + color: var(--text-primary); + font-size: 20px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 10px 24px var(--shadow); + backdrop-filter: blur(12px); + opacity: 0; + transform: translateY(12px) scale(0.9); + pointer-events: none; + transition: opacity 0.3s ease, transform 0.3s ease, background 0.2s ease; + z-index: 2400; +} + +.back-to-top.is-visible { + opacity: 1; + transform: none; + pointer-events: auto; +} + +.back-to-top:hover { + background: var(--bg-tertiary); + border-color: var(--border-hover); +} + +.back-to-top:active { + transform: scale(0.92); +} + +/* --- Command palette (⌘K) --------------------------------------------- */ +.command-palette { + position: fixed; + inset: 0; + z-index: 4000; + display: none; + align-items: flex-start; + justify-content: center; + padding-top: 14vh; + background: rgba(10, 8, 4, 0.55); + backdrop-filter: blur(6px); +} + +.command-palette.open { + display: flex; +} + +.cmdk-box { + width: min(560px, 92vw); + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + box-shadow: 0 30px 70px var(--shadow); + overflow: hidden; + animation: card-rise 0.2s ease both; +} + +.cmdk-input { + width: 100%; + padding: 18px 20px; + border: none; + border-bottom: 1px solid var(--border); + background: transparent; + color: var(--text-primary); + font-size: 16px; + font-family: var(--font-body); +} + +.cmdk-input::placeholder { color: var(--text-secondary); } +.cmdk-input:focus { outline: none; } + +.cmdk-list { + max-height: 50vh; + overflow-y: auto; + padding: 8px; +} + +.cmdk-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + text-align: left; + padding: 11px 14px; + border: none; + background: transparent; + color: var(--text-primary); + border-radius: 10px; + cursor: pointer; + font: inherit; +} + +.cmdk-item.is-active { + background: var(--bg-tertiary); +} + +.cmdk-label { font-size: 14px; } + +.cmdk-hint { + font-size: 11px; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-secondary); + flex-shrink: 0; +} + +/* --- Player ambient backdrop: blurred poster behind the video --------- */ +.modal-content::before { + content: ''; + position: absolute; + inset: 0; + background-image: var(--poster); + background-size: cover; + background-position: center; + filter: blur(60px) saturate(1.3) brightness(0.5); + transform: scale(1.25); + opacity: 0.55; + z-index: 0; + pointer-events: none; +} + +.modal-content > * { + position: relative; + z-index: 1; +} + +/* --- Reels HUD polish: serif title + brass scrubber + mute pulse ------- */ +.feed-title { font-family: var(--font-display); } + +.feed-timeline-fill, +.feed-timeline-handle { background: #c9a567; } + +@keyframes mute-pulse { + 0% { transform: scale(0.8); opacity: 0.75; } + 100% { transform: scale(1.5); opacity: 0; } +} + +.feed-mute-btn.is-muted::after { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + border: 2px solid rgba(201, 165, 103, 0.7); + animation: mute-pulse 2s ease-out infinite; + pointer-events: none; +} + +/* --- View Transition timing (player open/close crossfade) -------------- */ +@media (prefers-reduced-motion: no-preference) { + ::view-transition-old(root), + ::view-transition-new(root) { + animation-duration: 0.32s; + } +} diff --git a/frontend/index.html b/frontend/index.html index 2ec6223..ca81573 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -7,10 +7,11 @@ - + +
@@ -90,6 +91,13 @@
+
+ + +
+
+
+ + @@ -174,6 +191,7 @@ + diff --git a/frontend/js/enhance.js b/frontend/js/enhance.js new file mode 100644 index 0000000..45540c5 --- /dev/null +++ b/frontend/js/enhance.js @@ -0,0 +1,252 @@ +window.App = window.App || {}; +App.enhance = App.enhance || {}; + +// Progressive UI enhancements layered on top of the core app. Everything here +// is non-essential polish: cursor-tracking card spotlight, a scroll-progress +// bar, a back-to-top button, a ⌘K command palette, and hover video previews. +// None of it is required for the app to function, so each piece fails soft. +(function() { + const fineHover = window.matchMedia('(hover: hover) and (pointer: fine)').matches; + const reduceMotion = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches; + + // ---- Cursor-tracking spotlight border on grid cards ---------------------- + // One delegated listener keeps per-card vars (--mx/--my) updated; the brass + // border gradient that reads them lives in CSS (.video-card::after). + function initSpotlight() { + const grid = document.getElementById('video-grid'); + if (!grid || !fineHover) return; + grid.addEventListener('pointermove', (e) => { + const card = e.target.closest('.video-card'); + if (!card) return; + const r = card.getBoundingClientRect(); + card.style.setProperty('--mx', (e.clientX - r.left) + 'px'); + card.style.setProperty('--my', (e.clientY - r.top) + 'px'); + }, { passive: true }); + } + + // ---- Scroll progress bar + back-to-top FAB ------------------------------- + function initScrollAffordances() { + const bar = document.getElementById('scroll-progress'); + const fab = document.getElementById('back-to-top'); + let ticking = false; + const onScroll = () => { + if (ticking) return; + ticking = true; + requestAnimationFrame(() => { + ticking = false; + const doc = document.documentElement; + const max = doc.scrollHeight - window.innerHeight; + const pct = max > 0 ? (window.scrollY / max) * 100 : 0; + if (bar) bar.style.width = pct.toFixed(2) + '%'; + if (fab) fab.classList.toggle('is-visible', window.scrollY > 700); + }); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + if (fab) { + fab.addEventListener('click', () => { + window.scrollTo({ top: 0, behavior: reduceMotion() ? 'auto' : 'smooth' }); + }); + } + } + + // ---- Hover video preview -------------------------------------------------- + // After a short dwell over a card, play a muted inline clip in place of the + // poster — but only if the video's real formats are already resolved (the + // grid resolves them lazily on hover/scroll anyway), so we never block or + // hammer the backend just to preview. + function initHoverPreview() { + const grid = document.getElementById('video-grid'); + if (!grid || !fineHover) return; + let dwellTimer = null; + let activeCard = null; + + const clearPreview = () => { + if (dwellTimer) { clearTimeout(dwellTimer); dwellTimer = null; } + if (activeCard) { + const vid = activeCard.querySelector('.card-preview'); + if (vid) { try { vid.pause(); } catch (e) {} vid.remove(); } + activeCard.classList.remove('is-previewing'); + activeCard = null; + } + }; + + const startPreview = (card) => { + if (!App.videos || typeof App.videos.getVideoForCard !== 'function') return; + const v = App.videos.getVideoForCard(card); + if (!v) return; + const meta = v.meta; + const ready = meta && Array.isArray(meta.formats) && meta.formats.length; + if (!ready) { + // Not resolved yet: kick it off so the *next* hover can preview. + if (typeof App.videos.resolveAndProbe === 'function') App.videos.resolveAndProbe(v); + return; + } + let url = ''; + try { url = App.videos.buildStreamUrl(v); } catch (e) { return; } + if (!url) return; + const img = card.querySelector('img'); + const vid = document.createElement('video'); + vid.className = 'card-preview'; + vid.muted = true; + vid.loop = true; + vid.playsInline = true; + vid.setAttribute('playsinline', ''); + vid.setAttribute('webkit-playsinline', ''); + vid.preload = 'auto'; + if (img) vid.style.height = img.getBoundingClientRect().height + 'px'; + vid.src = url; + vid.addEventListener('error', () => { if (vid.isConnected) vid.remove(); }, { once: true }); + card.appendChild(vid); + card.classList.add('is-previewing'); + const p = vid.play(); + if (p && p.catch) p.catch(() => {}); + }; + + grid.addEventListener('pointerover', (e) => { + const card = e.target.closest('.video-card'); + if (!card || card === activeCard) return; + clearPreview(); + activeCard = card; + dwellTimer = setTimeout(() => { dwellTimer = null; if (activeCard === card) startPreview(card); }, 600); + }); + grid.addEventListener('pointerout', (e) => { + const card = e.target.closest('.video-card'); + if (!card) return; + const to = e.relatedTarget; + if (to && card.contains(to)) return; // still inside the same card + if (card === activeCard) clearPreview(); + }); + window.addEventListener('scroll', clearPreview, { passive: true }); + } + + // ---- Command palette (⌘K / Ctrl+K) --------------------------------------- + function initCommandPalette() { + const palette = document.getElementById('command-palette'); + const input = document.getElementById('cmdk-input'); + const list = document.getElementById('cmdk-list'); + if (!palette || !input || !list) return; + + let actions = []; + let filtered = []; + let activeIndex = 0; + + const fireChange = (el) => el && el.dispatchEvent(new Event('change')); + + const buildActions = () => { + const out = []; + out.push({ label: 'Search videos', hint: 'Focus the search box', run: () => { + const s = document.getElementById('search-input'); if (s) { s.focus(); s.select(); } + }}); + const theme = (localStorage.getItem('theme') || 'dark'); + out.push({ label: `Switch to ${theme === 'light' ? 'dark' : 'light'} theme`, hint: 'Appearance', run: () => { + localStorage.setItem('theme', theme === 'light' ? 'dark' : 'light'); + if (App.ui && App.ui.applyTheme) App.ui.applyTheme(); + }}); + const density = (App.storage && App.storage.getDensity) ? App.storage.getDensity() : 'comfortable'; + out.push({ label: `Grid density: ${density === 'compact' ? 'comfortable' : 'compact'}`, hint: 'Layout', run: () => { + if (!App.storage) return; + App.storage.setDensity(density === 'compact' ? 'comfortable' : 'compact'); + if (App.ui && App.ui.applyDensity) App.ui.applyDensity(); + if (App.virtualGrid && App.virtualGrid.relayout) App.virtualGrid.relayout(); + }}); + out.push({ label: 'Toggle Reels view', hint: 'Playback', run: () => { if (App.feed) App.feed.toggle(); } }); + out.push({ label: 'Reload channel', hint: 'Refresh the current feed', run: () => { if (App.videos) App.videos.resetAndReload(); } }); + out.push({ label: 'Open Menu', hint: 'Source · channel · filters', run: () => { if (App.ui) App.ui.toggleDrawer('menu'); } }); + out.push({ label: 'Open Settings', hint: 'Preferences', run: () => { if (App.ui) App.ui.toggleDrawer('settings'); } }); + + const sourceSelect = document.getElementById('source-select'); + if (sourceSelect) { + Array.from(sourceSelect.options).forEach((opt) => { + if (opt.value === sourceSelect.value) return; + out.push({ label: opt.textContent, hint: 'Source', run: () => { sourceSelect.value = opt.value; fireChange(sourceSelect); } }); + }); + } + const channelSelect = document.getElementById('channel-select'); + if (channelSelect) { + Array.from(channelSelect.options).forEach((opt) => { + if (opt.value === channelSelect.value) return; + out.push({ label: opt.textContent, hint: 'Channel', run: () => { channelSelect.value = opt.value; fireChange(channelSelect); } }); + }); + } + return out; + }; + + const render = () => { + list.innerHTML = ''; + filtered.forEach((a, i) => { + const li = document.createElement('button'); + li.type = 'button'; + li.className = 'cmdk-item' + (i === activeIndex ? ' is-active' : ''); + li.innerHTML = ``; + li.querySelector('.cmdk-label').textContent = a.label; + li.querySelector('.cmdk-hint').textContent = a.hint || ''; + li.addEventListener('click', () => choose(i)); + li.addEventListener('pointermove', () => { if (activeIndex !== i) { activeIndex = i; render(); } }); + list.appendChild(li); + }); + }; + + const applyFilter = () => { + const q = input.value.trim().toLowerCase(); + filtered = q + ? actions.filter((a) => (a.label + ' ' + (a.hint || '')).toLowerCase().includes(q)) + : actions.slice(); + activeIndex = 0; + render(); + }; + + const choose = (i) => { + const a = filtered[i]; + close(); + if (a && a.run) a.run(); + }; + + const open = () => { + actions = buildActions(); + input.value = ''; + applyFilter(); + palette.classList.add('open'); + palette.setAttribute('aria-hidden', 'false'); + requestAnimationFrame(() => input.focus()); + }; + const close = () => { + palette.classList.remove('open'); + palette.setAttribute('aria-hidden', 'true'); + }; + App.enhance.openPalette = open; + + input.addEventListener('input', applyFilter); + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { e.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); scrollActive(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); scrollActive(); } + else if (e.key === 'Enter') { e.preventDefault(); choose(activeIndex); } + else if (e.key === 'Escape') { e.preventDefault(); close(); } + }); + const scrollActive = () => { + const el = list.children[activeIndex]; + if (el) el.scrollIntoView({ block: 'nearest' }); + }; + palette.addEventListener('click', (e) => { if (e.target === palette) close(); }); + + document.addEventListener('keydown', (e) => { + if ((e.metaKey || e.ctrlKey) && (e.key === 'k' || e.key === 'K')) { + e.preventDefault(); + if (palette.classList.contains('open')) close(); else open(); + } + }); + } + + function init() { + initSpotlight(); + initScrollAffordances(); + initHoverPreview(); + initCommandPalette(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})(); diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 4bf8604..667f7ec 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -77,6 +77,7 @@ App.favorites = App.favorites || {}; if (!key) return; const favorites = App.favorites.getAll(); const existingIndex = favorites.findIndex((item) => item.key === key); + const becameFavorite = existingIndex < 0; if (existingIndex >= 0) { favorites.splice(existingIndex, 1); } else { @@ -86,6 +87,15 @@ App.favorites = App.favorites || {}; App.favorites.setAll(favorites); App.favorites.renderBar(); App.favorites.syncButtons(); + // Celebrate an add with a brass pop + ring on every button for this key. + if (becameFavorite) { + document.querySelectorAll(`.favorite-btn[data-fav-key="${(window.CSS && CSS.escape) ? CSS.escape(key) : key}"]`).forEach((btn) => { + btn.classList.remove('just-favorited'); + void btn.offsetWidth; // restart the animation + btn.classList.add('just-favorited'); + btn.addEventListener('animationend', () => btn.classList.remove('just-favorited'), { once: true }); + }); + } }; App.favorites.renderBar = function() { diff --git a/frontend/js/feed.js b/frontend/js/feed.js index 160180b..da2aeff 100644 --- a/frontend/js/feed.js +++ b/frontend/js/feed.js @@ -749,5 +749,8 @@ App.feed = App.feed || {}; ? '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'; icon.alt = state.feedMuted ? 'Unmute' : 'Mute'; + // Pulse a brass ring while muted to hint "tap to hear sound". + const btn = document.getElementById('feed-mute-btn'); + if (btn) btn.classList.toggle('is-muted', !!state.feedMuted); }; })(); diff --git a/frontend/js/main.js b/frontend/js/main.js index 691510a..c86fde3 100644 --- a/frontend/js/main.js +++ b/frontend/js/main.js @@ -7,6 +7,7 @@ window.App = window.App || {}; App.ui.applyTheme(); App.ui.applyPreferredQuality(); App.ui.applyFeedEndBehavior(); + App.ui.applyDensity(); App.ui.renderMenu(); App.favorites.renderBar(); App.ui.bindGlobalHandlers(); diff --git a/frontend/js/player.js b/frontend/js/player.js index 4f03dc0..058782a 100644 --- a/frontend/js/player.js +++ b/frontend/js/player.js @@ -294,7 +294,25 @@ App.player = App.player || {}; attempt(0); if (state.playerMode === 'modal') { - modal.style.display = 'flex'; + // Ambient backdrop: a blurred copy of the poster fills the letterbox + // behind the contained video for a richer, less sterile player. + const modalContent = modal.querySelector('.modal-content'); + if (modalContent) { + let poster = (source && (source.thumb || (source.meta && (source.meta.thumbnail || source.meta.thumb)))) || ''; + if (!poster && originEl) { + const img = originEl.querySelector('img'); + if (img) poster = img.currentSrc || img.src || ''; + } + if (poster) modalContent.style.setProperty('--poster', `url("${poster.replace(/"/g, '%22')}")`); + else modalContent.style.removeProperty('--poster'); + } + const reveal = () => { modal.style.display = 'flex'; }; + const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches; + if (!reduce && document.startViewTransition) { + document.startViewTransition(reveal); + } else { + reveal(); + } document.body.style.overflow = 'hidden'; } else { modal.style.display = 'none'; diff --git a/frontend/js/storage.js b/frontend/js/storage.js index d206ea8..7d6e9bb 100644 --- a/frontend/js/storage.js +++ b/frontend/js/storage.js @@ -48,6 +48,15 @@ App.session = App.session || {}; localStorage.setItem(FEED_END_BEHAVIOR_KEY, nextBehavior === 'scroll' ? 'scroll' : 'loop'); }; + // Grid density: 'comfortable' (default) or 'compact' (more, smaller columns). + App.storage.getDensity = function() { + return localStorage.getItem('density') === 'compact' ? 'compact' : 'comfortable'; + }; + + App.storage.setDensity = function(nextDensity) { + localStorage.setItem('density', nextDensity === 'compact' ? 'compact' : 'comfortable'); + }; + App.storage.getServerEntries = function() { const config = App.storage.getConfig(); if (!config.servers || !Array.isArray(config.servers)) return []; diff --git a/frontend/js/ui.js b/frontend/js/ui.js index ab2130c..d48aa7d 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -21,6 +21,13 @@ App.ui = App.ui || {}; if (select) select.value = App.storage.getFeedEndBehavior(); }; + App.ui.applyDensity = function() { + const density = App.storage.getDensity(); + document.body.dataset.density = density; + const select = document.getElementById('density-select'); + if (select) select.value = density; + }; + // Toast helper for playback + network errors. App.ui.showError = function(message) { const toast = document.getElementById('error-toast'); @@ -286,6 +293,18 @@ App.ui = App.ui || {}; }; } + const densitySelect = document.getElementById('density-select'); + if (densitySelect) { + densitySelect.value = App.storage.getDensity(); + densitySelect.onchange = () => { + App.storage.setDensity(densitySelect.value); + App.ui.applyDensity(); + if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') { + App.virtualGrid.relayout(); + } + }; + } + const feedEndSelect = document.getElementById('feed-end-select'); if (feedEndSelect) { feedEndSelect.value = App.storage.getFeedEndBehavior(); diff --git a/frontend/js/videos.js b/frontend/js/videos.js index a66a95a..4e70e56 100644 --- a/frontend/js/videos.js +++ b/frontend/js/videos.js @@ -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 });