fix(plex): give the library search its own ephemeral state

The Plex library search box wrote to the shared, persisted feed filter
(filters.q). That leaked a Plex query into the feed search box and, being
persisted, restored a stale query after a reload — which then collided
with a persisted collection filter to produce a confusing empty grid.
Plex now has its own search state: kept across page switches within a
session, but not persisted and not shared with the feed, so a reload
starts clean.
This commit is contained in:
npeter83 2026-07-08 23:19:36 +02:00
parent d84edebc09
commit a005a1bcbb
2 changed files with 28 additions and 8 deletions

View file

@ -260,6 +260,12 @@ export default function App() {
} }
}, [plexFiltersRaw]); }, [plexFiltersRaw]);
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f)); const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
// The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed
// `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted,
// greeted you with a stale query (colliding with a persisted collection filter → confusing
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
// resets on reload.
const [plexQ, setPlexQ] = useState("");
// Bumped to tell the channel manager to drop a stale column filter when we send the user // Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link). // there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0); const [channelsFilterReset, setChannelsFilterReset] = useState(0);
@ -767,6 +773,8 @@ export default function App() {
me={meQuery.data!} me={meQuery.data!}
filters={filters} filters={filters}
setFilters={setFilters} setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
page={page} page={page}
onYtSearch={enterYtSearch} onYtSearch={enterYtSearch}
/> />
@ -819,8 +827,8 @@ export default function App() {
/> />
) : page === "plex" && meQuery.data!.plex_enabled ? ( ) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse <PlexBrowse
q={filters.q} q={plexQ}
onClearSearch={() => setFilters({ ...filters, q: "" })} onClearSearch={() => setPlexQ("")}
library={plexLib} library={plexLib}
show={plexShowFilter} show={plexShowFilter}
sort={plexSort} sort={plexSort}

View file

@ -12,12 +12,17 @@ export default function Header({
me, me,
filters, filters,
setFilters, setFilters,
plexQ,
setPlexQ,
page, page,
onYtSearch, onYtSearch,
}: { }: {
me: Me; me: Me;
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
plexQ: string;
setPlexQ: (q: string) => void;
page: Page; page: Page;
// Trigger a live YouTube search for the current term (hidden for the demo account, which // Trigger a live YouTube search for the current term (hidden for the demo account, which
// can't spend the shared quota). // can't spend the shared quota).
@ -25,11 +30,14 @@ export default function Header({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const canSearchYt = !me.is_demo; const canSearchYt = !me.is_demo;
const trimmedQ = filters.q.trim();
// The search box serves both the YouTube feed and the Plex module (integrated search); the live // The search box serves both the YouTube feed and the Plex module (integrated search); the live
// YouTube-search escalation (Enter / button) is feed-only. // YouTube-search escalation (Enter / button) is feed-only. On Plex it drives `plexQ`, on the feed
// the persisted `filters.q`.
const isSearchPage = page === "feed" || page === "plex"; const isSearchPage = page === "feed" || page === "plex";
const isPlex = page === "plex";
const isYtCapable = page === "feed" && canSearchYt; const isYtCapable = page === "feed" && canSearchYt;
const searchValue = isPlex ? plexQ : filters.q;
const trimmedQ = searchValue.trim();
return ( return (
<header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20"> <header className="glass h-14 shrink-0 border-b border-border flex items-center gap-3 px-4 z-20">
@ -38,14 +46,18 @@ export default function Header({
<div className="flex-1 relative"> <div className="flex-1 relative">
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" /> <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-muted" />
<input <input
value={filters.q} value={searchValue}
onChange={(e) => { onChange={(e) => {
const q = e.target.value; const q = e.target.value;
if (isPlex) {
setPlexQ(q);
return;
}
// When a search first appears, rank the feed by relevance — set atomically with // When a search first appears, rank the feed by relevance — set atomically with
// the query (race-free vs. per-keystroke updates). Only overrides the default // the query (race-free vs. per-keystroke updates). Only overrides the default
// "newest" sort; a custom sort the user chose is left alone. Cleared in Feed. // "newest" sort; a custom sort the user chose is left alone. Cleared in Feed.
const startSearch = const startSearch =
page === "feed" && !filters.q.trim() && !!q.trim() && filters.sort === "newest"; !filters.q.trim() && !!q.trim() && filters.sort === "newest";
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) }); setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
@ -56,9 +68,9 @@ export default function Header({
placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")} placeholder={page === "plex" ? t("plex.searchPlaceholder") : t("header.searchPlaceholder")}
className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent" className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent"
/> />
{filters.q && ( {searchValue && (
<button <button
onClick={() => setFilters({ ...filters, q: "" })} onClick={() => (isPlex ? setPlexQ("") : setFilters({ ...filters, q: "" }))}
title={t("header.clearSearch")} title={t("header.clearSearch")}
aria-label={t("header.clearSearch")} aria-label={t("header.clearSearch")}
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition" className="absolute right-2.5 top-1/2 -translate-y-1/2 p-0.5 rounded-full text-muted hover:text-fg hover:bg-border/60 transition"