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:
2026-07-16 03:25:05 +02:00
parent 78f21eb46b
commit 944dedb218
3 changed files with 82 additions and 26 deletions
+66 -14
View File
@@ -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 ?? ""}