- In-place editors (Playlists heading + rail rename, Plex collection + playlist rename, PriorityStepper, Scheduler interval): a real Enter closed the editor, returned the focus to the trigger, and the same keystroke's activation pressed that trigger again - re-opening the editor, or in the rail selecting the renamed playlist. useReturnFocus.arm(e) now cancels the key's default (type-enforced); the rail and Scheduler editors do the same. The earlier tests dispatched a synthetic keydown, which has no default. - Plex playlist rename: an unchanged or empty title closes with no write, as the collection rename does. - Switch: a 36x24 box around the track (the 24 floor), 44 on a coarse pointer; `label` is required, and the Plex two-way-sync switch had no name. - z-index: numeric z-10/z-20 and a literal 40 moved onto the named layer scale; a lint rule + contract. - The /watch file card's Download is the filled primary: the page's one action. - Stale comments: the 1024x768 floor, the retired glass tuner.
126 lines
5.7 KiB
TypeScript
126 lines
5.7 KiB
TypeScript
// The feed's view vocabulary. Lifted out of Feed (like feedSort.ts) because it has three
|
|
// consumers that can't share module-private state: App owns the pref, the toolbar's
|
|
// ViewSwitcher offers it, and VirtualFeed/VideoCard render it.
|
|
export type FeedView = "cards" | "cardsSmall" | "rows" | "rowsCompact" | "tiles";
|
|
|
|
export const FEED_VIEWS: readonly FeedView[] = [
|
|
"cards",
|
|
"cardsSmall",
|
|
"rows",
|
|
"rowsCompact",
|
|
"tiles",
|
|
] as const;
|
|
|
|
const FEED_VIEW_DEFAULT: FeedView = "cards";
|
|
|
|
export interface FeedViewSpec {
|
|
/** Which shape VideoCard renders: a portrait card (thumbnail on top) or a horizontal block. */
|
|
family: "card" | "row";
|
|
/** Narrowest grid column in px, or null for ONE full-width column. VirtualFeed derives the
|
|
* column count from this and the container width, so every multi-column view reflows for free. */
|
|
minCol: number | null;
|
|
/** Single-column views only: the reading-width cap in px (null = fill). A long line is hard to
|
|
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
|
|
colMaxWidth: number | null;
|
|
/** Multi-column views only: the most columns to lay out (null = as many as fit). The UPPER end of
|
|
* the tablet..ultrawide contract (E10 S4; tablet portrait since E28). An auto-fill grid always settles just above `minCol`,
|
|
* so without a cap a wider screen buys more, smaller items rather than better ones — measured at
|
|
* 3440px, `cards` laid out 11 columns of 264px. Each cap here is set so the item tops out near
|
|
* 1.5x its own `minCol` on a 3440px ultrawide, which keeps each view recognisably itself (a capped
|
|
* `cardsSmall` must not grow into a `cards`). Below the width where that many columns already fit
|
|
* the cap does nothing at all. For `cards` that width is past 2560; `cardsSmall` already binds AT
|
|
* 2560 with the filter rail collapsed (12 would fit, 11 are laid out) — the intended layout, not
|
|
* an accident, but not "unchanged" either. */
|
|
maxCols: number | null;
|
|
/** Show the video thumbnail. False only for the bare row — whose badges (duration, live state,
|
|
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
|
|
thumb: boolean;
|
|
/** card family: tighter padding and no channel avatar, for the small card's ~180px column. */
|
|
dense: boolean;
|
|
/** row family: actions sit under the meta rather than docked at the right edge. Frees ~168px of
|
|
* width (measured), which is what lets a row block survive in a narrow tile column. */
|
|
actionsBelow: boolean;
|
|
}
|
|
|
|
// The whole matrix in one place. Adding a view here makes TS point at every switch that needs it.
|
|
export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
|
cards: {
|
|
family: "card",
|
|
minCol: 260,
|
|
colMaxWidth: null,
|
|
// 8 -> 369px cards at 3440 (vs 11 x 264 uncapped); binds only above ~2560.
|
|
maxCols: 8,
|
|
thumb: true,
|
|
dense: false,
|
|
actionsBelow: false,
|
|
},
|
|
cardsSmall: {
|
|
family: "card",
|
|
minCol: 180,
|
|
colMaxWidth: null,
|
|
// 11 -> 264px at 3440: still visibly the DENSE card, and never as wide as `cards` gets.
|
|
maxCols: 11,
|
|
thumb: true,
|
|
dense: true,
|
|
actionsBelow: false,
|
|
},
|
|
// Single column on purpose: the point of the list is that your eye runs down one edge with the
|
|
// titles stacked. Density must not cost that — `tiles` is the multi-column option instead.
|
|
// Keeps the 896px cap: its actions dock at the right edge, so a wider row would only stretch the
|
|
// dead space between them and the meta.
|
|
rows: {
|
|
family: "row",
|
|
minCol: null,
|
|
colMaxWidth: 896,
|
|
maxCols: null,
|
|
thumb: true,
|
|
dense: false,
|
|
actionsBelow: false,
|
|
},
|
|
// Much wider (1472): everything sits on one line and the columns after the title are fixed, so
|
|
// there's no gap for the extra width to stretch — it all goes to the title. Raised from 1280 at
|
|
// the user's request once they saw how much room was left over with the rail and filters hidden.
|
|
rowsCompact: {
|
|
family: "row",
|
|
minCol: null,
|
|
colMaxWidth: 1472,
|
|
maxCols: null,
|
|
thumb: false,
|
|
dense: false,
|
|
actionsBelow: false,
|
|
},
|
|
// 380 = thumbnail (160) + gap (12) + ~192 for the text + the block's own padding (16). The text
|
|
// floor is the action row's measured 156px plus enough for a title to be worth reading.
|
|
// Tuned by measuring the real layouts, not guessed: 3 columns need 3n+32 px, so 380 turns the
|
|
// channel page (1208px) and the unpinned feed into 3 columns while the filter-pinned feed
|
|
// (~955px) still gets 2. 470 was tried first and needed 956px for two — the common layout fell
|
|
// back to ONE column and the mode looked broken; 400 then left 596px tiles whose titles ended
|
|
// halfway, which is the very waste this mode exists to kill.
|
|
tiles: {
|
|
family: "row",
|
|
minCol: 380,
|
|
colMaxWidth: null,
|
|
// 6 -> 498px tiles at 3440. A tile spends extra width on its TEXT column rather than on a bigger
|
|
// thumbnail, so it tolerates dilution better than a card — but it still bottoms out at its 380px
|
|
// minimum on an ultrawide, which is the same waste, so it gets the same treatment.
|
|
maxCols: 6,
|
|
thumb: true,
|
|
dense: false,
|
|
actionsBelow: true,
|
|
},
|
|
};
|
|
|
|
function isFeedView(v: unknown): v is FeedView {
|
|
return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v);
|
|
}
|
|
|
|
/** Coerce a stored pref to a view. Up to 0.44 it was "grid" | "list"; those map forward here so
|
|
* nobody's saved choice is lost. Anything else (a corrupt value, a future view rolled back) falls
|
|
* back to the default rather than rendering nothing. */
|
|
export function parseFeedView(raw: unknown): FeedView {
|
|
if (isFeedView(raw)) return raw;
|
|
if (raw === "grid") return "cards";
|
|
if (raw === "list") return "rows";
|
|
return FEED_VIEW_DEFAULT;
|
|
}
|