fix(ui): a card's Play clears its hover row at a large text size; Downloads links and the player description

At text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the
hover row (the Play grows with the root font, the picture's height does
not). A picture narrower than 14rem centres the Play and the resume pair
above the row; the picture box is a size container for it.

The Downloads title and channel links were 21px tall inline boxes; they
stand on a 24px line now (B1). The player's description says a failed
load, with a retry, instead of 'No description' (both the side panel
and the popover).
This commit is contained in:
2026-10-02 23:42:31 +02:00
parent 15ec67b029
commit e16cdbd16f
11 changed files with 112 additions and 18 deletions
+3 -8
View File
@@ -59,13 +59,14 @@ import { SURFACE } from "./ui/surfaces";
import clsx from "clsx";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
import {
MEDIA_BAR,
MEDIA_BADGE,
MEDIA_HOVER_LAYER,
MEDIA_RESUME_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
@@ -595,13 +596,7 @@ function Hero({
{/* A started hero continues on a click, and offers the start as a card does (C5): the pair
is a sibling over the picture, never inside its button. */}
{started && (
<div
className={clsx(
HOVER_REVEAL,
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
card.aspect === "poster" && "flex-col",
)}
>
<div className={clsx(MEDIA_RESUME_LAYER, card.aspect === "poster" && "flex-col")}>
<MediaResumeButtons onContinue={() => onPlay(card)} onRestart={() => onPlay(card, 0)} />
</div>
)}
@@ -9,7 +9,7 @@
// test that leaves `window.YT` unset). Each item the modal plays constructs a new YT.Player, and the
// fake records what it was asked for — that is how these tests see where playback starts.
import { describe, it, expect, vi, afterEach, beforeAll, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
import { render, screen, fireEvent, waitFor, cleanup, act, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
@@ -542,6 +542,27 @@ describe("PlayerModal — the side panel on a wide window", () => {
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
});
// E31 S5: a failed load read "No description." in the always-open panel — an error drawn as empty.
it("says the description failed to load, with a retry, instead of 'No description'", async () => {
wideWindow(true);
const detail = vi
.spyOn(api, "videoDetail")
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue({ description: "A long description" } as Awaited<
ReturnType<typeof api.videoDetail>
>);
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const panel = screen.getByTestId("player-description");
expect(await within(panel).findByText("player.descriptionFailed")).toBeTruthy();
expect(within(panel).queryByText("player.noDescription")).toBeNull();
fireEvent.click(within(panel).getByRole("button", { name: "common.retry" }));
expect(await within(panel).findByText("A long description")).toBeTruthy();
expect(detail).toHaveBeenCalledTimes(2);
});
it("keeps the details under the video below 1280 px", () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
+9
View File
@@ -61,6 +61,7 @@ import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import Toaster from "./ui/Toaster";
import { StateMessage } from "./ui/QueryState";
import { useOverlayOrder } from "./ui/overlayOrder";
import { afterRemoval } from "../lib/playerQueue";
import {
@@ -888,6 +889,10 @@ export default function PlayerModal({
</SectionLabel>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.isError && !detail.data ? (
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
{t("player.descriptionFailed")}
</StateMessage>
) : detail.data?.description ? (
<div
ref={descFade.ref}
@@ -1086,6 +1091,10 @@ export default function PlayerModal({
</SectionLabel>
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.isError && !detail.data ? (
<StateMessage tone="error" compact onRetry={() => void detail.refetch()}>
{t("player.descriptionFailed")}
</StateMessage>
) : detail.data?.description ? (
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
{renderDescription(detail.data.description, {
+2 -1
View File
@@ -35,6 +35,7 @@ import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_PICTURE_CONTAINER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaTitleClass,
@@ -239,7 +240,7 @@ function Thumb({
// min-h-0: the caller's aspect box must hold its ratio before the image arrives — an aspect-ratio
// box otherwise grows to its content, and an off-screen, not-yet-loaded thumbnail measured 338px
// tall, which stretched a whole Home shelf to it (ui/mediaCardStyles `mediaPictureBox`).
<div className={clsx("relative min-h-0", className)}>
<div className={clsx("relative min-h-0", MEDIA_PICTURE_CONTAINER, className)}>
{/* Named by the title: its only text is the duration badge, so a screen reader heard the first
Tab stop of every card as "44:10" (E28 S3d4). */}
<a
@@ -237,6 +237,27 @@ describe("DownloadRow · a link with no details yet", () => {
expect(container.textContent).not.toContain("https://");
});
// E31 S5 (measured at 21px on the Downloads page): every link in the row stands on a 24px line on
// a mouse (DESIGN B1), as the link line and the failure link already did.
it("gives the title link and the channel link a 24px line", () => {
const { container } = render(
<DownloadRow
job={{
...bare,
display_uploader: "Someone",
display_uploader_url: "https://example.com/c",
}}
/>,
);
const title = [...container.querySelectorAll("a")].find((a) => a.href === bare.source_url)!;
const channel = container.querySelector<HTMLAnchorElement>("a[href='https://example.com/c']")!;
// inline-block: an inline link's box is the font's height (21px) whatever its line height.
for (const a of [title, channel])
expect(a.className.split(/\s+/)).toEqual(
expect.arrayContaining(["inline-block", "leading-6"]),
);
});
it("keeps the named row as it was: title text, and the link on its own line", () => {
const named = { ...bare, display_name: "A clip" };
const { container } = render(<DownloadRow job={named} />);
+2 -2
View File
@@ -211,7 +211,7 @@ function ChannelLine({ job }: { job: DownloadJob }) {
target="_blank"
rel="noopener noreferrer"
title={url}
className="text-muted hover:text-accent hover:underline"
className="inline-block max-w-full truncate align-top leading-6 text-muted hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{name}
@@ -274,7 +274,7 @@ export function DownloadRow({
target="_blank"
rel="noopener noreferrer"
title={`${t("downloads.source.open")} — ${job.source_url}`}
className="hover:text-accent hover:underline"
className="inline-block max-w-full truncate align-top leading-6 hover:text-accent hover:underline pointer-coarse:leading-(--touch-target)"
{...nativeMenu}
>
{jobTitle(job)}
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import videoCard from "./VideoCard.tsx?raw";
import {
MEDIA_HOVER_LAYER,
MEDIA_PICTURE_CONTAINER,
MEDIA_RESUME_LAYER,
mediaPictureBox,
} from "./ui/mediaCardStyles";
// E31 S5, measured: at text size 1.3 a 242px feed thumbnail put the 63px Play 13px into the 37px hover
// row (the Play and the row grow with the root font, the picture's height does not). A narrow picture
// (under 14rem, so the cut moves with the text size) centres what it carries above the row instead.
describe("a media picture", () => {
it("is a size container, so what sits on it answers its width", () => {
expect(mediaPictureBox("wide")).toContain(MEDIA_PICTURE_CONTAINER);
expect(mediaPictureBox("poster")).toContain(MEDIA_PICTURE_CONTAINER);
// The video card draws its own box (it is not a mediaPictureBox).
expect(videoCard).toContain('clsx("relative min-h-0", MEDIA_PICTURE_CONTAINER, className)');
});
it.each([
["the Play", MEDIA_HOVER_LAYER],
["the Continue + Restart pair", MEDIA_RESUME_LAYER],
])("leaves the hover row its band when narrow: %s", (_, layer) => {
expect(layer.split(" ")).toContain("@max-[14rem]/pic:pb-10");
});
});
+3 -3
View File
@@ -12,12 +12,13 @@ import { plexAddLabelKey, plexStarted } from "./usePlexCardActions";
import { IconButton } from "./ui/IconButton";
import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable";
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
import { HOVER_YIELD } from "./ui/reveal";
import { MediaResumeButtons } from "./MediaResumeButtons";
import {
MEDIA_BADGE,
MEDIA_BAR,
MEDIA_HOVER_LAYER,
MEDIA_RESUME_LAYER,
MEDIA_IMG,
MEDIA_PICTURE,
mediaPictureBox,
@@ -234,8 +235,7 @@ function PlexPicture({
{resumable && (
<div
className={clsx(
HOVER_REVEAL,
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
MEDIA_RESUME_LAYER,
// A poster is too narrow for the pair side by side (150px); it stacks, as in the lab.
aspect === "poster" && "flex-col",
)}
+19 -3
View File
@@ -18,9 +18,21 @@ export const MEDIA_IMG =
export const MEDIA_BADGE =
"pointer-events-none absolute rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums";
/** The hover layer over the picture: a dim that carries the Play (mouse only, like the hover row). */
/** The picture box as a size container (`pic`), so what sits on the picture can answer its width.
* Every picture box carries it: `mediaPictureBox` and the video card's own box. */
export const MEDIA_PICTURE_CONTAINER = "@container/pic";
/** The hover layer over the picture: a dim that carries the Play (mouse only, like the hover row).
* On a narrow picture the Play centres in the band ABOVE the hover row instead of on the whole
* picture: the picture's height follows the card's width, but the Play and the row grow with the
* text size, so at 1.3 a 242px thumbnail put the 63px Play 13px into the 37px row (E31 S5,
* measured). The cut is in rem, so it moves with the text size too: a picture under 14rem wide. */
export const MEDIA_HOVER_LAYER =
"reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100";
"reveal-hover absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100 @max-[14rem]/pic:pb-10";
/** The layer that holds a started title's Continue + Restart pair over the picture: a sibling of the
* picture's button, never inside it (C5). Narrow, it leaves the hover row its band as the Play does. */
export const MEDIA_RESUME_LAYER = `${HOVER_REVEAL} pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto @max-[14rem]/pic:pb-10`;
/** Where the card's hover row (watched, save, add, "…") docks: the picture's bottom-right. */
export const MEDIA_BAR = `${HOVER_REVEAL} absolute bottom-2.5 right-1.5 z-base`;
@@ -39,4 +51,8 @@ export const mediaTitleClass = (dense: boolean, shelf = false) =>
/** The box that holds the picture at its ratio. `min-h-0`: an aspect-ratio box otherwise grows to
* its content's height, so a square channel avatar made a 16:9 slot square (measured 182×182). */
export const mediaPictureBox = (aspect: "poster" | "wide") =>
clsx("relative min-h-0", aspect === "poster" ? "aspect-2/3" : "aspect-video");
clsx(
"relative min-h-0",
MEDIA_PICTURE_CONTAINER,
aspect === "poster" ? "aspect-2/3" : "aspect-video",
);
+1
View File
@@ -23,6 +23,7 @@
"closeEsc": "Close (Esc)",
"description": "Description",
"noDescription": "No description.",
"descriptionFailed": "Couldn't load the description",
"channel": "Channel",
"views": "{{formattedCount}} views",
"stream": "stream",
+1
View File
@@ -23,6 +23,7 @@
"closeEsc": "Bezárás (Esc)",
"description": "Leírás",
"noDescription": "Nincs leírás.",
"descriptionFailed": "Nem sikerült betölteni a leírást",
"channel": "Csatorna",
"views": "{{formattedCount}} megtekintés",
"stream": "élő adás",