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. */
|
||||
|
||||
Reference in New Issue
Block a user