// 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 (tablet portrait included). 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 = { 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; }