feat(glass): dev-prototype feed backdrop (blurred rotating thumbnail)
Idea from UAT-2: give the feeds the same 'content behind glass' lift the Plex detail page gets. New FeedBackdrop paints a faint, slowly-rotating feed thumbnail as a fixed backdrop on <main> (the proven art-backdrop path — a fixed z:-1 layer is occluded by the root's opaque bg-bg). Blur = tiny-canvas downscale + cover upscale (no CSS filter; ytimg is CORS-readable so it uses a data-URL, else raw). Flat --bg stays as the 'perf mode' look. Behind the GlassTuner toggle + a Backdrop-fade slider (--feedbg-fade) so it's judged/tuned in UAT before anything is committed for real.
This commit is contained in:
parent
c1ba5229c3
commit
036e2fe7ea
3 changed files with 134 additions and 3 deletions
93
frontend/src/components/FeedBackdrop.tsx
Normal file
93
frontend/src/components/FeedBackdrop.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DEV prototype (toggled by GlassTuner): a faint, blurred, slowly-rotating feed thumbnail painted
|
||||||
|
* as a fixed backdrop so the glass surfaces have real content to refract — the same trick that makes
|
||||||
|
* the Plex detail page pop, brought to the YT/Plex feeds. The flat `--bg` then reads as "perf mode".
|
||||||
|
*
|
||||||
|
* It writes `<main>`'s background (NOT a separate fixed layer): a fixed z-index:-1 layer is occluded
|
||||||
|
* by the app root's opaque `bg-bg`, whereas `<main>` is a transparent descendant whose own background
|
||||||
|
* paints above it — this is exactly how `useArtBackdrop` (Plex) works, reused here.
|
||||||
|
*
|
||||||
|
* Blur: downscale the thumbnail into a tiny canvas and let `background-size: cover` upscale it back —
|
||||||
|
* a cheap gaussian-ish blur with no CSS filter (which can't touch a background-image). Falls back to
|
||||||
|
* the raw url if the image isn't CORS-readable (still soft once upscaled + heavily faded).
|
||||||
|
*/
|
||||||
|
export default function FeedBackdrop() {
|
||||||
|
const [srcs, setSrcs] = useState<string[]>([]);
|
||||||
|
const [idx, setIdx] = useState(0);
|
||||||
|
const [art, setArt] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Sample the video thumbnails currently on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
const found: string[] = [];
|
||||||
|
document.querySelectorAll<HTMLImageElement>("img").forEach((img) => {
|
||||||
|
const s = img.currentSrc || img.src;
|
||||||
|
if (/i\.ytimg\.com\/vi\//.test(s)) found.push(s);
|
||||||
|
});
|
||||||
|
setSrcs([...new Set(found)]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Slowly cycle through them.
|
||||||
|
useEffect(() => {
|
||||||
|
if (srcs.length < 2) return;
|
||||||
|
const t = setInterval(() => setIdx((i) => (i + 1) % srcs.length), 14000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, [srcs]);
|
||||||
|
|
||||||
|
// Blur the current pick via a tiny-canvas downscale (fallback: raw url on CORS taint).
|
||||||
|
useEffect(() => {
|
||||||
|
const url = srcs[idx];
|
||||||
|
if (!url) return;
|
||||||
|
let cancelled = false;
|
||||||
|
const img = new Image();
|
||||||
|
img.crossOrigin = "anonymous";
|
||||||
|
img.onload = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const c = document.createElement("canvas");
|
||||||
|
c.width = 40;
|
||||||
|
c.height = 22;
|
||||||
|
c.getContext("2d")!.drawImage(img, 0, 0, 40, 22);
|
||||||
|
setArt(c.toDataURL("image/jpeg", 0.6));
|
||||||
|
} catch {
|
||||||
|
setArt(url);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
if (!cancelled) setArt(url);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [srcs, idx]);
|
||||||
|
|
||||||
|
// Paint (and clean up) the faded backdrop on <main>.
|
||||||
|
useEffect(() => {
|
||||||
|
const main = document.querySelector("main");
|
||||||
|
if (!main) return;
|
||||||
|
const s = main.style;
|
||||||
|
const clear = () => {
|
||||||
|
s.backgroundImage = "";
|
||||||
|
s.backgroundSize = "";
|
||||||
|
s.backgroundPosition = "";
|
||||||
|
s.backgroundRepeat = "";
|
||||||
|
s.backgroundAttachment = "";
|
||||||
|
};
|
||||||
|
if (art) {
|
||||||
|
s.backgroundImage =
|
||||||
|
`linear-gradient(color-mix(in srgb, var(--bg) var(--feedbg-fade), transparent), ` +
|
||||||
|
`color-mix(in srgb, var(--bg) var(--feedbg-fade), transparent)), url("${art}")`;
|
||||||
|
s.backgroundSize = "cover";
|
||||||
|
s.backgroundPosition = "center";
|
||||||
|
s.backgroundRepeat = "no-repeat";
|
||||||
|
s.backgroundAttachment = "fixed";
|
||||||
|
} else {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
return clear;
|
||||||
|
}, [art]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import FeedBackdrop from "./FeedBackdrop";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page.
|
* DEV-ONLY live appearance tuner. Mounted at the App root so it stays reachable on every page.
|
||||||
|
|
@ -77,13 +78,19 @@ const PRESETS: { id: string; name: string; hint: string; over: Record<string, nu
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const ALL = SLIDERS;
|
// Dev-prototype feed-backdrop control (only meaningful while the backdrop toggle is on).
|
||||||
|
const FEEDBG_SLIDERS: Slider[] = [
|
||||||
|
{ k: "--feedbg-fade", label: "Backdrop fade", min: 60, max: 97, step: 1, unit: "%", def: 86 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const ALL = [...SLIDERS, ...FEEDBG_SLIDERS];
|
||||||
const root = () => document.documentElement;
|
const root = () => document.documentElement;
|
||||||
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
|
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
|
||||||
|
|
||||||
type Saved = {
|
type Saved = {
|
||||||
vars?: Record<string, number>;
|
vars?: Record<string, number>;
|
||||||
palette?: Record<string, string>;
|
palette?: Record<string, string>;
|
||||||
|
feedbg?: boolean;
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -103,6 +110,7 @@ export default function GlassTuner() {
|
||||||
return { ...base, ...(initial.vars || {}) };
|
return { ...base, ...(initial.vars || {}) };
|
||||||
});
|
});
|
||||||
const [palette, setPalette] = useState<Record<string, string>>(() => initial.palette || {});
|
const [palette, setPalette] = useState<Record<string, string>>(() => initial.palette || {});
|
||||||
|
const [feedbg, setFeedbg] = useState<boolean>(() => Boolean(initial.feedbg));
|
||||||
const [open, setOpen] = useState<boolean>(() => initial.open ?? true);
|
const [open, setOpen] = useState<boolean>(() => initial.open ?? true);
|
||||||
const [tab, setTab] = useState<"glass" | "palette">("glass");
|
const [tab, setTab] = useState<"glass" | "palette">("glass");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
@ -199,10 +207,17 @@ export default function GlassTuner() {
|
||||||
persist({ open: v });
|
persist({ open: v });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleFeedbg(v: boolean) {
|
||||||
|
setFeedbg(v);
|
||||||
|
persist({ feedbg: v });
|
||||||
|
}
|
||||||
|
|
||||||
if (!DEV_TUNER) return null;
|
if (!DEV_TUNER) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
|
<>
|
||||||
|
{feedbg && <FeedBackdrop />}
|
||||||
|
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
|
||||||
{!open && (
|
{!open && (
|
||||||
<button
|
<button
|
||||||
onClick={() => persistOpen(true)}
|
onClick={() => persistOpen(true)}
|
||||||
|
|
@ -249,6 +264,17 @@ export default function GlassTuner() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Feed backdrop (dev prototype) */}
|
||||||
|
<label className="flex items-center gap-2 text-xs text-fg cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={feedbg}
|
||||||
|
onChange={(e) => toggleFeedbg(e.target.checked)}
|
||||||
|
className="accent-accent"
|
||||||
|
/>
|
||||||
|
Feed backdrop <span className="text-muted">(blurred thumbnail)</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Tab switch */}
|
{/* Tab switch */}
|
||||||
<div className="flex gap-1.5">
|
<div className="flex gap-1.5">
|
||||||
{(["glass", "palette"] as const).map((t) => (
|
{(["glass", "palette"] as const).map((t) => (
|
||||||
|
|
@ -269,6 +295,13 @@ export default function GlassTuner() {
|
||||||
{SLIDERS.map((s) => (
|
{SLIDERS.map((s) => (
|
||||||
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
|
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
|
||||||
))}
|
))}
|
||||||
|
{feedbg && (
|
||||||
|
<div className="flex flex-col gap-2.5 pt-1 border-t border-border/50">
|
||||||
|
{FEEDBG_SLIDERS.map((s) => (
|
||||||
|
<Ctl key={s.k} s={s} value={vars[s.k]} onChange={(v) => setVar(s.k, v)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -313,7 +346,8 @@ export default function GlassTuner() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@
|
||||||
--glass-edge: 10%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */
|
--glass-edge: 10%; /* extra edge-light mixed into the TOP border (#fff N%) — 0 = off */
|
||||||
--glass-scrim: 14%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */
|
--glass-scrim: 14%; /* under-content scrim: darkens surfaces toward --bg for text contrast — 0 = off */
|
||||||
--ambient: 1.1; /* multiplier on the ambient accent backdrop pools */
|
--ambient: 1.1; /* multiplier on the ambient accent backdrop pools */
|
||||||
|
|
||||||
|
/* Dev-prototype feed backdrop (see <FeedBackdrop/>): how strongly the faded --bg overlay hides the
|
||||||
|
blurred thumbnail — higher = fainter image. Only in effect while the backdrop is toggled on. */
|
||||||
|
--feedbg-fade: 86%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Rich-backdrop scope: surfaces that float over real imagery (Plex art hero/detail, and
|
/* ===== Rich-backdrop scope: surfaces that float over real imagery (Plex art hero/detail, and
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue