diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5943fb70..c77465e4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,11 +3,9 @@ import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { api, - EMPTY_PLEX_FILTERS, getActiveAccount, setActiveAccount, setUnauthorizedHandler, - type PlexFilters, } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; import { @@ -156,30 +154,6 @@ export default function App() { : channelsViewRaw === "blocked" ? "blocked" : "subscribed"; - // Plex module filters (its own left-sidebar filter section) — per-account persisted. - // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library). - // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".) - const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both"); - const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both"; - const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all"); - const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title"); - const [plexPlaylistOpen, setPlexPlaylistOpen] = useState(null); // sidebar → open a playlist - // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). - const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}"); - const plexFilters = useMemo(() => { - try { - return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(plexFiltersRaw) }; - } catch { - return EMPTY_PLEX_FILTERS; - } - }, [plexFiltersRaw]); - 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] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search // 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). const [channelsFilterReset, setChannelsFilterReset] = useState(0); @@ -581,15 +555,6 @@ export default function App() { {page === "plex" && meQuery.data!.plex_enabled && ( setPanelLayout("plex", l)} collapsed={filterCollapsed} @@ -644,8 +609,6 @@ export default function App() { header={
setWizardOpen(true)} /> ) : page === "plex" && meQuery.data!.plex_enabled ? ( - setPlexPlaylistOpen(null)} - /> + ) : ( void; channelsSearch: string; setChannelsSearch: (q: string) => void; // Trigger a live YouTube search for the current term (feed only; hidden for the demo account, @@ -39,9 +35,12 @@ export default function Header({ const { setPage } = useNavigationActions(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); - // The playlists name filter lives in PlaylistsProvider (shared with its rail + detail pane). + // The playlists name filter lives in PlaylistsProvider (shared with its rail + detail pane); the + // Plex search term lives in PlexProvider (shared with its rail + browse grid). const { search: playlistsSearch } = usePlaylists(); const { setSearch: setPlaylistsSearch } = usePlaylistsActions(); + const { q: plexQ } = usePlex(); + const { setQ: setPlexQ } = usePlexActions(); // The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so // it stays correct as modules are added/gated). Labels of every active module feed ModuleName's // dynamic width so the pill is sized to the widest reachable title in the current language. diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index 96a611e5..03782890 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -34,6 +34,7 @@ import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd"; import PlexCollectionEditor from "./PlexCollectionEditor"; import { PageToolbar } from "./PageShell"; +import { usePlex, usePlexActions } from "./PlexProvider"; // Lazy: the rich player (pulls in hls.js) loads only when something is first played. const PlexPlayer = lazy(() => import("./PlexPlayer")); @@ -54,34 +55,16 @@ type Sub = // (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces // for now. Rendered by App for page==="plex" (its own nav module). -type Props = { - q: string; - scope: string; // movie | show | both (unified cross-library scope) - setScope: (v: string) => void; - show: string; - sort: string; - filters: PlexFilters; - setFilters: (f: PlexFilters) => void; - // The sidebar opens a playlist by setting this; PlexBrowse turns it into a history subview + clears it. - openPlaylist?: number | null; - onPlaylistOpened?: () => void; -}; - const PAGE = 40; -export default function PlexBrowse({ - q, - scope, - setScope, - show, - sort, - filters, - setFilters, - openPlaylist, - onPlaylistOpened, -}: Props) { +export default function PlexBrowse() { const { t } = useTranslation(); const qc = useQueryClient(); + // Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the + // search term (q) comes from the shared header box; the rail hands off a playlist to open via the + // shared `playlistOpen` slot, which we consume into a history subview then clear. + const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex(); + const { setScope, setFilters, setPlaylistOpen } = usePlexActions(); const dq = useDebounced(q.trim(), 350); const sub = useHistorySubview({ kind: "grid" }); @@ -90,7 +73,7 @@ export default function PlexBrowse({ useEffect(() => { if (openPlaylist != null) { sub.open({ kind: "playlist", id: openPlaylist }); - onPlaylistOpened?.(); + setPlaylistOpen(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [openPlaylist]); diff --git a/frontend/src/components/PlexProvider.tsx b/frontend/src/components/PlexProvider.tsx new file mode 100644 index 00000000..dc37f357 --- /dev/null +++ b/frontend/src/components/PlexProvider.tsx @@ -0,0 +1,124 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; +import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api"; +import { getAccountRaw, LS, setAccountRaw } from "../lib/storage"; + +// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort / +// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of +// App so the PlexSidebar (rail), PlexBrowse (content), and the header search box read it via hooks +// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts, +// stable useCallback setters, one-line hooks; composed in main.tsx around ). +// +// scope / show / sort / filters / q are per-account persisted (localStorage); the account switch does +// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral +// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex +// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks +// into (and is never greeted by a stale value from) the feed. + +type PlexState = { + scope: string; // movie | show | both (unified cross-library scope) + show: string; + sort: string; + filters: PlexFilters; + q: string; + playlistOpen: number | null; +}; + +interface PlexActions { + setScope: (v: string) => void; + setShow: (v: string) => void; + setSort: (v: string) => void; + setFilters: (f: PlexFilters) => void; + setQ: (q: string) => void; + setPlaylistOpen: (id: number | null) => void; +} + +const StateContext = createContext({ + scope: "both", + show: "all", + sort: "title", + filters: EMPTY_PLEX_FILTERS, + q: "", + playlistOpen: null, +}); +const ActionsContext = createContext({ + setScope: () => {}, + setShow: () => {}, + setSort: () => {}, + setFilters: () => {}, + setQ: () => {}, + setPlaylistOpen: () => {}, +}); + +export function usePlex(): PlexState { + return useContext(StateContext); +} +export function usePlexActions(): PlexActions { + return useContext(ActionsContext); +} + +export function PlexProvider({ children }: { children: ReactNode }) { + // The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library). + // (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".) + const [scope, setScopeState] = useState(() => { + const v = getAccountRaw(LS.plexLibrary) ?? "both"; + return ["movie", "show", "both"].includes(v) ? v : "both"; + }); + const [show, setShowState] = useState(() => getAccountRaw(LS.plexShow) ?? "all"); + const [sort, setSortState] = useState(() => getAccountRaw(LS.plexSort) ?? "title"); + // The expanded Plex filters live as one JSON blob (the string-only account store serializes it). + const [filtersRaw, setFiltersRaw] = useState(() => getAccountRaw(LS.plexFilters) ?? "{}"); + const filters = useMemo(() => { + try { + return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(filtersRaw) }; + } catch { + return EMPTY_PLEX_FILTERS; + } + }, [filtersRaw]); + // Survives F5 (persisted), unlike the feed search — see the scope note above. + const [q, setQState] = useState(() => getAccountRaw(LS.plexQ) ?? ""); + const [playlistOpen, setPlaylistOpen] = useState(null); + + const setScope = useCallback((v: string) => { + setScopeState(v); + setAccountRaw(LS.plexLibrary, v); + }, []); + const setShow = useCallback((v: string) => { + setShowState(v); + setAccountRaw(LS.plexShow, v); + }, []); + const setSort = useCallback((v: string) => { + setSortState(v); + setAccountRaw(LS.plexSort, v); + }, []); + const setFilters = useCallback((f: PlexFilters) => { + const s = JSON.stringify(f); + setFiltersRaw(s); + setAccountRaw(LS.plexFilters, s); + }, []); + const setQ = useCallback((v: string) => { + setQState(v); + setAccountRaw(LS.plexQ, v); + }, []); + + const state = useMemo( + () => ({ scope, show, sort, filters, q, playlistOpen }), + [scope, show, sort, filters, q, playlistOpen], + ); + const actions = useMemo( + () => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }), + [setScope, setShow, setSort, setFilters, setQ], + ); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 7cbbc528..d4639a69 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -8,6 +8,7 @@ import { useDebounced } from "../lib/useDebounced"; import { useScrollFade } from "../lib/useScrollFade"; import SidePanel from "./SidePanel"; import PanelGroups, { type PanelItem } from "./PanelGroups"; +import { usePlex, usePlexActions } from "./PlexProvider"; // The Plex module's left filter column — now the shared floating SidePanel with its filter groups // as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata @@ -15,15 +16,6 @@ import PanelGroups, { type PanelItem } from "./PanelGroups"; // the backend so the panel only offers what the library actually contains. type Props = { - scope: string; // movie | show | both (unified cross-library scope) - setScope: (v: string) => void; - show: string; - setShow: (v: string) => void; - sort: string; - setSort: (v: string) => void; - filters: PlexFilters; - setFilters: (f: PlexFilters) => void; - onOpenPlaylist: (id: number) => void; layout: PanelLayout; setLayout: (l: PanelLayout) => void; collapsed: boolean; @@ -42,21 +34,17 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[ ]; export default function PlexSidebar({ - scope, - setScope, - show, - setShow, - sort, - setSort, - filters, - setFilters, - onOpenPlaylist, layout, setLayout, collapsed, onToggleCollapse, }: Props) { const { t } = useTranslation(); + // Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and + // the header search box); this rail is the primary editor of them. Opening a playlist is a + // hand-off to PlexBrowse via the shared `playlistOpen` slot. + const { scope, show, sort, filters } = usePlex(); + const { setScope, setShow, setSort, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions(); const collFade = useScrollFade(); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index aa7fabfa..3da3f344 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,6 +6,7 @@ import { ConfirmProvider } from "./components/ConfirmProvider"; import { NavigationProvider } from "./components/NavigationProvider"; import { FeedFiltersProvider } from "./components/FeedFiltersProvider"; import { PlaylistsProvider } from "./components/PlaylistsProvider"; +import { PlexProvider } from "./components/PlexProvider"; import "./i18n"; import "./index.css"; @@ -41,7 +42,9 @@ const root = - + + +