Every locale formatter takes the language as an argument. Under the React
Compiler a formatter that read the i18n singleton was memoized on its other
arguments, so a card kept "2 hr 4 min" after a switch to Hungarian; plexLang()
had the same flaw and left Plex queries in the old language. Components now
take the language from useTranslation, and the signature makes forgetting it
a type error.
- ratings, file sizes, stream stats: the language's decimal mark ("8,3")
- view and subscriber counts compact in the language ("1,2 E")
- seconds as the language's unit ("6 mp") in the sliders and the stats
- a person's birth and death dates as dates, not ISO strings
- A-Z lists by the shown name in the language's alphabet (compareText),
the tag catalog re-sorted by its translated names
- lint local/locale-format (was date-format) also refuses Intl.NumberFormat
outside lib/format.ts, localeCompare, and a component reading the language
off the i18n singleton
122 lines
4.9 KiB
TypeScript
122 lines
4.9 KiB
TypeScript
// The feed's sort vocabulary. `FeedFilters.sort` is a single wire string ("newest", "views_asc",
|
|
// …); the UI thinks in a key + direction. Kept here rather than inside Feed so the active-filter
|
|
// chips can label a sort without re-deriving the mapping.
|
|
import type { Video } from "./api";
|
|
import type { SortOption } from "./columnSort";
|
|
import { compareText } from "./collate";
|
|
|
|
export type SortKey =
|
|
| "date"
|
|
| "popular"
|
|
| "duration"
|
|
| "title"
|
|
| "subscribers"
|
|
| "priority"
|
|
| "shuffle"
|
|
| "relevance";
|
|
|
|
export const SORT_KEYS: SortKey[] = [
|
|
"date",
|
|
"popular",
|
|
"duration",
|
|
"title",
|
|
"subscribers",
|
|
"priority",
|
|
"shuffle",
|
|
];
|
|
|
|
/** Sorts with no meaningful asc/desc flip. */
|
|
const DIRECTIONLESS: SortKey[] = ["shuffle", "relevance"];
|
|
|
|
const SORT_MAP: Record<Exclude<SortKey, "shuffle" | "relevance">, { asc: string; desc: string }> = {
|
|
date: { desc: "newest", asc: "oldest" },
|
|
popular: { desc: "views", asc: "views_asc" },
|
|
duration: { desc: "duration_desc", asc: "duration_asc" },
|
|
title: { asc: "title", desc: "title_desc" },
|
|
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
|
|
priority: { desc: "priority", asc: "priority_asc" },
|
|
};
|
|
|
|
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle" | "relevance">, "asc" | "desc"> = {
|
|
date: "desc",
|
|
popular: "desc",
|
|
duration: "desc",
|
|
title: "asc",
|
|
subscribers: "desc",
|
|
priority: "desc",
|
|
};
|
|
|
|
export function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
|
|
if (s === "shuffle" || s === "relevance") return { key: s, dir: "desc" };
|
|
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
|
|
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
|
|
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
|
|
}
|
|
return { key: "date", dir: "desc" };
|
|
}
|
|
|
|
export function buildSort(key: SortKey, dir: "asc" | "desc"): string {
|
|
if (key === "shuffle" || key === "relevance") return key;
|
|
return SORT_MAP[key][dir];
|
|
}
|
|
|
|
/** Feed sort keys as options for the shared SortControl. ONE mapping for the library feed and the
|
|
* live search results, which share the vocabulary: two copies could hand the same key a different
|
|
* direction default, or lose its directionless flag, depending on which mode you were in. */
|
|
export function feedSortOptions(keys: SortKey[], label: (k: SortKey) => string): SortOption[] {
|
|
return keys.map((k) => ({
|
|
key: k,
|
|
label: label(k),
|
|
directionless: DIRECTIONLESS.includes(k),
|
|
defaultDir: DIRECTIONLESS.includes(k)
|
|
? undefined
|
|
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
|
|
}));
|
|
}
|
|
|
|
// --- Live YouTube search results ----------------------------------------------------------------
|
|
//
|
|
// The results view had NO sort control at all — the order was the provider's and that was that. It is
|
|
// sorted HERE, on the page already loaded, rather than by re-asking YouTube with an `order=` param,
|
|
// for three reasons: the owner's case is "even one page of results", i.e. reorder what is on screen;
|
|
// a re-query would REPLACE the results, which is not what "sort these" means; and the zero-quota
|
|
// scrape source has no order parameter at all, so a server-side control would silently do nothing (or
|
|
// quietly cost 100 quota units) depending on which source happened to serve the page.
|
|
//
|
|
// Only the keys a search result actually carries are offered. `subscribers` and `priority` are
|
|
// properties of a SUBSCRIPTION, which a live search result is not, and `shuffle` on a ranked result
|
|
// list is noise — an option that cannot mean anything here is worse than no option.
|
|
export const YT_SORT_KEYS: SortKey[] = ["relevance", "date", "popular", "duration", "title"];
|
|
|
|
const YT_SORT_VALUE: Partial<Record<SortKey, (v: Video) => number | string | null | undefined>> = {
|
|
date: (v) => (v.published_at ? Date.parse(v.published_at) : null),
|
|
popular: (v) => v.view_count,
|
|
duration: (v) => v.duration_seconds,
|
|
title: (v) => v.title,
|
|
};
|
|
|
|
/** Order a loaded page of live-search results. `relevance` is the provider's own ranking, i.e. the
|
|
* order they arrived in — so it is the identity, not a sort.
|
|
*
|
|
* Results missing the value sink to the END in both directions (a video whose duration YouTube did
|
|
* not report is not "the shortest"), and ties keep the provider's ranking, in both directions —
|
|
* hence a signed comparator rather than sorting and reversing, which would flip the ties too. */
|
|
export function sortYtResults(
|
|
items: Video[],
|
|
key: SortKey,
|
|
dir: "asc" | "desc",
|
|
lang: string,
|
|
): Video[] {
|
|
const value = YT_SORT_VALUE[key];
|
|
if (!value) return items; // relevance, and anything a stale pref names that we no longer offer
|
|
const sign = dir === "asc" ? 1 : -1;
|
|
const have = items.filter((v) => value(v) != null);
|
|
const missing = items.filter((v) => value(v) == null);
|
|
have.sort((a, b) => {
|
|
const x = value(a)!;
|
|
const y = value(b)!;
|
|
return sign * (typeof x === "string" ? compareText(x, String(y), lang) : x - Number(y));
|
|
});
|
|
return [...have, ...missing];
|
|
}
|