feat(player): a roomier YouTube player, its panel shut on demand

- The full-height previous/next strips beside the card are gone: the panel's
  transport row and Shift+arrows step on their own, and the video gains the
  7rem the strips' gutters took.
- Close is one icon in the card's top-right corner (the wide panel's head,
  the stacked card's title row).
- The panel's description is cut to three lines with More / Less
  (ClampedText now takes the linked text as children).
- Wide, the panel shuts to a 2.75rem strip (close, open, previous, next) for
  a bigger video; it stays shut while the queue steps on, and a video opened
  from a card starts it open. Focus moves to the counterpart toggle.
- The wheel has one meaning per place: over a panel (or the stacked card)
  with something to scroll it only scrolls, never falling through to the
  volume at the end; over the video, or a panel that fits, it is the volume.
- A long channel name is cut on the panel's line instead of running the
  20rem panel into a sideways scroll.
This commit is contained in:
2026-10-09 00:27:29 +02:00
parent 600d5c39da
commit ece74a42ab
8 changed files with 466 additions and 305 deletions
@@ -426,21 +426,34 @@ describe("PlayerModal — the description", () => {
// C3: the queue's side arrows sit in a gutter of the card's row, not outside it, so no window cuts
// them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
describe("PlayerModal — the side arrows", () => {
it("stand inside the row's gutter", () => {
// The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
// width (owner): the panel's transport row and Shift+← / → step on their own.
describe("PlayerModal — no side strips", () => {
it("steps from the panel only, and the card keeps no gutter for strips", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find(
(b) => b.className.includes("w-12"),
)!;
expect(arrow).toBeTruthy();
expect(arrow.className).toMatch(/\bright-0\b/);
expect(arrow.className).not.toMatch(/left-full/);
expect(arrow.parentElement!.className).toMatch(/sm:px-14/);
expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.className).not.toMatch(/px-14/);
});
it("closes from one icon-only button", () => {
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const onClose = vi.fn();
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={onClose} onState={() => {}} />,
);
const close = screen.getByTestId("player-close");
expect(close.getAttribute("aria-label")).toBe("player.close");
expect(close.textContent).toBe("");
fireEvent.click(close);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
@@ -531,9 +544,7 @@ describe("PlayerModal — fits the window's height", () => {
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.style.maxWidth).toBe(
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
);
expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
});
});
@@ -587,13 +598,52 @@ describe("PlayerModal — the side panel on a wide window", () => {
// The description is shown outright; there is no ⓘ to open it.
expect(await screen.findByText("A long description")).toBeTruthy();
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull();
// The row's width: the video at the window's height, plus the panel and the side strips.
expect(card.parentElement!.style.maxWidth).toBe(
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)",
);
// The row's width: the video at the window's height, plus the panel.
expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
// A long channel name is cut on the panel's line, not run past its 20rem into a sideways scroll.
const name = within(info).getByText("Channel").closest("button, a")!.parentElement!;
expect(name.className).toMatch(/\bmin-w-0\b/);
expect(name.className).toMatch(/\bflex-1\b/);
expect(name.className).not.toMatch(/\bshrink-0\b/);
// The description is cut to three lines, with a More once it is cut.
expect((await screen.findByText("A long description")).closest(".line-clamp-3")).toBeTruthy();
});
it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => {
it("shuts the panel to a strip for a bigger video, and keeps it shut as the queue steps on", () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
const B = vid("B", 0);
renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const row = () =>
document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
fireEvent.click(screen.getByRole("button", { name: "player.collapsePanel" }));
expect(screen.queryByTestId("player-info")).toBeNull();
const strip = screen.getByTestId("player-strip");
// The pressed toggle is gone: focus is on its counterpart, not on <body>.
expect(document.activeElement).toBe(
within(strip).getByRole("button", { name: "player.expandPanel" }),
);
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 2.75rem)");
// The strip keeps the way out and the stepping.
expect(within(strip).getByTestId("player-close")).toBeTruthy();
fireEvent.click(within(strip).getByRole("button", { name: "player.next" }));
expect(screen.getByTestId("player-strip")).toBeTruthy(); // still shut on the next video
fireEvent.click(
within(screen.getByTestId("player-strip")).getByRole("button", {
name: "player.expandPanel",
}),
);
expect(screen.getByTestId("player-info")).toBeTruthy();
expect(document.activeElement).toBe(
screen.getByRole("button", { name: "player.collapsePanel" }),
);
expect(row().style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
});
it("gives a panel with something to scroll the wheel for that alone, the volume elsewhere", async () => {
wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description",
@@ -614,11 +664,33 @@ describe("PlayerModal — the side panel on a wide window", () => {
return e.defaultPrevented; // prevented = the wheel went to the volume
};
expect(spin(text, 100)).toBe(false); // room below: the panel scrolls
expect(spin(text, -100)).toBe(true); // at its top: nothing to scroll up, so the volume
expect(spin(text, -100)).toBe(false); // at its top too: one gesture, one meaning (owner)
top = 400;
expect(spin(text, 100)).toBe(true); // at its end: the volume
expect(spin(text, -100)).toBe(false);
expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video
// A panel that fits has nothing to scroll: the wheel is the volume there.
Object.defineProperty(panel, "scrollHeight", { value: 500, configurable: true });
expect(spin(text, 100)).toBe(true);
});
it("stacked: the card scrolls under the wheel, the video in it is still the volume", () => {
wideWindow(false);
vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0);
renderModal(
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
);
const stage = document.querySelector<HTMLElement>(".player-stage")!;
const card = stage.parentElement!;
Object.defineProperty(card, "scrollHeight", { value: 900, configurable: true });
Object.defineProperty(card, "clientHeight", { value: 500, configurable: true });
const spin = (el: Element) => {
const e = new WheelEvent("wheel", { deltaY: 100, bubbles: true, cancelable: true });
el.dispatchEvent(e);
return e.defaultPrevented;
};
expect(spin(screen.getByRole("heading", { name: "Video A" }))).toBe(false);
expect(spin(stage)).toBe(true);
});
// A failed load read "No description." in the always-open panel — an error drawn as empty.
+326 -265
View File
@@ -21,14 +21,16 @@ import {
ArrowLeft,
Check,
CheckCheck,
ChevronLeft,
ChevronRight,
ExternalLink,
Info,
Maximize,
Minimize,
Minimize2,
PanelRightClose,
PanelRightOpen,
Settings,
SkipBack,
SkipForward,
Trash2,
Volume2,
VolumeX,
@@ -74,7 +76,6 @@ import {
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { SectionLabel } from "./ui/Headings";
@@ -105,6 +106,8 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean {
// a rem width, so it grows with the text size; the video takes what is left.
const WIDE_PLAYER_PX = 1280;
const SIDE_PANEL = "20rem";
// The side panel shut to a strip: one icon button wide, close / open / previous / next in a column.
const PANEL_STRIP = "2.75rem";
// The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
const MIN_VIDEO = "36rem";
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size
@@ -248,6 +251,23 @@ export default function PlayerModal({
// move into a side panel beside the video, so the video takes the window's whole height — at
// 1920×911 it was capped at ~1008 px wide with empty space all round. Narrower, they stay under it.
const wide = useMediaQuery(`(min-width: ${WIDE_PLAYER_PX}px)`, false);
// The wide panel shut to a strip, for a bigger video. This player's own state: it holds while the
// queue steps on (a next video plays in this same modal), and a video opened from a card mounts a
// fresh player, which starts with the panel open (owner).
const [panelShut, setPanelShut] = useState(false);
// The toggle that was pressed is gone after the switch (shut → the strip's "open", and back): focus
// goes to its counterpart, not to <body>, where the player's keys would no longer reach it.
const panelToggleRef = useRef<HTMLButtonElement>(null);
const refocusToggle = useRef(false);
const togglePanel = (shut: boolean) => {
refocusToggle.current = true;
setPanelShut(shut);
};
useEffect(() => {
if (!refocusToggle.current) return;
refocusToggle.current = false;
panelToggleRef.current?.focus();
}, [panelShut]);
useLayoutEffect(() => {
const card = cardRef.current;
const stage = stageRef.current;
@@ -646,9 +666,22 @@ export default function PlayerModal({
onLoadVideo: loadVideo,
};
// The card's width budget (its maxWidth, below): the video's, and the side strips' gutters.
// The card's width budget (its maxWidth, below): the video's, and the side panel's when wide.
const fitWidth = `(100dvh - 3rem - ${below}px) * 16 / 9`;
const gutters = hasQueue ? " + 7rem" : "";
const panelWidth = panelShut ? PANEL_STRIP : SIDE_PANEL;
// One Close, icon only, in the card's top-right corner: the wide panel's head (or its strip), the
// stacked card's title row.
const closeButton = (
<IconButton
variant="ghost"
data-testid="player-close"
onClick={onClose}
label={t("player.close")}
title={t("player.closeEsc")}
>
<X />
</IconButton>
);
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
// must not render inside the page scroller: once that scroller is a stacking context (its
@@ -665,15 +698,13 @@ export default function PlayerModal({
aria-modal="true"
aria-labelledby={titleId}
>
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a
playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades
out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut
it and a portrait tablet lost it). stopPropagation so a click steps instead of closing.
Hidden on narrow screens where there's no room beside the card. */}
{/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
for a second copy of the same two buttons (owner). */}
<div
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem).
className="relative flex w-full max-h-full"
// The design width (56rem), narrowed so the 16:9 video plus the chrome under it fits the
// window's height less the backdrop's padding (`p-6` = 3rem).
// Wide: no design cap — the video is as wide as the window's height allows, plus the side panel,
// whose content sits beside the video (`below` is ~0), so the fit is exact and the card, which
// does not scroll there, always holds the whole video.
@@ -682,27 +713,15 @@ export default function PlayerModal({
// (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
style={{
maxWidth: wide
? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})`
: `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`,
? `calc(${fitWidth} + ${panelWidth})`
: `min(56rem, max(${MIN_VIDEO}, ${fitWidth}))`,
}}
>
{hasQueue && (
<Pressable
onClick={(e) => {
e.stopPropagation();
goPrev();
}}
disabled={index === 0}
aria-label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
className="absolute left-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronLeft className="h-8 w-8" />
</Pressable>
)}
<div
ref={cardRef}
tabIndex={-1}
// Stacked, the card is what scrolls (its details under the video): the wheel's, while it can.
data-wheel-scrolls={wide ? undefined : ""}
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
wide ? "flex overflow-hidden" : "overflow-y-auto"
}`}
@@ -710,6 +729,8 @@ export default function PlayerModal({
>
<div
ref={stageRef}
// The wheel over the video is its volume, even inside a stacked card that scrolls.
data-wheel-volume=""
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`}
@@ -891,275 +912,315 @@ export default function PlayerModal({
description never makes the card taller than the video. */}
<div
className={wide ? "relative shrink-0 border-l border-border" : undefined}
style={wide ? { width: SIDE_PANEL } : undefined}
style={wide ? { width: panelWidth } : undefined}
>
<div
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
data-testid="player-info"
// The wheel scrolls this panel while it can (lib/playerModalHooks), the volume elsewhere.
data-wheel-scrolls={wide ? "" : undefined}
>
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<PlayerTransportControls
variant="modal"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
</div>
)}
<div className="p-4 sm:p-5">
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
<div className="flex items-start gap-3">
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</h2>
{!wide && (
{wide && panelShut ? (
<div
data-testid="player-strip"
className="absolute inset-0 flex flex-col items-center gap-1 py-2"
>
{closeButton}
<IconButton
ref={panelToggleRef}
variant="ghost"
onClick={() => togglePanel(false)}
label={t("player.expandPanel")}
aria-expanded={false}
>
<PanelRightOpen />
</IconButton>
{hasQueue && (
<>
<span className="flex-1" />
<IconButton
ref={setDescAnchor}
{...descPop.getReferenceProps()}
variant="ghost"
aria-expanded={showDesc}
label={t("player.description")}
onClick={goPrev}
disabled={index === 0}
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
>
<Info />
<SkipBack />
</IconButton>
)}
{navigated && (
<Button
<IconButton
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
onClick={goNext}
disabled={index >= queue!.length - 1}
label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
>
{t("player.back")}
</Button>
)}
<PopoverSurface
popover={descPop}
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
>
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
<PlayerDescription
ref={descFadeRef}
style={descFadeStyle}
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
{...descriptionProps}
<SkipForward />
</IconButton>
</>
)}
</div>
) : (
<div
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
data-testid="player-info"
// A panel with something to scroll takes the wheel for that alone (lib/playerModalHooks).
data-wheel-scrolls={wide ? "" : undefined}
>
{wide && (
<div className="flex items-center justify-between px-2 pt-2">
<IconButton
ref={panelToggleRef}
variant="ghost"
onClick={() => togglePanel(true)}
label={t("player.collapsePanel")}
aria-expanded
>
<PanelRightClose />
</IconButton>
{closeButton}
</div>
)}
{hasQueue && (
<div className="flex flex-wrap items-center justify-center gap-x-4 gap-y-1.5 px-4 py-2 border-b border-border text-sm">
<PlayerTransportControls
variant="modal"
index={index}
queueLength={queue!.length}
autoMode={autoMode}
loopMode={loopMode}
onPrev={goPrev}
onNext={goNext}
onCycleAuto={cycleAuto}
onCycleLoop={cycleLoop}
/>
</PopoverSurface>
<Button
variant="ghost"
size="sm"
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
>
{t("player.close")}
<X />
</Button>
</div>
</div>
)}
{/* Channel + meta on one line, with the actions pushed to the right.
<div className="p-4 sm:p-5">
{/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the
right (wide, it heads the panel). */}
<div className="flex items-start gap-3">
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
</h2>
{!wide && (
<IconButton
ref={setDescAnchor}
{...descPop.getReferenceProps()}
variant="ghost"
aria-expanded={showDesc}
label={t("player.description")}
>
<Info />
</IconButton>
)}
{navigated && (
<Button
variant="ghost"
size="sm"
onClick={() => loadVideo(active.id, null)}
title={t("player.backToOriginal")}
icon={<ArrowLeft />}
>
{t("player.back")}
</Button>
)}
<PopoverSurface
popover={descPop}
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`}
>
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
<PlayerDescription
ref={descFadeRef}
style={descFadeStyle}
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
{...descriptionProps}
/>
</PopoverSurface>
{!wide && closeButton}
</div>
{/* Channel + meta on one line, with the actions pushed to the right.
When navigated to a linked video we only have its author (from the player),
so we show that as plain text and hide feed-video-specific bits.
It wraps: the meta never gets narrower than META_MIN, and the actions move down as one
group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a
one-word column between the name and the buttons. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
)}
{navigated ? (
detail.data?.channel_id ? (
<a
href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank"
rel="noreferrer"
className="font-medium hover:text-accent shrink-0"
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
)
) : (
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<ChannelNameLink
channelId={active.channel_id}
title={active.channel_title}
onOpenInApp={() => {
// openChannel closes this player first and pushes only once its
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// and clicking the name merely closes the player
// (lib/history.closeOverlaysThenNavigate).
openChannel(active.channel_id, active.channel_title ?? undefined);
}}
className="font-medium hover:text-accent truncate text-left"
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
{!navigated && (
<Avatar
src={active.channel_thumbnail}
fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0"
/>
<IconLink
href={active.channel_url}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
className="shrink-0"
)}
{navigated ? (
detail.data?.channel_id ? (
<a
href={channelYouTubeUrl(detail.data.channel_id)}
target="_blank"
rel="noreferrer"
className={`font-medium hover:text-accent ${wide ? "min-w-0 truncate" : "shrink-0"}`}
>
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
</a>
) : (
<span className={`font-medium ${wide ? "min-w-0 truncate" : "shrink-0"}`}>
{liveData?.author ?? ""}
</span>
)
) : (
// Wide, the name takes what the avatar leaves of the panel's line and is cut
// there: unshrinkable, a long one ran the 20rem panel into a sideways scroll.
<div
className={`flex items-center gap-1.5 min-w-0 ${wide ? "flex-1" : "shrink-0"}`}
>
<ExternalLink />
</IconLink>
</div>
)}
{!navigated ? (
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{active.view_count != null && (
<ChannelNameLink
channelId={active.channel_id}
title={active.channel_title}
onOpenInApp={() => {
// openChannel closes this player first and pushes only once its
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
// and clicking the name merely closes the player
// (lib/history.closeOverlaysThenNavigate).
openChannel(active.channel_id, active.channel_title ?? undefined);
}}
className="font-medium hover:text-accent truncate text-left"
/>
<IconLink
href={active.channel_url}
target="_blank"
rel="noreferrer"
label={t("channels.row.openOnYouTube")}
className="shrink-0"
>
<ExternalLink />
</IconLink>
</div>
)}
{!navigated ? (
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{active.view_count != null && (
<span>
{metaSep}
{t("player.views", {
count: active.view_count,
formattedCount: formatCompact(active.view_count, lang),
})}
</span>
)}
<span>
{metaSep}
{t("player.views", {
count: active.view_count,
formattedCount: formatCompact(active.view_count, lang),
})}
{relativeTime(active.published_at, lang, t)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
)}
<span>
{metaSep}
{relativeTime(active.published_at, lang, t)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{active.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(active.duration_seconds)}
</span>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")}
</span>
)}
</div>
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{detail.data?.view_count != null && (
<span>
{metaSep}
{t("player.views", {
count: detail.data.view_count,
formattedCount: formatCompact(detail.data.view_count, lang),
})}
</span>
)}
{detail.data?.published_at && (
<span>
{metaSep}
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
{fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(detail.data.duration_seconds)}
</span>
)}
</div>
)}
{active.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(active.duration_seconds)}
</span>
)}
{active.live_status === "was_live" && (
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{t("player.stream")}
</span>
)}
</div>
) : (
// Linked video's stats come from the (already-fetched) video detail.
<div
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
>
{detail.data?.view_count != null && (
<span>
{metaSep}
{t("player.views", {
count: detail.data.view_count,
formattedCount: formatCompact(detail.data.view_count, lang),
})}
</span>
)}
{detail.data?.published_at && (
<span>
{metaSep}
{relativeTime(detail.data.published_at, lang, t)} ·{" "}
{fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>
{metaSep}
{formatDuration(detail.data.duration_seconds)}
</span>
)}
</div>
)}
{/* Reliable, discoverable path into our big view straight from the windowed player —
{/* Reliable, discoverable path into our big view straight from the windowed player —
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
the only fullscreen button previously reachable in the windowed video was YouTube's
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
the docked bar offers OUR true fullscreen. */}
{!navigated && (
// Narrow: the actions end the channel line. Wide: their own line in the panel.
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
<IconButton
variant="outline"
size="md"
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
>
<Maximize />
</IconButton>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
{onRemoveFromList && (
{!navigated && (
// Narrow: the actions end the channel line. Wide: their own line in the panel.
<div className={`flex items-center gap-3 ${wide ? "flex-wrap" : "ml-auto"}`}>
<IconButton
variant="outline-danger"
variant="outline"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
onClick={toggleFullscreen}
label={t("player.fullscreen")}
title={`${t("player.fullscreen")} · F`}
>
<Trash2 />
<Maximize />
</IconButton>
)}
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
</div>
<AddToPlaylist videoId={active.id} variant="outline" size="md" />
<DownloadButton
videoId={active.id}
title={active.title}
variant="outline"
size="md"
/>
{onRemoveFromList && (
<IconButton
variant="outline-danger"
size="md"
onClick={() => void removeFromList()}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
// Download / Mark watched reads as "delete this video".
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
>
<Trash2 />
</IconButton>
)}
<Button
variant="outline"
size="sm"
pressed={watched}
onClick={() => setWatched(!watched)}
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
icon={watched ? <CheckCheck /> : <Check />}
>
{watched ? t("player.watched") : t("player.markWatched")}
</Button>
</div>
)}
</div>
{wide && (
<section className="mt-5" data-testid="player-description">
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
clamp
{...descriptionProps}
/>
</section>
)}
</div>
{wide && (
<section className="mt-5" data-testid="player-description">
<SectionLabel level={3} className="mb-2">
{t("player.description")}
</SectionLabel>
<PlayerDescription
className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed"
{...descriptionProps}
/>
</section>
)}
</div>
</div>
)}
</div>
</div>
{hasQueue && (
<Pressable
onClick={(e) => {
e.stopPropagation();
goNext();
}}
disabled={index >= queue!.length - 1}
aria-label={t("player.next")}
title={`${t("player.next")} · Shift+→`}
className="absolute right-0 top-0 bottom-0 hidden w-12 place-items-center rounded-2xl bg-white/5 text-white/40 backdrop-blur-xs transition hover:bg-white/15 hover:text-white disabled:pointer-events-none disabled:opacity-0 sm:grid"
>
<ChevronRight className="h-8 w-8" />
</Pressable>
)}
</div>
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own
Toaster is underneath this scrim. See ui/Toaster. In true fullscreen it moves into the
@@ -5,6 +5,7 @@ import type { VideoDetail } from "../lib/api";
import type { AutoMode, LoopMode } from "../lib/playerModes";
import { Button } from "./ui/Button";
import { StateMessage } from "./ui/QueryState";
import { ClampedText } from "./ui/ClampedText";
import { renderDescription } from "./descriptionLinks";
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component —
@@ -109,7 +110,8 @@ export function PlayerTransportControls({
/** The description, said the same way in its two places (the popover under 1280 px, the panel beside
* the video above it): loading, failed with a retry, the text in the caller's box, or none. One
* ladder, so the two cannot drift (the failed state was once tested in the panel only).
* `ref` / `style` / `className` dress the text's box only. */
* `ref` / `style` / `className` dress the text's box only. `clamp` cuts it to three lines with a
* More / Less (the panel, where the description sits under the actions and is rarely read whole). */
export function PlayerDescription({
detail,
currentId,
@@ -118,6 +120,7 @@ export function PlayerDescription({
className,
style,
ref,
clamp = false,
}: {
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
currentId: string;
@@ -126,6 +129,7 @@ export function PlayerDescription({
className: string;
style?: CSSProperties;
ref?: Ref<HTMLDivElement>;
clamp?: boolean;
}) {
const { t } = useTranslation();
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>;
@@ -137,6 +141,18 @@ export function PlayerDescription({
);
const text = detail.data?.description;
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>;
if (clamp)
return (
<ClampedText
text={text}
lines={3}
moreLabel={t("player.descriptionMore")}
lessLabel={t("player.descriptionLess")}
textClassName={className}
>
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
</ClampedText>
);
return (
<div ref={ref} style={style} className={className}>
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })}
+12 -9
View File
@@ -1,4 +1,4 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useLayoutEffect, useRef, useState, type ReactNode } from "react";
import { Button } from "./Button";
// Static class names, so Tailwind sees them.
@@ -8,19 +8,25 @@ const CLAMP = { 3: "line-clamp-3", 5: "line-clamp-5", 8: "line-clamp-8" } as con
* when the text is actually cut — measured, so a short text carries no dead button. Re-measured on a
* resize: the window and the text-size slider both move where the cut falls. Another text starts
* collapsed: a view reused for the next item (a show page opening another show) must not open its
* text already expanded. */
* text already expanded. `children` draws the text in a richer form (a description's links and
* timestamps); `text` still names it, so another text is still another text. */
export function ClampedText({
text,
lines,
moreLabel,
lessLabel,
className,
textClassName = "measure whitespace-pre-line text-sm leading-relaxed text-muted",
children,
}: {
text: string;
lines: keyof typeof CLAMP;
moreLabel: string;
lessLabel: string;
className?: string;
/** The text box's own dress (the default: a summary's muted paragraph). */
textClassName?: string;
children?: ReactNode;
}) {
const [expanded, setExpanded] = useState(false);
const [clipped, setClipped] = useState(false);
@@ -29,7 +35,7 @@ export function ClampedText({
setShown(text);
setExpanded(false);
}
const ref = useRef<HTMLParagraphElement>(null);
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const el = ref.current;
if (!el || expanded) return;
@@ -41,12 +47,9 @@ export function ClampedText({
}, [text, expanded]);
return (
<div className={className}>
<p
ref={ref}
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : CLAMP[lines]}`}
>
{text}
</p>
<div ref={ref} className={`${textClassName} ${expanded ? "" : CLAMP[lines]}`}>
{children ?? text}
</div>
{(clipped || expanded) && (
<Button
variant="link"
@@ -24,7 +24,6 @@ const PLAIN: Record<string, [number, string]> = {
"LanguageSwitcher.tsx": [1, "an option row"],
"NavSidebar.tsx": [4, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"],
"PlayerModal.tsx": [2, "previous / next arrows"],
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"],
+4
View File
@@ -24,6 +24,10 @@
"description": "Description",
"noDescription": "No description.",
"descriptionFailed": "Couldn't load the description",
"descriptionMore": "More",
"descriptionLess": "Less",
"collapsePanel": "Hide the details",
"expandPanel": "Show the details",
"channel": "Channel",
"views": "{{formattedCount}} views",
"stream": "stream",
+4
View File
@@ -24,6 +24,10 @@
"description": "Leírás",
"noDescription": "Nincs leírás.",
"descriptionFailed": "Nem sikerült betölteni a leírást",
"descriptionMore": "Több",
"descriptionLess": "Kevesebb",
"collapsePanel": "Részletek elrejtése",
"expandPanel": "Részletek megjelenítése",
"channel": "Csatorna",
"views": "{{formattedCount}} megtekintés",
"stream": "élő adás",
+11 -9
View File
@@ -77,7 +77,7 @@ function loadYouTubeApi(): Promise<YTNamespace> {
* per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area —
* except over a `[data-wheel-scrolls]` panel that can still scroll that way. */
* except over a `[data-wheel-scrolls]` panel that has anything to scroll, which keeps the wheel. */
export function useYtVolume({
playerRef,
dialogRef,
@@ -154,15 +154,17 @@ export function useYtVolume({
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return;
// Over a panel that can still scroll that way, the wheel scrolls it: the wide player's side panel
// holds the description, which a wheel spent on the volume could never be read past.
const panel =
e.target instanceof Element ? e.target.closest<HTMLElement>("[data-wheel-scrolls]") : null;
// One gesture, one meaning (owner): over a panel with something to scroll the wheel only ever
// scrolls it — at its end too, where it once fell through to the volume mid-spin. Over the video,
// or a panel that fits, it is the volume. The nearer mark wins: the video inside a stacked card
// that scrolls is still the volume.
const zone =
e.target instanceof Element
? e.target.closest<HTMLElement>("[data-wheel-scrolls],[data-wheel-volume]")
: null;
if (
panel &&
(e.deltaY < 0
? panel.scrollTop > 0
: panel.scrollTop + panel.clientHeight < panel.scrollHeight - 1) // -1: sub-pixel heights
zone?.hasAttribute("data-wheel-scrolls") &&
zone.scrollHeight > zone.clientHeight + 1 // +1: sub-pixel heights
)
return;
e.preventDefault();