fix(feed): keep the duration, live state and resume bar in the bare row
Dropping Thumb dropped everything it draws ON the image — duration, the live/stream badges, the saved marker and the resume bar. Those are metadata, not decoration, so they move into the meta line and the bar onto the row. Splits the spec's overloaded compact flag into thumb/dense, which meant one thing per family and nothing on its own.
This commit is contained in:
@@ -113,6 +113,51 @@ function openInApp(
|
||||
onOpen(video);
|
||||
}
|
||||
|
||||
// A non-zero saved position on an unfinished video = "in progress": offer Continue / Restart
|
||||
// instead of a plain Play. Note this is TRUE even with no known duration — in which case there is
|
||||
// no percentage to draw, so the two are deliberately separate predicates.
|
||||
function isInProgress(video: Video): boolean {
|
||||
return video.position_seconds > 0 && video.status !== "watched";
|
||||
}
|
||||
/** Resume position as 0-100, or 0 when there's nothing to draw. Shared by Thumb and the
|
||||
* thumbnail-less row, which has no image to draw the bar on but still owes you the state. */
|
||||
function resumePct(video: Video): number {
|
||||
return isInProgress(video) && video.duration_seconds
|
||||
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
|
||||
: 0;
|
||||
}
|
||||
|
||||
// Thumb overlays duration / live state / saved ON the image. The thumbnail-less row has no image,
|
||||
// so it renders the same FACTS inline here — deliberately not a shared component with Thumb's
|
||||
// badges: floating chips over artwork and inline text chips are different presentations, and
|
||||
// forcing one component to do both would be a variant-flag knot for no reuse.
|
||||
function InlineBadges({ video }: { video: Video }) {
|
||||
const { t } = useTranslation();
|
||||
// Inline-flow, not flex: these sit inside the meta line's " · "-separated text.
|
||||
const chip = "inline-block mr-1.5 px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide";
|
||||
return (
|
||||
<>
|
||||
{video.duration_seconds != null ? (
|
||||
<span className="inline-block mr-1.5 px-1.5 py-0.5 rounded bg-surface text-fg text-[11px] font-medium tabular-nums">
|
||||
{formatDuration(video.duration_seconds)}
|
||||
</span>
|
||||
) : video.live_status === "live" || video.live_status === "upcoming" ? (
|
||||
<span
|
||||
className={clsx(chip, video.live_status === "live" ? "bg-red-500/90 text-white" : "bg-surface text-fg")}
|
||||
>
|
||||
{t(`card.${video.live_status}`)}
|
||||
</span>
|
||||
) : null}
|
||||
{video.live_status === "was_live" && (
|
||||
<span className={clsx(chip, "bg-accent text-accent-fg")}>{t("card.stream")}</span>
|
||||
)}
|
||||
{video.saved && (
|
||||
<Bookmark className="inline-block mr-1.5 w-3.5 h-3.5 align-[-2px] text-accent fill-current" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumb({
|
||||
video,
|
||||
className,
|
||||
@@ -123,13 +168,8 @@ function Thumb({
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// A non-zero saved position on an unfinished video = "in progress": show a resume
|
||||
// progress bar and offer Continue / Restart instead of a plain Play.
|
||||
const inProgress = video.position_seconds > 0 && video.status !== "watched";
|
||||
const pct =
|
||||
inProgress && video.duration_seconds
|
||||
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
|
||||
: 0;
|
||||
const inProgress = isInProgress(video);
|
||||
const pct = resumePct(video);
|
||||
|
||||
// Open in the in-app player at an explicit position (null = resume from saved).
|
||||
const open = (startAt: number | null) => (e: React.MouseEvent) => {
|
||||
@@ -246,9 +286,15 @@ function VideoCard({
|
||||
onOpen?: (v: Video, startAt?: number | null) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const spec = FEED_VIEW_SPEC[view];
|
||||
const watched = video.status === "watched";
|
||||
// The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
|
||||
// Thumb overlays on the image. Those are metadata, not decoration, so fold them into the meta
|
||||
// line here rather than let the mode silently lose them.
|
||||
const noThumb = spec.family === "row" && !spec.thumb;
|
||||
const meta = (
|
||||
<>
|
||||
{noThumb && <InlineBadges video={video} />}
|
||||
{video.view_count != null && (
|
||||
<>
|
||||
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
|
||||
@@ -304,21 +350,21 @@ function VideoCard({
|
||||
</>
|
||||
);
|
||||
|
||||
const spec = FEED_VIEW_SPEC[view];
|
||||
const actions = (
|
||||
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
|
||||
);
|
||||
|
||||
if (spec.family === "row") {
|
||||
const pct = noThumb ? resumePct(video) : 0;
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
||||
"cv-row group relative flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
|
||||
watched && "opacity-55"
|
||||
)}
|
||||
>
|
||||
{/* `compact` = the thumbnail-less row: everything else (meta, actions) stays put. */}
|
||||
{!spec.compact && (
|
||||
{/* The bare row drops Thumb; its badges move into the meta line instead (see noThumb). */}
|
||||
{spec.thumb && (
|
||||
<Thumb
|
||||
video={video}
|
||||
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
|
||||
@@ -333,6 +379,12 @@ function VideoCard({
|
||||
{spec.actionsBelow && <div className="mt-1">{actions}</div>}
|
||||
</div>
|
||||
{!spec.actionsBelow && actions}
|
||||
{/* Thumb draws the resume bar over the image; with no image it goes on the row itself. */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-2 right-2 h-0.5 rounded-full bg-black/40">
|
||||
<div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -341,15 +393,15 @@ function VideoCard({
|
||||
<div
|
||||
className={clsx(
|
||||
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
|
||||
spec.compact ? "p-2" : "p-2.5",
|
||||
spec.dense ? "p-2" : "p-2.5",
|
||||
watched && "opacity-55"
|
||||
)}
|
||||
>
|
||||
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
|
||||
<div className={clsx("flex gap-3 px-0.5 pb-0.5", spec.compact ? "mt-2" : "mt-2.5")}>
|
||||
<div className={clsx("flex gap-3 px-0.5 pb-0.5", spec.dense ? "mt-2" : "mt-2.5")}>
|
||||
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
|
||||
gap would leave the title barely half the width. The channel name is still below it. */}
|
||||
{!spec.compact && (
|
||||
{!spec.dense && (
|
||||
<Avatar
|
||||
src={video.channel_thumbnail}
|
||||
fallback={video.channel_title ?? ""}
|
||||
|
||||
@@ -9,14 +9,15 @@ import VideoCard from "./VideoCard";
|
||||
// minimum column width lives with the rest of the view matrix in lib/feedView.ts.
|
||||
const GRID_GAP = 16;
|
||||
const LIST_GAP = 4;
|
||||
// Estimated row heights before measurement; real heights are measured per-row so
|
||||
// these only affect the very first paint and the scrollbar's initial guess.
|
||||
// Estimated row heights before measurement; real heights are measured per-row so these only affect
|
||||
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
|
||||
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
|
||||
const ROW_EST: Record<FeedView, number> = {
|
||||
cards: 340,
|
||||
cardsSmall: 240,
|
||||
cardsSmall: 257,
|
||||
rows: 115,
|
||||
rowsCompact: 64,
|
||||
tiles: 124,
|
||||
rowsCompact: 82,
|
||||
tiles: 132,
|
||||
};
|
||||
|
||||
/** Columns that fit `width`, or 1 for the single-column views. */
|
||||
|
||||
@@ -19,8 +19,11 @@ export interface FeedViewSpec {
|
||||
/** 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;
|
||||
/** card family: a tighter card. row family: no thumbnail at all. */
|
||||
compact: boolean;
|
||||
/** 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;
|
||||
@@ -28,12 +31,12 @@ export interface FeedViewSpec {
|
||||
|
||||
// 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, compact: false, actionsBelow: false },
|
||||
cardsSmall: { family: "card", minCol: 180, compact: true, actionsBelow: false },
|
||||
cards: { family: "card", minCol: 260, thumb: true, dense: false, actionsBelow: false },
|
||||
cardsSmall: { family: "card", minCol: 180, 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.
|
||||
rows: { family: "row", minCol: null, compact: false, actionsBelow: false },
|
||||
rowsCompact: { family: "row", minCol: null, compact: true, actionsBelow: false },
|
||||
rows: { family: "row", minCol: null, thumb: true, dense: false, actionsBelow: false },
|
||||
rowsCompact: { family: "row", minCol: 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
|
||||
@@ -41,7 +44,7 @@ export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
|
||||
// (~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, compact: false, actionsBelow: true },
|
||||
tiles: { family: "row", minCol: 380, thumb: true, dense: false, actionsBelow: true },
|
||||
};
|
||||
|
||||
function isFeedView(v: unknown): v is FeedView {
|
||||
|
||||
Reference in New Issue
Block a user