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:
@@ -0,0 +1,73 @@
|
||||
// Serialize the Plex-browse content state (library scope + watch-state + sort + metadata filters) to/
|
||||
// from the address bar so a Plex-browse view is bookmarkable/shareable — the S4 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 the app
|
||||
// is 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. Only non-
|
||||
// default values are emitted, so URLs stay clean.
|
||||
import { parsePlexFilters, plexFiltersToParams, type PlexFilters } from "./api";
|
||||
import { parseSortState, type SortState } from "./columnSort";
|
||||
import { PLEX_SORT_FALLBACK } from "./plexSort";
|
||||
|
||||
// The content state S4 mirrors. `sort` is the RAW provider SortState (scope-healed only at the point of
|
||||
// use), so the user's real preference round-trips through the URL.
|
||||
export interface PlexUrlState {
|
||||
scope: string;
|
||||
sort: SortState;
|
||||
show: string;
|
||||
filters: PlexFilters;
|
||||
}
|
||||
|
||||
const KEYS = ["pscope", "psort", "pshow", "pf"] as const;
|
||||
const SCOPES = ["movie", "show", "both"];
|
||||
const SHOWS = ["all", "unwatched", "in_progress", "watched"];
|
||||
|
||||
// Sort ⇄ `key:dir` (e.g. `year:desc`). Plex sort keys are simple words (no colon), so a plain split is
|
||||
// safe. The default (`title:asc`) emits nothing.
|
||||
function encodeSort(s: SortState): string | null {
|
||||
if (!s || (s.key === PLEX_SORT_FALLBACK?.key && s.dir === PLEX_SORT_FALLBACK?.dir)) return null;
|
||||
return `${s.key}:${s.dir}`;
|
||||
}
|
||||
function decodeSort(raw: string): SortState {
|
||||
const i = raw.indexOf(":");
|
||||
const key = i === -1 ? raw : raw.slice(0, i);
|
||||
const dir = i === -1 ? "asc" : raw.slice(i + 1);
|
||||
return parseSortState({ key, dir });
|
||||
}
|
||||
|
||||
/** The address-bar delta for the Plex content state: each namespaced key mapped to its emitted value,
|
||||
* or null to delete it (so a control back at its default clears the key). Pass `null` to clear ALL Plex
|
||||
* keys — used when the app is off the Plex page (localStorage still holds the state; it re-mirrors on
|
||||
* return). Feed the result into urlWithParams(). */
|
||||
export function plexParamUpdates(state: PlexUrlState | null): Record<string, string | null> {
|
||||
if (!state) return { pscope: null, psort: null, pshow: null, pf: null };
|
||||
const pf = plexFiltersToParams(state.filters).toString();
|
||||
return {
|
||||
pscope: state.scope && state.scope !== "both" ? state.scope : null,
|
||||
psort: encodeSort(state.sort),
|
||||
pshow: state.show && state.show !== "all" ? state.show : null,
|
||||
pf: pf || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** True if the query string carries any Plex content key we recognize (a shared Plex-browse link). */
|
||||
export function hasPlexParams(params: URLSearchParams): boolean {
|
||||
return KEYS.some((k) => params.has(k));
|
||||
}
|
||||
|
||||
/** Merge the Plex content keys from the URL over a base state (the localStorage-seeded defaults); an
|
||||
* absent or invalid key keeps the base value, so a partial link (only `?psort=`) is honoured. */
|
||||
export function plexParamsToState(params: URLSearchParams, base: PlexUrlState): PlexUrlState {
|
||||
const scope = params.get("pscope");
|
||||
const show = params.get("pshow");
|
||||
const psort = params.get("psort");
|
||||
const sort = psort != null ? (decodeSort(psort) ?? base.sort) : base.sort;
|
||||
return {
|
||||
scope: scope && SCOPES.includes(scope) ? scope : base.scope,
|
||||
sort,
|
||||
show: show && SHOWS.includes(show) ? show : base.show,
|
||||
filters: params.has("pf")
|
||||
? parsePlexFilters(new URLSearchParams(params.get("pf") ?? ""))
|
||||
: base.filters,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user