feat(plex): info-page polish — per-source strip toggles, glassy look, scroll restore
From UAT feedback on the collections info page: - Per-source show/hide toggles in the customize menu, one per collection-strip type present on the item (Collections/IMDb/TMDb/…), persisted per account. - Customize menu closes on Escape / outside click (reuses useDismiss). - Preserve the info-page scroll position when returning from "Browse collection". - Glassy refresh: content floats as .glass/.glass-card panels over a faint FIXED art backdrop on the <main> scroller (HTPC-style, toggleable); frosted menu; wider ~90% layout. Uses the existing glass surface system.
This commit is contained in:
parent
8a8087ae99
commit
f037a4563c
3 changed files with 110 additions and 25 deletions
|
|
@ -52,12 +52,17 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
||||||
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
// restore it when we come back — so the browser/mouse Back from the player lands on the same card
|
||||||
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
// instead of the top of the library. The scroller is App's <main> (the page's overflow-y-auto).
|
||||||
const scrollRef = useRef(0);
|
const scrollRef = useRef(0);
|
||||||
|
// Same idea for the info page: "Browse collection" (onFilter) leaves the info view for a filtered
|
||||||
|
// grid, and browser Back returns to this same info page — restore where the user had scrolled it
|
||||||
|
// (down to a collection strip) instead of snapping to the top. Reset on a fresh info open so a
|
||||||
|
// different title's info starts at the top.
|
||||||
|
const infoScrollRef = useRef(0);
|
||||||
const scroller = () => document.querySelector("main");
|
const scroller = () => document.querySelector("main");
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (sub.view.kind === "grid" && scrollRef.current) {
|
|
||||||
const el = scroller();
|
const el = scroller();
|
||||||
if (el) el.scrollTop = scrollRef.current;
|
if (!el) return;
|
||||||
}
|
if (sub.view.kind === "grid" && scrollRef.current) el.scrollTop = scrollRef.current;
|
||||||
|
else if (sub.view.kind === "info" && infoScrollRef.current) el.scrollTop = infoScrollRef.current;
|
||||||
}, [sub.view.kind]);
|
}, [sub.view.kind]);
|
||||||
|
|
||||||
const browseQ = useInfiniteQuery({
|
const browseQ = useInfiniteQuery({
|
||||||
|
|
@ -121,6 +126,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
||||||
}
|
}
|
||||||
function onInfo(card: PlexCard) {
|
function onInfo(card: PlexCard) {
|
||||||
scrollRef.current = scroller()?.scrollTop ?? 0;
|
scrollRef.current = scroller()?.scrollTop ?? 0;
|
||||||
|
infoScrollRef.current = 0; // fresh info page starts at the top
|
||||||
sub.open({ kind: "info", id: card.id });
|
sub.open({ kind: "info", id: card.id });
|
||||||
}
|
}
|
||||||
async function toggleWatched(card: PlexCard) {
|
async function toggleWatched(card: PlexCard) {
|
||||||
|
|
@ -158,6 +164,7 @@ export default function PlexBrowse({ q, onClearSearch, library, show, sort, filt
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setFilters(merged as unknown as PlexFilters);
|
setFilters(merged as unknown as PlexFilters);
|
||||||
|
infoScrollRef.current = scroller()?.scrollTop ?? 0; // restore on Back to this info page
|
||||||
sub.open({ kind: "grid" });
|
sub.open({ kind: "grid" });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
@ -373,7 +380,7 @@ function PlexInfoView({
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
const q = useQuery({ queryKey: ["plex-item", id], queryFn: () => api.plexItem(id) });
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1100px] mx-auto">
|
<div className="w-[90%] max-w-[1600px] mx-auto">
|
||||||
<div className="p-4 pb-0">
|
<div className="p-4 pb-0">
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { useState, type ReactNode } from "react";
|
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useDismiss } from "../lib/useDismiss";
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ExternalLink,
|
ExternalLink,
|
||||||
|
|
@ -72,11 +73,56 @@ export default function PlexInfo({
|
||||||
|
|
||||||
// Personal display prefs (default ON). Stored per-user; the backend merges any pref key.
|
// Personal display prefs (default ON). Stored per-user; the backend merges any pref key.
|
||||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(["me"])?.preferences ??
|
||||||
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean };
|
{}) as { plexInfoArtBg?: boolean; plexInfoCast?: boolean; plexHiddenStripSources?: string[] };
|
||||||
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
const [artBg, setArtBg] = useState(prefs.plexInfoArtBg !== false);
|
||||||
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
const [showCast, setShowCast] = useState(prefs.plexInfoCast !== false);
|
||||||
|
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
|
||||||
|
// per type in the customize menu (Collections / IMDb / TMDb / …), only for sources present here.
|
||||||
|
const [hiddenSources, setHiddenSources] = useState<string[]>(
|
||||||
|
Array.isArray(prefs.plexHiddenStripSources) ? prefs.plexHiddenStripSources : [],
|
||||||
|
);
|
||||||
|
const toggleSource = (src: string) => {
|
||||||
|
setHiddenSources((cur) => {
|
||||||
|
const next = cur.includes(src) ? cur.filter((s) => s !== src) : [...cur, src];
|
||||||
|
savePref({ plexHiddenStripSources: next });
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const [customizing, setCustomizing] = useState(false);
|
const [customizing, setCustomizing] = useState(false);
|
||||||
const savePref = (patch: Record<string, boolean>) => {
|
const custBtnRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const custMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
useDismiss(customizing, () => setCustomizing(false), [custMenuRef, custBtnRef]);
|
||||||
|
|
||||||
|
// Faint art backdrop, HTPC-style: paint the item's art as a FIXED background on the page's scroll
|
||||||
|
// container (<main>) so it fills the content area and stays put while the info page scrolls over it
|
||||||
|
// (like the body's ambient pools). Scoped to <main> → never covers the nav/filter sidebars. A soft
|
||||||
|
// scrim keeps text readable; kept subtle. Page variant only, toggleable, restored on unmount/off.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (variant === "overlay") return;
|
||||||
|
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 (artBg && detail.art) {
|
||||||
|
s.backgroundImage =
|
||||||
|
`linear-gradient(color-mix(in srgb, var(--bg) 60%, transparent), ` +
|
||||||
|
`color-mix(in srgb, var(--bg) 64%, transparent)), url("${detail.art}")`;
|
||||||
|
s.backgroundSize = "cover";
|
||||||
|
s.backgroundPosition = "center 20%";
|
||||||
|
s.backgroundRepeat = "no-repeat";
|
||||||
|
s.backgroundAttachment = "fixed";
|
||||||
|
} else {
|
||||||
|
clear();
|
||||||
|
}
|
||||||
|
return clear;
|
||||||
|
}, [variant, artBg, detail.art]);
|
||||||
|
const savePref = (patch: Record<string, unknown>) => {
|
||||||
api.savePrefs(patch).catch(() => {});
|
api.savePrefs(patch).catch(() => {});
|
||||||
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
qc.setQueryData<{ preferences?: Record<string, unknown> } | undefined>(["me"], (m) =>
|
||||||
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
m ? { ...m, preferences: { ...(m.preferences || {}), ...patch } } : m,
|
||||||
|
|
@ -96,17 +142,22 @@ export default function PlexInfo({
|
||||||
const overlay = variant === "overlay";
|
const overlay = variant === "overlay";
|
||||||
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
const mutedCls = overlay ? "text-white/70" : "text-muted";
|
||||||
|
|
||||||
|
// Distinct strip-source buckets present on THIS item, in strip order — one toggle each.
|
||||||
|
const presentSources = (detail.collections ?? []).reduce<string[]>((acc, c) => {
|
||||||
|
if (!acc.includes(c.source)) acc.push(c.source);
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
const sourceLabel = (src: string) =>
|
||||||
|
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}>
|
<div className={overlay ? "relative w-full max-w-3xl" : "relative"}>
|
||||||
{/* Faint art backdrop (page only, toggleable). */}
|
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
|
||||||
{!overlay && artBg && detail.art && (
|
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
||||||
<div className="absolute inset-0 -z-10 overflow-hidden rounded-2xl">
|
<div
|
||||||
<img src={detail.art} alt="" className="w-full h-full object-cover opacity-20" />
|
className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-bg via-bg/85 to-bg/60" />
|
style={overlay ? undefined : { background: "color-mix(in srgb, var(--surface) 55%, transparent)" }}
|
||||||
</div>
|
>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className={overlay ? "p-5" : "p-4 sm:p-6"}>
|
|
||||||
{/* Header: close (overlay) / customize */}
|
{/* Header: close (overlay) / customize */}
|
||||||
<div className="mb-4 flex items-start gap-3">
|
<div className="mb-4 flex items-start gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
|
@ -121,6 +172,7 @@ export default function PlexInfo({
|
||||||
</div>
|
</div>
|
||||||
<div className="relative shrink-0">
|
<div className="relative shrink-0">
|
||||||
<button
|
<button
|
||||||
|
ref={custBtnRef}
|
||||||
onClick={() => setCustomizing((v) => !v)}
|
onClick={() => setCustomizing((v) => !v)}
|
||||||
title={t("plex.info.customize")}
|
title={t("plex.info.customize")}
|
||||||
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
className={`p-1.5 rounded-lg ${overlay ? "text-white/80 hover:bg-white/15" : "text-muted hover:bg-surface hover:text-fg"}`}
|
||||||
|
|
@ -128,7 +180,11 @@ export default function PlexInfo({
|
||||||
<SlidersHorizontal className="w-4 h-4" />
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
{customizing && (
|
{customizing && (
|
||||||
<div className="absolute right-0 top-full z-10 mt-1 w-52 rounded-xl border border-border bg-card p-1.5 text-sm shadow-xl">
|
<div
|
||||||
|
ref={custMenuRef}
|
||||||
|
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm"
|
||||||
|
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
|
||||||
|
>
|
||||||
<PrefToggle
|
<PrefToggle
|
||||||
label={t("plex.info.prefArtBg")}
|
label={t("plex.info.prefArtBg")}
|
||||||
on={artBg}
|
on={artBg}
|
||||||
|
|
@ -145,6 +201,14 @@ export default function PlexInfo({
|
||||||
savePref({ plexInfoCast: !showCast });
|
savePref({ plexInfoCast: !showCast });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{presentSources.map((src) => (
|
||||||
|
<PrefToggle
|
||||||
|
key={src}
|
||||||
|
label={sourceLabel(src)}
|
||||||
|
on={!hiddenSources.includes(src)}
|
||||||
|
onClick={() => toggleSource(src)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -273,7 +337,7 @@ export default function PlexInfo({
|
||||||
{detail.status === "watched" ? (
|
{detail.status === "watched" ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => setState("new")}
|
onClick={() => setState("new")}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
{t("plex.info.markUnwatched")}
|
{t("plex.info.markUnwatched")}
|
||||||
|
|
@ -281,7 +345,7 @@ export default function PlexInfo({
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => setState("watched")}
|
onClick={() => setState("watched")}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||||
>
|
>
|
||||||
<Check className="w-4 h-4" />
|
<Check className="w-4 h-4" />
|
||||||
{t("plex.info.markWatched")}
|
{t("plex.info.markWatched")}
|
||||||
|
|
@ -290,7 +354,7 @@ export default function PlexInfo({
|
||||||
{inProgress && (
|
{inProgress && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setState("new")}
|
onClick={() => setState("new")}
|
||||||
className="inline-flex items-center gap-1.5 rounded-xl border border-border px-3 py-2 text-sm hover:border-accent hover:text-accent"
|
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-xl px-3 py-2 text-sm hover:text-accent"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<RotateCcw className="w-4 h-4" />
|
||||||
{t("plex.info.clearResume")}
|
{t("plex.info.clearResume")}
|
||||||
|
|
@ -300,10 +364,15 @@ export default function PlexInfo({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* End hero panel. */}
|
||||||
|
|
||||||
{/* Cast row (toggleable), circular photos. */}
|
{/* Cast row (toggleable), circular photos. */}
|
||||||
{cast.length > 0 && (
|
{cast.length > 0 && (
|
||||||
<div className="mt-5">
|
<div
|
||||||
|
className={overlay ? "mt-5" : "glass-card rounded-2xl p-4 sm:p-5"}
|
||||||
|
style={overlay ? undefined : { background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
|
>
|
||||||
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
<h2 className={`mb-2 text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>
|
||||||
{t("plex.info.cast")}
|
{t("plex.info.cast")}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
@ -357,9 +426,16 @@ export default function PlexInfo({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Collection strips — the other titles in this movie's collection(s), directly playable. */}
|
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
|
||||||
{detail.collections?.map((col) => (
|
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
|
||||||
<div key={col.id} className="mt-6">
|
{detail.collections
|
||||||
|
?.filter((col) => !hiddenSources.includes(col.source))
|
||||||
|
.map((col) => (
|
||||||
|
<div
|
||||||
|
key={col.id}
|
||||||
|
className={overlay ? "mt-6" : "glass-card rounded-2xl p-4 sm:p-5"}
|
||||||
|
style={overlay ? undefined : { background: "color-mix(in srgb, var(--card) 55%, transparent)" }}
|
||||||
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>
|
<h2 className={`text-sm font-semibold ${overlay ? "text-white/80" : ""}`}>{col.title}</h2>
|
||||||
{onFilter && (
|
{onFilter && (
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,8 @@ export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||||
"Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries.",
|
"Plex collections: every Plex collection (franchises like Avatar, curated sets, and community lists like IMDb Top 250) is now mirrored locally by the background sync, so browsing is instant — no more waiting on Plex's slow collection queries.",
|
||||||
"Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter.",
|
"Plex library: a searchable Collection filter in the sidebar — pick a collection to see just its titles, combinable with every other filter.",
|
||||||
"Plex info page: a title's collection(s) now appear as strips of the sibling films, playable straight from there (most-specific collection first).",
|
"Plex info page: a title's collection(s) now appear as strips of the sibling films, playable straight from there (most-specific collection first).",
|
||||||
|
"Plex info page: show or hide each collection-strip type independently — keep genuine franchises (Avatar, James Bond) while hiding the big auto-lists (IMDb Top 250, TMDb Trending), remembered per account.",
|
||||||
|
"Plex info page: a refreshed, wider layout — content floats as frosted-glass panels over a faint, fixed backdrop of the title's own art (toggleable), matching the app's glassy look.",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue