Files
siftlode/frontend/src/components/PlexProvider.tsx
T
peter e795d191c5 fix(boot): review round 1 — reconcile the tab pin, one loading clock, honest probe failures
Deep-review round 1 over main..improvement/watch-background-audio.

BootGate
- The tab's account pin is corrected to the account the server answered as.
  The server honours the pin only for an account still signed in to this
  browser and otherwise answers as the default, so a tab pinned to an account
  signed out in another tab ran as the default on the server while reading
  and writing the gone account's local state.
- A mounted tab whose identity changes (its session ended, or the case above)
  reloads instead of rebuilding the tree in place, like every account switch;
  that also drops the previous account's query cache. The account key on the
  tree goes: the readyFor gate already unmounted it on every change.
- An unanswered setup probe shows the error with Retry, like an unanswered
  account probe, instead of the signed-out landing to a signed-in user.
- Comments: /api/me never 401s; the ended-session path is identity → nobody.

Loading states
- LoadingState's reveal delay is one clock across loading states that hand
  over to each other (boot screen → page-chunk fallback → the page's own
  fetching state), instead of a CSS delay restarted by each, which blanked a
  visible spinner for another 300 ms at every hand-off. BootScreen is now the
  shared loading visual, full screen, counting from navigation start.

Plex shared link
- Persists only the dimensions the link carries again. Writing the default
  filters for an omitted `pf` created the stored-filters key, which is what
  tells SavedViewsProvider the account picked filters, so the account's
  default Plex saved view was never applied again.

Watch page
- The Repeat toggle uses the shared pressed-pill paint (a solid fill when on)
  instead of Button variants that differ only in colour, and its flag goes
  through storage.ts readJSON/writeJSON.
- The actions wrap under the title when the row is too narrow, instead of
  squeezing the title into a column a word wide on a phone; the title breaks
  long words.

Comments that still justified code by the removed adopt effects (usePref,
PrefsProvider, FeedViewProvider) are rewritten against the seed-once model.

Tests: BootGate error/retry for both probes, setup mode, a failed background
refetch keeping the app, the pin correction and the reload on an identity
change; the shared reveal clock; the Plex link persist; the audio signature
actually moved off version 4; a video download (av and v) still remuxed to
an mp4. Each mutation-checked RED for its own reason.
2026-09-15 00:53:15 +02:00

269 lines
12 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { coercePlexFilters, EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { parseSortState, type SortState } from "../lib/columnSort";
import { parsePlexField, type PlexSearchField } from "../lib/plexSearch";
import { PLEX_SORT_FALLBACK } from "../lib/plexSort";
import {
hasPlexParams,
plexParamsToState,
plexParamUpdates,
type PlexUrlState,
} from "../lib/plexUrlState";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useUrlMirror } from "../lib/useUrlMirror";
import { useNavigation } from "./NavigationProvider";
// 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 <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage) and seeded from it at mount
// (BootGate pins the tab's account first; an account switch reloads). `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.
// A "jump straight to this title" hand-off: PlexBrowse opens a movie's info page (like the card's ⓘ),
// a show's drill-down (like clicking a show card), or — for E24's Home hero, whose whole promise is a
// single Play button — the PLAYER for a movie/episode rating_key. `play` exists because the player
// lives inside this module with its lazy hls.js and its provider tree; mounting a second one on the
// landing page to avoid one hand-off would be the expensive way round.
export type PlexJumpTarget = {
id: string;
type: "movie" | "show" | "play";
/** The jump ARRIVED WITH a page change — another module set this and sent the user here in one
* gesture (the Home surface plays a Plex title). The opened sub-view then REPLACES the page entry
* that hand-off just pushed, instead of stacking on top of it, so Back returns to where the user
* actually came from rather than to a module root they never visited. Omitted for a jump made
* from INSIDE the Plex module (the header typeahead), where the entry being replaced would be a
* sub-view the user really was looking at. */
replace?: boolean;
};
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
sort: SortState; // {key,dir}; healed against the scope's options at the point of use
filters: PlexFilters;
q: string;
field: PlexSearchField; // search scope-pill; healed against `scope` at the point of use
playlistOpen: number | null;
jumpTo: PlexJumpTarget | null; // typeahead → grid hand-off ("open this title")
};
interface PlexActions {
setScope: (v: string) => void;
setShow: (v: string) => void;
setSort: (v: SortState) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setField: (f: PlexSearchField) => void;
setPlaylistOpen: (id: number | null) => void;
setJumpTo: (t: PlexJumpTarget | null) => void;
}
const StateContext = createContext<PlexState>({
scope: "both",
show: "all",
sort: PLEX_SORT_FALLBACK,
filters: EMPTY_PLEX_FILTERS,
q: "",
field: "all",
playlistOpen: null,
jumpTo: null,
});
const ActionsContext = createContext<PlexActions>({
setScope: () => {},
setShow: () => {},
setSort: () => {},
setFilters: () => {},
setQ: () => {},
setField: () => {},
setPlaylistOpen: () => {},
setJumpTo: () => {},
});
export function usePlex(): PlexState {
return useContext(StateContext);
}
export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
// The per-account init readers, shared by the useState seeds and the shared-link seed's base.
function readPlexScope(): string {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
}
const readPlexShow = (): string => getAccountRaw(LS.plexShow) ?? "all";
const readPlexFiltersRaw = (): string => getAccountRaw(LS.plexFilters) ?? "{}";
const readPlexQ = (): string => getAccountRaw(LS.plexQ) ?? "";
const readPlexField = (): PlexSearchField => parsePlexField(getAccountRaw(LS.plexField));
// Sort is a {key,dir} SortState. Migrate the old shape: a legacy bare-key string in plexSort plus the
// direction that used to live in plexFilters.sortDir — so an existing sort preference survives the
// upgrade rather than snapping back to the default.
function readPlexSort(): SortState {
const raw = getAccountRaw(LS.plexSort);
if (!raw) return PLEX_SORT_FALLBACK;
let wasJson = false;
try {
const parsed = parseSortState(JSON.parse(raw));
wasJson = true;
if (parsed) return parsed;
} catch {
/* not JSON → a legacy bare-key string, handled below */
}
// Valid JSON that wasn't a SortState is corrupt — fall back rather than treat the JSON text as a
// key. Only a non-JSON raw is a genuine legacy bare key.
if (wasJson) return PLEX_SORT_FALLBACK;
let dir: "asc" | "desc" = "asc";
try {
if (
(JSON.parse(getAccountRaw(LS.plexFilters) ?? "{}") as { sortDir?: string }).sortDir === "desc"
)
dir = "desc";
} catch {
/* ignore */
}
return { key: raw, dir };
}
// S4: a shared Plex-browse link carries the content state in namespaced URL keys (pscope/psort/pshow/
// pf). When present, the URL wins over this account's stored view (like the feed's URL-over-localStorage
// seed) — read once at mount. The keys are Plex-unique, so their mere presence identifies a shared Plex
// view; no ?page check is needed (and the mirror below is page-gated so it never pollutes other pages).
function readPlexFilters(): PlexFilters {
try {
return coercePlexFilters(JSON.parse(readPlexFiltersRaw()) as Record<string, unknown>);
} catch {
return EMPTY_PLEX_FILTERS;
}
}
// The base is this account's stored view (BootGate pins the account before this provider mounts), so a
// PARTIAL link — one carrying only the scope, say — keeps the stored sort, show and filters for the rest.
// `present` records which dimensions the link actually carried: only those are persisted (below).
type PlexSeedPresent = { scope: boolean; sort: boolean; show: boolean; filters: boolean };
type PlexUrlSeed = PlexUrlState & { present: PlexSeedPresent };
function readPlexUrlSeed(): PlexUrlSeed | null {
const params = new URLSearchParams(window.location.search);
if (!hasPlexParams(params)) return null;
const state = plexParamsToState(params, {
scope: readPlexScope(),
sort: readPlexSort(),
show: readPlexShow(),
filters: readPlexFilters(),
});
const present = {
scope: params.has("pscope"),
sort: params.has("psort"),
show: params.has("pshow"),
filters: params.has("pf"),
};
return { ...state, present };
}
export function PlexProvider({ children }: { children: ReactNode }) {
// Captured once: the shared-link content state (or null). Seeds the slices below and is persisted to
// the account (the effect further down), so the shared view also survives a later plain reload.
const [urlSeed] = useState(readPlexUrlSeed);
// 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<string>(() => urlSeed?.scope ?? readPlexScope());
const [show, setShowState] = useState<string>(() => urlSeed?.show ?? readPlexShow());
const [sort, setSortState] = useState<SortState>(() => urlSeed?.sort ?? readPlexSort());
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() =>
urlSeed ? JSON.stringify(urlSeed.filters) : readPlexFiltersRaw(),
);
const filters = useMemo<PlexFilters>(() => {
try {
// coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort
// SortState — left here it would leak into the facets key and be re-persisted), and migrates the
// legacy single `collection`/`collectionTitle` into `collections`/`collectionTitles`. Shared with
// the saved-views provider so the localStorage seed and a DB view blob coerce identically.
return coercePlexFilters(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<string>(readPlexQ);
const [field, setFieldState] = useState<PlexSearchField>(readPlexField);
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
// Ephemeral hand-off like `playlistOpen`: the header typeahead sets the title to jump to;
// PlexBrowse consumes it into an info/show history subview then clears it.
const [jumpTo, setJumpTo] = useState<PlexJumpTarget | null>(null);
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
// account has picked filters, so the account's default saved view would never be applied again.
useEffect(() => {
if (!urlSeed) return;
const { present } = urlSeed;
if (present.scope) setAccountRaw(LS.plexLibrary, urlSeed.scope);
if (present.show) setAccountRaw(LS.plexShow, urlSeed.show);
if (present.sort) setAccountRaw(LS.plexSort, JSON.stringify(urlSeed.sort));
if (present.filters) setAccountRaw(LS.plexFilters, JSON.stringify(urlSeed.filters));
}, [urlSeed]);
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: SortState) => {
setSortState(v);
setAccountRaw(LS.plexSort, JSON.stringify(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 setField = useCallback((v: PlexSearchField) => {
setFieldState(v);
setAccountRaw(LS.plexField, v);
}, []);
const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, field, playlistOpen, jumpTo }),
[scope, show, sort, filters, q, field, playlistOpen, jumpTo],
);
const actions = useMemo<PlexActions>(
() => ({ setScope, setShow, setSort, setFilters, setQ, setField, setPlaylistOpen, setJumpTo }),
[setScope, setShow, setSort, setFilters, setQ, setField],
);
// S4: mirror the content state (scope + sort + watch-state + filters) into the address bar under the
// namespaced keys so a Plex-browse view is shareable. Gated on page===plex: off the Plex page the keys
// are cleared (localStorage still holds the state; it re-mirrors on return). `q`/`field` stay OUT — the
// search term is deliberately transient-per-view and not part of the shared four-key contract.
const { page } = useNavigation();
useUrlMirror(plexParamUpdates(page === "plex" ? { scope, sort, show, filters } : null));
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}