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:
@@ -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(() => {}));
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user