feat(plex): shareable Plex-browse state in the URL — scope+filters+sort+show (S4)
Mirror the Plex-browse content state into the address bar so a browse view is bookmarkable/shareable, the Plex analogue of the feed's urlState. Keys are NAMESPACED (pscope/psort/pshow/pf) rather than reusing the feed's bare scope/sort/show: FeedFiltersProvider actively strips its own KEYS from the URL whenever off the feed page, so a shared key would be deleted out from under the Plex page. The namespaced keys are Plex-unique (like ?v=/?pl), so no other provider ever reads or clears them. - New lib/plexUrlState.ts: plexParamUpdates / plexParamsToState / hasPlexParams. The pf filter blob reuses the SAME appendPlexFilters wire serializer (new public plexFiltersToParams + inverse parsePlexFilters, kept next to each other so a new wire key can't be added on one side only). - PlexProvider: URL-seed wins over localStorage at mount; the account-pin rehydrate persists the seed instead of clobbering it (mirrors feed's initialHadUrlFilters); useUrlMirror mirrors/clears the 4 keys, gated on page===plex. q/field stay out of the shareable contract. - NavigationProvider: a page switch now also nulls the 4 plex keys. Tests: 8 new round-trip cases in plexUrlState.dom.test.ts (FE vitest 483→491).
This commit is contained in:
@@ -334,6 +334,48 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
|
||||
if (f.collections?.length) u.set("collections", f.collections.join(","));
|
||||
if (f.collectionMode === "all") u.set("collection_mode", "all");
|
||||
}
|
||||
|
||||
// Public wrapper over the private appendPlexFilters so the URL mirror (plexUrlState) serializes the
|
||||
// filter set through the SAME single source the API request uses — the two can never drift. Display-only
|
||||
// titles (collectionTitles) stay out, exactly like the wire params and the saved-view signature.
|
||||
export function plexFiltersToParams(f: PlexFilters): URLSearchParams {
|
||||
const u = new URLSearchParams();
|
||||
appendPlexFilters(u, f);
|
||||
return u;
|
||||
}
|
||||
|
||||
// The inverse of appendPlexFilters: parse a filter sub-querystring back into a full PlexFilters (missing
|
||||
// keys → EMPTY defaults). Kept next to its encoder so a new wire key can't be added on one side only.
|
||||
export function parsePlexFilters(p: URLSearchParams): PlexFilters {
|
||||
const csv = (k: string): string[] => {
|
||||
const v = p.get(k);
|
||||
return v ? v.split(",").filter(Boolean) : [];
|
||||
};
|
||||
const num = (k: string): number | undefined => {
|
||||
if (!p.has(k)) return undefined;
|
||||
const n = Number(p.get(k));
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
};
|
||||
const f: PlexFilters = { ...EMPTY_PLEX_FILTERS };
|
||||
f.genres = csv("genres");
|
||||
if (p.get("genre_mode") === "all") f.genreMode = "all";
|
||||
f.contentRatings = csv("content_ratings");
|
||||
f.yearMin = num("year_min");
|
||||
f.yearMax = num("year_max");
|
||||
f.ratingMin = num("rating_min");
|
||||
f.durationMin = num("duration_min");
|
||||
f.durationMax = num("duration_max");
|
||||
const aw = p.get("added_within");
|
||||
if (aw) f.addedWithin = aw;
|
||||
f.directors = csv("directors");
|
||||
f.actors = csv("actors");
|
||||
if (p.get("actor_mode") === "all") f.actorMode = "all";
|
||||
f.studios = csv("studios");
|
||||
f.keywords = csv("keywords");
|
||||
f.collections = csv("collections");
|
||||
if (p.get("collection_mode") === "all") f.collectionMode = "all";
|
||||
return f;
|
||||
}
|
||||
export interface PlexFacets {
|
||||
genres: { value: string; count: number }[];
|
||||
content_ratings: { value: string; count: number }[];
|
||||
|
||||
Reference in New Issue
Block a user