fix(e5): Plex detail/UI polish from review (backdrop reset, layout, combobox, labels)

User-reported issues after the E5 build:

- useCrossfadeBackdrop: the static branch (one image / crossfade off /
  reduced motion) painted #root but returned NO cleanup, so the backdrop
  persisted after leaving the page (e.g. switching modules). Every painted
  branch now returns clear() so unmount always resets the default backdrop.
- scheduler: give the tmdb_enrich job a proper label + description (was the
  raw "tmdb_enrich" id) in EN/HU.
- CastStrip: cap crew names to ONE line (truncate) so the row of circular
  photos sits on one baseline instead of going wavy on 2-line names.
- PlexInfo: lay Details / Keywords / Crew out as side-by-side boxes
  (items-start grid) instead of full-width stacked panels with dead space;
  narrower Crew grid for the boxed width; each panel independently hideable
  from the customize menu (new Details/Keywords/Crew toggles). Tighter
  vertical spacing.
- MultiSelectChips: reshape the trigger from a "+Add" button into an
  Overseerr-style combi input — removable chips inside a typeable field that
  filters the checklist, opening on focus (backspace removes the last chip).
- Discover / person rails: reduce the vertical gap between rails.
This commit is contained in:
2026-08-21 03:16:40 +02:00
parent 2b5de89eda
commit b83248c510
11 changed files with 205 additions and 93 deletions
@@ -1,9 +1,10 @@
// @vitest-environment jsdom
//
// E5 S7: the compact listbox-select + chips multi-value filter. Covers the selected-chip removal, the
// popover open, the search filter, and toggling an option on. i18n is key-passthrough; the popover is
// floating-ui based (ActionMenu's test recipe: stub the two observers autoUpdate wires, wrap in a
// FloatingTree, and query the hidden surface with `{ hidden: true }` since jsdom never positions it).
// E5 S7: the combi-input (chips + typed search field) multi-value filter. Covers the selected-chip
// removal, opening the list by FOCUSING the field (not a button anymore), the search filter, and
// toggling an option on. i18n is key-passthrough; the popover is floating-ui based (ActionMenu's test
// recipe: stub the two observers autoUpdate wires, wrap in a FloatingTree, and query the hidden surface
// with `{ hidden: true }` since jsdom never positions it).
import { describe, it, expect, vi, afterEach, beforeAll } from "vitest";
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { FloatingTree } from "@floating-ui/react";
@@ -51,17 +52,18 @@ describe("MultiSelectChips", () => {
expect(onChange).toHaveBeenCalledWith([]); // Drama removed
});
it("keeps the option list unmounted until the trigger is clicked", () => {
it("keeps the option list unmounted until the field is focused", () => {
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("menuitemcheckbox", { hidden: true })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
fireEvent.focus(screen.getByRole("textbox"));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(3);
});
it("filters the options by the search query", () => {
it("filters the options by the typed query", () => {
wrap(<MultiSelectChips label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
fireEvent.change(screen.getByPlaceholderText("common.search"), { target: { value: "com" } });
const field = screen.getByRole("textbox");
fireEvent.focus(field);
fireEvent.change(field, { target: { value: "com" } });
const items = screen.getAllByRole("menuitemcheckbox", { hidden: true });
expect(items.length).toBe(1);
expect(items[0]?.textContent).toContain("Comedy");
@@ -72,16 +74,30 @@ describe("MultiSelectChips", () => {
wrap(
<MultiSelectChips label="Genre" options={OPTIONS} selected={["Drama"]} onChange={onChange} />,
);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
fireEvent.focus(screen.getByRole("textbox"));
fireEvent.click(screen.getByText("Action")); // clicking the option's label toggles it on
expect(onChange).toHaveBeenCalledWith(["Drama", "Action"]);
});
it("removes the last chip on Backspace in an empty field", () => {
const onChange = vi.fn();
wrap(
<MultiSelectChips
label="Genre"
options={OPTIONS}
selected={["Drama", "Action"]}
onChange={onChange}
/>,
);
fireEvent.keyDown(screen.getByRole("textbox"), { key: "Backspace" });
expect(onChange).toHaveBeenCalledWith(["Drama"]); // last chip dropped
});
it("caps the rendered rows for a large vocabulary and shows a refine hint", () => {
// A studio-sized list (hundreds) must not render every row — cap at 50 + a "N more" footer.
const many = Array.from({ length: 60 }, (_, i) => ({ value: `Studio ${i}`, count: 60 - i }));
wrap(<MultiSelectChips label="Studio" options={many} selected={[]} onChange={vi.fn()} />);
fireEvent.click(screen.getByRole("button", { name: "plex.filter.add" }));
fireEvent.focus(screen.getByRole("textbox"));
expect(screen.getAllByRole("menuitemcheckbox", { hidden: true }).length).toBe(50);
expect(screen.getByText("plex.filter.moreRefine")).toBeTruthy(); // 10 hidden behind search
});
+79 -57
View File
@@ -1,14 +1,16 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Check, Plus, Search, X } from "lucide-react";
import { Check, ChevronDown, X } from "lucide-react";
import { useScrollFade } from "../lib/useScrollFade";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// A compact "listbox-select + removable chips" multi-value filter control (E5 S7), replacing a flat
// wall of toggle chips: the selected values show as removable chips, and a "+" trigger opens a
// searchable checklist popover (AnchoredPopover platform — menuitemcheckbox items, arrow/Escape, anchor
// flip). Options carry an optional count. Space-saving for a large vocabulary (genres / studios), which
// is exactly why it replaces the always-expanded chip lists in the Plex filter panel.
// A "combi input" multi-value filter control (E5 S7, reshaped): the selected values are removable
// chips that live INSIDE a text field you can type into (the text filters the vocabulary), and the
// searchable checklist opens below on focus — a react-select / Overseerr-style combobox. It replaces
// the earlier "+Add button opening a popover" so a large vocabulary (genres / studios) reads as one
// familiar input rather than a wall of toggle chips. Positioning + outside-dismiss come from the
// AnchoredPopover platform (refs only — open is driven by the field's focus so a click to place the
// caret never toggles the list shut); the field's own keydown owns Escape + backspace-to-remove.
export interface MultiSelectOption {
value: string;
@@ -23,7 +25,7 @@ export default function MultiSelectChips({
addLabel,
searchPlaceholder,
}: {
/** Accessible name for the popover + trigger (e.g. "Genre"). */
/** Accessible name for the field + listbox (e.g. "Genre"). */
label: string;
options: readonly MultiSelectOption[];
selected: string[];
@@ -36,8 +38,8 @@ export default function MultiSelectChips({
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const fade = useScrollFade();
// A free-text search field lives in the surface — keep typed chars + pointer-hover out of the roving
// list so neither steals the field's caret (same reason ChannelTags' tag editor sets this).
// hostsInput keeps the platform's list-navigation/typeahead from stealing the field's caret; open is
// controlled here so the field's focus (not a click-to-toggle) drives it.
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
@@ -45,10 +47,9 @@ export default function MultiSelectChips({
placement: "bottom-start",
hostsInput: true,
});
// Focus the search field when the menu opens; clear the query when it closes.
// Clear the typed query when the list closes.
useEffect(() => {
if (open) inputRef.current?.focus();
else setQuery("");
if (!open) setQuery("");
}, [open]);
const filtered = useMemo(() => {
@@ -57,8 +58,8 @@ export default function MultiSelectChips({
}, [options, query]);
// A large vocabulary (studios can run to hundreds) must not render every option as a DOM node — cap
// the rendered rows and let search narrow to the rest. The options arrive count-desc, so the default
// view is the most-common values; a footer flags how many more are hidden behind the search.
// the rendered rows and let search narrow to the rest. Options arrive count-desc, so the default view
// is the most-common values; a footer flags how many more are hidden behind the search.
const MAX_VISIBLE = 50;
const visible = filtered.slice(0, MAX_VISIBLE);
const hiddenCount = filtered.length - visible.length;
@@ -68,56 +69,74 @@ export default function MultiSelectChips({
return (
<div>
{selected.length > 0 && (
<div className="mb-1.5 flex flex-wrap gap-1.5">
{selected.map((v) => (
<span key={v} className="chip px-2 py-1 text-xs">
<span className="max-w-36 truncate">{v}</span>
<button
onClick={() => toggle(v)}
aria-label={t("common.remove", { defaultValue: "Remove" })}
className="hover:opacity-70"
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
)}
<button
{/* The field: removable chips + a free-text input + a chevron, styled as one input box. */}
<div
ref={pop.refs.setReference}
type="button"
aria-label={addLabel ?? t("plex.filter.add")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-xs text-muted transition hover:text-accent"
{...pop.getReferenceProps()}
onMouseDown={(e) => {
// A click anywhere in the box (padding, a chip gap) focuses the field and opens the list —
// never toggles it shut; the field's onFocus is the single source of "open".
if (e.target !== inputRef.current) {
e.preventDefault();
inputRef.current?.focus();
setOpen(true);
}
}}
className="glass-card flex min-h-9 w-full cursor-text flex-wrap items-center gap-1 rounded-lg border border-border px-1.5 py-1 transition focus-within:border-accent focus-within:ring-2 focus-within:ring-accent/30"
>
<Plus className="h-3.5 w-3.5" />
{addLabel ?? t("plex.filter.add")}
</button>
{selected.map((v) => (
<span key={v} className="chip inline-flex items-center gap-1 px-2 py-0.5 text-xs">
<span className="max-w-36 truncate">{v}</span>
<button
type="button"
onMouseDown={(e) => {
// Keep the box's focus-the-field handler from firing when removing a chip.
e.stopPropagation();
e.preventDefault();
}}
onClick={() => toggle(v)}
aria-label={t("common.remove", { defaultValue: "Remove" })}
className="hover:opacity-70"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
return;
}
// Backspace on an empty field removes the last chip (react-select behaviour).
if (e.key === "Backspace" && query === "" && selected.length > 0) {
onChange(selected.slice(0, -1));
return;
}
e.stopPropagation(); // typed chars / arrows / Home / End are text editing, not list nav
}}
placeholder={
selected.length === 0
? (addLabel ?? t("plex.filter.add"))
: (searchPlaceholder ?? t("common.search", { defaultValue: "Search" }))
}
aria-label={label}
className="min-w-16 flex-1 bg-transparent px-1 py-0.5 text-sm outline-hidden placeholder:text-muted"
/>
<ChevronDown className="pointer-events-none mr-0.5 h-3.5 w-3.5 shrink-0 text-muted" />
</div>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu w-60 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="relative mb-1">
<Search className="pointer-events-none absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted" />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
// Escape bubbles to the popover's dismiss (close + refocus trigger); every other key —
// typed chars, arrows, Home/End — is text editing here, kept out of the surface's
// list-navigation/typeahead handlers so the caret behaves.
if (e.key === "Escape") return;
e.stopPropagation();
}}
placeholder={searchPlaceholder ?? t("common.search", { defaultValue: "Search" })}
className="w-full rounded-lg border border-border bg-surface py-1 pl-7 pr-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</div>
<div ref={fade.ref} style={fade.style} className="max-h-64 overflow-y-auto no-scrollbar">
{filtered.length === 0 ? (
<p className="px-2 py-1.5 text-xs text-muted">
@@ -132,7 +151,10 @@ export default function MultiSelectChips({
key={o.value}
role="menuitemcheckbox"
checked={on}
onSelect={() => toggle(o.value)}
onSelect={() => {
toggle(o.value);
inputRef.current?.focus(); // keep the field hot for the next pick / more typing
}}
label={o.value}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm outline-hidden transition ${
on ? "text-fg" : "text-muted"
+2 -2
View File
@@ -56,7 +56,7 @@ function CardRail({
title
);
return (
<section className="mb-8">
<section className="mb-5">
<Carousel title={heading} ariaLabel={ariaLabel} onSeeAll={onSeeAll} seeAllLabel={seeAllLabel}>
{cards.map((c) => (
<div key={c.id} className="w-[150px] shrink-0">
@@ -198,7 +198,7 @@ export default function PlexDiscover({
)}
{d.genres.length > 0 && (
<section className="mb-8">
<section className="mb-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.discover.byGenre")}</h2>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{d.genres.map((g) => {
+70 -15
View File
@@ -14,6 +14,7 @@ import {
useCrossfadeBackdrop,
useDetailPrefs,
} from "../lib/plexDetailUi";
import { usePref } from "../lib/usePref";
import PlexCollectionEditor from "./PlexCollectionEditor";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
@@ -222,7 +223,9 @@ function CrewPanel({
return (
<div className="glass-card rounded-2xl p-4 sm:p-5">
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
<div className="grid grid-cols-2 gap-x-4 gap-y-2 sm:grid-cols-3 md:grid-cols-4">
{/* Two columns inside the (now narrow) side box — viewport media-queries can't see the box width,
so a fixed small column count keeps names from over-spreading. */}
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
{crew.map((c, i) => (
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
{onOpenPerson ? (
@@ -280,6 +283,10 @@ export default function PlexInfo({
// is PlexInfo-specific state, persisted through the same savePref.
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
useDetailPrefs();
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
const [showFacts, setShowFacts] = usePref<boolean>("plexInfoFacts", true);
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
{}) as { plexHiddenStripSources?: string[] };
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
@@ -333,9 +340,24 @@ export default function PlexInfo({
const sourceLabel = (src: string) =>
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
// Whether each enriched side panel has anything to show — drives both its render and its toggle row
// (no toggle for a panel that would be empty anyway).
const tmdb = detail.tmdb;
const hasFacts =
detail.year != null ||
!!detail.duration_seconds ||
!!detail.content_rating ||
!!tmdb?.budget ||
!!tmdb?.revenue ||
!!tmdb?.studios?.length ||
!!detail.studio ||
!!tmdb?.production_countries?.length;
const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0;
const hasCrew = (tmdb?.crew?.length ?? 0) > 0;
return (
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
<div className={overlay ? "p-5" : "flex flex-col gap-4 p-3 sm:p-4"}>
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
<div className={overlay ? "" : "glass rounded-2xl p-4 sm:p-6"}>
{/* Header: close (overlay) / customize */}
@@ -359,14 +381,39 @@ export default function PlexInfo({
showCast={showCast}
onToggleCast={toggleCast}
hasCast={detail.cast.length > 0}
extra={presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
extra={
<>
{hasFacts && (
<PrefToggle
label={t("plex.info.prefFacts")}
on={showFacts}
onClick={() => setShowFacts(!showFacts)}
/>
)}
{hasKeywords && (
<PrefToggle
label={t("plex.info.prefKeywords")}
on={showKeywords}
onClick={() => setShowKeywords(!showKeywords)}
/>
)}
{hasCrew && (
<PrefToggle
label={t("plex.info.prefCrew")}
on={showCrew}
onClick={() => setShowCrew(!showCrew)}
/>
)}
{presentSources.map((src) => (
<PrefToggle
key={src}
label={sourceLabel(src)}
on={!hiddenSources.includes(src)}
onClick={() => toggleSource(src)}
/>
))}
</>
}
/>
{overlay && onClose && (
<button
@@ -548,9 +595,18 @@ export default function PlexInfo({
</div>
{/* End hero panel. */}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. */}
{!overlay && <FactsPanel detail={detail} onFilter={onFilter} />}
{!overlay && <KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />}
{/* Enriched detail (E5 S5) — page variant only; the overlay stays lean. Details / Keywords /
Crew sit side by side (each its natural height, items-start) so no single box hogs the full
width with dead space; each is independently hideable from the customize menu. */}
{!overlay && (showFacts || showKeywords || showCrew) && (
<div className="grid items-start gap-3 sm:grid-cols-2 xl:grid-cols-3">
{showFacts && <FactsPanel detail={detail} onFilter={onFilter} />}
{showKeywords && (
<KeywordsPanel keywords={detail.tmdb?.keywords ?? []} onFilter={onFilter} />
)}
{showCrew && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
</div>
)}
{/* Cast row (toggleable), circular photos. */}
{cast.length > 0 && (
@@ -617,8 +673,7 @@ export default function PlexInfo({
</div>
)}
{/* Full crew (E5 S5) — TMDB key-crew, page variant only. */}
{!overlay && <CrewPanel crew={detail.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
{/* (Full crew now lives in the enriched Details/Keywords/Crew grid above the cast row.) */}
{/* Collection strips — the other titles in this movie's collection(s), directly playable.
Each source bucket (Collections / IMDb / TMDb / smart / …) is independently hideable. */}
+9 -2
View File
@@ -348,12 +348,19 @@ export function CastStrip({
</div>
)}
</div>
{/* Name + role are each capped to ONE line (truncate → ellipsis) so every tile has the
same text height and the row of circles sits on one baseline regardless of name length. */}
<div
className={`mt-1.5 text-xs font-medium line-clamp-2 leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
className={`mt-1.5 truncate text-xs font-medium leading-tight ${clickable ? "group-hover/cast:text-accent" : ""}`}
title={c.name}
>
{c.name}
</div>
{c.role && <div className="text-2xs text-muted line-clamp-1">{c.role}</div>}
{c.role && (
<div className="truncate text-2xs text-muted" title={c.role}>
{c.role}
</div>
)}
</>
);
return clickable ? (
+2 -2
View File
@@ -144,7 +144,7 @@ export function PlexPersonView({
const section = (titleKey: string, cards: PlexCard[]) =>
cards.length > 0 ? (
<section className="mb-8">
<section className="mb-5">
<h2 className="text-sm font-semibold mb-3">{t(titleKey, { count: cards.length })}</h2>
<div className="grid grid-cols-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{cards.map((c) => (
@@ -237,7 +237,7 @@ export function PlexPersonView({
{/* "Known for" — the person's most-popular titles that are in our library (E5 S6). */}
{d.known_for.length > 0 && (
<section className="mb-8">
<section className="mb-5">
<Carousel title={t("plex.person.knownFor")} ariaLabel={t("plex.person.knownFor")}>
{d.known_for.map((c) => (
<div key={c.id} className="w-[150px] shrink-0">
+3
View File
@@ -189,6 +189,9 @@
"prefArtBg": "Faint backdrop",
"prefCast": "Cast & crew",
"prefRelated": "Related shows",
"prefFacts": "Details",
"prefKeywords": "Keywords",
"prefCrew": "Crew",
"stripSource": {
"collection": "Collections",
"imdb": "IMDb",
+3 -1
View File
@@ -72,7 +72,8 @@
"plex_sync": "Mirrors the enabled Plex libraries into the app's catalogue — the metadata behind Plex browsing, search, filters and the info pages (genres, cast, ratings, artwork). If it stops, newly added or changed Plex titles don't appear until the next sync.",
"plex_watch_sync": "Pulls recent Plex watch changes (finished episodes, resume positions) into Siftlode and re-pushes any local watch changes that didn't reach Plex. The cheap two-way keep-in-sync between full reconciles. If it stops, watched/resume made in the Plex app takes longer to show here.",
"plex_watch_reconcile": "A full watch-state reconcile that rescans every library — catches what the incremental pass can't, notably an episode un-marked as watched on the Plex side. Heavier, so it runs about once a day. If it stops, un-watches done in Plex aren't mirrored back.",
"audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded."
"audit_gc": "Prunes admin audit-log entries older than the configured retention window (Configuration → Audit log). If it stops, the audit log just keeps growing — harmless but unbounded.",
"tmdb_enrich": "Enriches the mirrored Plex catalogue from TMDB (keywords, crew, budget/revenue, TMDb score, recommendations, artwork), matched by each title's canonical id and served from the app's own database. Optional — a no-op until a TMDB API key is set. If it stops, new Plex titles just miss their TMDB extras until it runs again."
},
"jobs": {
"rss_poll": "RSS poll (new uploads)",
@@ -89,6 +90,7 @@
"plex_sync": "Plex library sync",
"plex_watch_sync": "Plex watch sync (incremental)",
"plex_watch_reconcile": "Plex watch reconcile (full)",
"tmdb_enrich": "TMDB enrichment (Plex)",
"audit_gc": "Audit-log cleanup"
},
"queue": {
+3
View File
@@ -189,6 +189,9 @@
"prefArtBg": "Halvány háttér",
"prefCast": "Szereplők",
"prefRelated": "Kapcsolódó sorozatok",
"prefFacts": "Részletek",
"prefKeywords": "Kulcsszavak",
"prefCrew": "Stáb",
"stripSource": {
"collection": "Kollekciók",
"imdb": "IMDb",
+3 -1
View File
@@ -72,7 +72,8 @@
"plex_sync": "Tükrözi az engedélyezett Plex-könyvtárakat az app katalógusába — ez adja a Plex böngészés, keresés, szűrők és infó-oldalak metaadatait (műfaj, szereplők, pontszám, borítók). Ha leáll, az újonnan hozzáadott vagy módosított Plex-címek csak a következő sync után jelennek meg.",
"plex_watch_sync": "Áthozza a Plex-oldali friss megtekintés-változásokat (befejezett epizódok, folytatás-pozíciók) a Siftlode-ba, és felnyomja a Plexet el nem érő helyi változásokat. Ez az olcsó kétirányú szinkron a teljes reconcile-ok között. Ha leáll, a Plex-appban jelölt megtekintés/folytatás lassabban jelenik meg itt.",
"plex_watch_reconcile": "Teljes megtekintés-állapot reconcile, ami minden könyvtárat újraszken­nel — elkapja, amit az inkrementális menet nem, főleg ha egy epizódról a Plex oldalán levették a megtekintve jelet. Nehezebb, ezért kb. naponta fut. Ha leáll, a Plexben végzett un-watch nem tükröződik vissza.",
"audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul."
"audit_gc": "Törli a beállított megőrzési időnél (Beállítások → Naplózás) régebbi admin-naplóbejegyzéseket. Ha leáll, a napló csak nő — ártalmatlan, de korlátlanul.",
"tmdb_enrich": "A tükrözött Plex-katalógust dúsítja a TMDB-ből (kulcsszavak, stáb, költségvetés/bevétel, TMDb-pontszám, ajánlások, artwork), a címek kanonikus azonosítója alapján párosítva, és az app saját adatbázisából szolgálja ki. Opcionális — TMDB API-kulcs nélkül nem csinál semmit. Ha leáll, az új Plex-címek csak a következő futásig maradnak TMDB-extrák nélkül."
},
"jobs": {
"rss_poll": "RSS-lekérdezés (új feltöltések)",
@@ -89,6 +90,7 @@
"plex_sync": "Plex-könyvtár szinkron",
"plex_watch_sync": "Plex megtekintés-szinkron (inkrementális)",
"plex_watch_reconcile": "Plex megtekintés-reconcile (teljes)",
"tmdb_enrich": "TMDB-dúsítás (Plex)",
"audit_gc": "Napló-karbantartás"
},
"queue": {
+4 -2
View File
@@ -125,7 +125,7 @@ export function useCrossfadeBackdrop(
const pool = key ? key.split("|") : [];
if (!enabled || pool.length === 0) {
clear();
return;
return clear; // and clear again on unmount / dep-change (idempotent)
}
paint(pool[0]!);
@@ -133,7 +133,9 @@ export function useCrossfadeBackdrop(
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!animate || reduce || pool.length < 2) return; // static: first image only
// Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise
// #root keeps the painted backdrop after leaving the page (e.g. switching to another module).
if (!animate || reduce || pool.length < 2) return clear;
const layer = document.createElement("div");
Object.assign(layer.style, {