The player names what it plays in its own history entry (watch, wrel, wtab): a reload or a
bookmark reopens it on the page that hosts it, with that page's list as the queue, or alone
from the catalog (GET /api/videos/{id}/row); a page that hosts none gets the shell's. Back
lands on the page's address without it (useBackToClose's url; ui/Modal passes it on). The
Files viewer and player do the same (fview, fplay). The Plex and channel-manager views are
always written in full, as their fallback is remembered state.
135 lines
6.1 KiB
TypeScript
135 lines
6.1 KiB
TypeScript
// Shared column-sort primitives, lifted out of DataTable so a NON-table renderer (the channel
|
|
// manager's card layout) can sort the same rows the same way — one comparator, one source of truth.
|
|
// Kept structural (no `Column` import) so it has zero coupling to DataTable.
|
|
|
|
import { compareText } from "./collate";
|
|
|
|
export type SortState = { key: string; dir: "asc" | "desc" } | null;
|
|
|
|
interface Sortable<T> {
|
|
key: string;
|
|
sortValue?: (row: T) => string | number;
|
|
}
|
|
|
|
/** Sort a copy of `rows` by the sortable column named in `sort` (numeric diff, or text in the UI
|
|
* language's alphabet — `lang`). Returns `rows` unchanged when there's no sort or the column has no
|
|
* `sortValue`. */
|
|
export function sortRows<T>(rows: T[], columns: Sortable<T>[], sort: SortState, lang: string): T[] {
|
|
if (!sort) return rows;
|
|
const get = columns.find((c) => c.key === sort.key)?.sortValue;
|
|
if (!get) return rows;
|
|
return [...rows].sort((a, b) => {
|
|
const av = get(a);
|
|
const bv = get(b);
|
|
const cmp =
|
|
typeof av === "number" && typeof bv === "number"
|
|
? av - bv
|
|
: compareText(String(av), String(bv), lang);
|
|
return sort.dir === "asc" ? cmp : -cmp;
|
|
});
|
|
}
|
|
|
|
/** Coerce a stored value to a SortState. Anything malformed (corrupt storage, a shape from an older
|
|
* build) becomes "no sort" rather than flowing a junk `dir` into the comparator. */
|
|
export function parseSortState(raw: unknown): SortState {
|
|
if (!raw || typeof raw !== "object") return null;
|
|
const { key, dir } = raw as { key?: unknown; dir?: unknown };
|
|
return typeof key === "string" && (dir === "asc" || dir === "desc") ? { key, dir } : null;
|
|
}
|
|
|
|
/** Encode a SortState to the compact `key:dir` wire form the shareable-URL modules (plex/channels)
|
|
* put in the address bar. Sort keys are simple words (no colon), so the plain join round-trips. Returns
|
|
* null for no-sort. A module with a default sort short-circuits that to null at its own call site. */
|
|
export function encodeSortParam(s: SortState): string | null {
|
|
return s ? `${s.key}:${s.dir}` : null;
|
|
}
|
|
|
|
/** Inverse of `encodeSortParam`: parse `key:dir` (or a bare key → asc) from the URL back into a
|
|
* SortState, healing anything malformed to null. One codec for both URL-state modules — no drift. */
|
|
export function decodeSortParam(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 list's own order (no column sort) as a word of its own, for a page whose sort is remembered (an
|
|
// account pref, this browser's view): there the sort is always written, because a page opened without
|
|
// one takes the remembered sort, which may not be the one bookmarked.
|
|
const UNSORTED = "none";
|
|
|
|
export function encodeSortParamOrNone(s: SortState): string {
|
|
return encodeSortParam(s) ?? UNSORTED;
|
|
}
|
|
|
|
/** The sort an address names; `undefined` when it names none or a malformed one (the remembered one
|
|
* applies). */
|
|
export function decodeSortParamOrNone(raw: string | null): SortState | undefined {
|
|
if (raw == null) return undefined;
|
|
return raw === UNSORTED ? null : (decodeSortParam(raw) ?? undefined);
|
|
}
|
|
|
|
/** The header-click cycle: unsorted → ascending → descending → unsorted. */
|
|
export function cycleSort(current: SortState, key: string): SortState {
|
|
if (!current || current.key !== key) return { key, dir: "asc" };
|
|
if (current.dir === "asc") return { key, dir: "desc" };
|
|
return null;
|
|
}
|
|
|
|
// --- the source-agnostic sort vocabulary -------------------------------------------------
|
|
// A `SortOption[]` is the one shape every content surface (YT feed, Plex library, channel
|
|
// manager, discovery) hands to the shared `SortControl`. A table derives it from its columns;
|
|
// the feed and Plex declare it explicitly. Direction lives in `SortState`, never encoded into
|
|
// the key — the feed's wire string (`feedSort.ts`) is a codec at the server boundary only.
|
|
|
|
export interface SortOption {
|
|
key: string;
|
|
label: string;
|
|
/** No meaningful asc/desc flip (the feed's `shuffle`/`relevance`) — hides the direction toggle. */
|
|
directionless?: boolean;
|
|
/** The natural first direction when this option is freshly picked (defaults to `"asc"`). */
|
|
defaultDir?: "asc" | "desc";
|
|
}
|
|
|
|
/** Decide the next `SortState` when the user PICKS `key` from a dropdown/chip (NOT the header-click
|
|
* cycle — that's `cycleSort`). Re-picking the same key keeps its direction; a new key starts at its
|
|
* `defaultDir` (asc fallback); a directionless option carries a stable dir; an empty key clears sort. */
|
|
export function pickSort(options: SortOption[], prev: SortState, key: string): SortState {
|
|
if (!key) return null;
|
|
const opt = options.find((o) => o.key === key);
|
|
if (!opt) return null;
|
|
if (opt.directionless) return { key, dir: "desc" };
|
|
if (prev?.key === key) return prev;
|
|
return { key, dir: opt.defaultDir ?? "asc" };
|
|
}
|
|
|
|
/** Collapse a sort that names an option no longer offered (a renamed column, or Plex's movie/show
|
|
* scope swap dropping a key) to `fallback` — otherwise a stale key claims a sort nothing applies. */
|
|
export function healSort(
|
|
sort: SortState,
|
|
options: SortOption[],
|
|
fallback: SortState = null,
|
|
): SortState {
|
|
return sort && options.some((o) => o.key === sort.key) ? sort : fallback;
|
|
}
|
|
|
|
/** Hydrate a persisted raw string into a `SortState`. Accepts the JSON `{key,dir}` shape and tolerates
|
|
* a legacy bare-key string (older builds stored just the key). When `options` is empty, the value is
|
|
* parsed but NOT healed — for a table/provider that owns no vocabulary itself and heals for display
|
|
* elsewhere (e.g. `ColumnSortControl`); a non-empty `options` heals an absent key to `fallback`. */
|
|
export function hydrateSort(
|
|
raw: string | null,
|
|
options: SortOption[],
|
|
fallback: SortState = null,
|
|
): SortState {
|
|
if (!raw) return fallback;
|
|
const heal = (s: SortState): SortState =>
|
|
options.length === 0 ? (s ?? fallback) : healSort(s, options, fallback);
|
|
try {
|
|
return heal(parseSortState(JSON.parse(raw)));
|
|
} catch {
|
|
const opt = options.find((o) => o.key === raw);
|
|
return opt ? { key: raw, dir: opt.defaultDir ?? "asc" } : fallback;
|
|
}
|
|
}
|