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 // 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). // them off (outside the card, a 1024 window cut them and a portrait tablet lost them).
describe("PlayerModal — the side arrows", () => { // The full-height strips beside the card were a second Previous / Next that cost the video 7rem of
it("stand inside the row's gutter", () => { // 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(() => {})); vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {}));
const A = vid("A", 0); const A = vid("A", 0);
const B = vid("B", 0); const B = vid("B", 0);
renderModal( renderModal(
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />, <PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
); );
const arrow = [...document.querySelectorAll<HTMLElement>("[aria-label='player.next']")].find( expect(screen.getAllByRole("button", { name: "player.next" })).toHaveLength(1);
(b) => b.className.includes("w-12"), expect(screen.getAllByRole("button", { name: "player.previous" })).toHaveLength(1);
)!; const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(arrow).toBeTruthy(); expect(row.className).not.toMatch(/px-14/);
expect(arrow.className).toMatch(/\bright-0\b/); });
expect(arrow.className).not.toMatch(/left-full/);
expect(arrow.parentElement!.className).toMatch(/sm: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={() => {}} />, <PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
); );
const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!; const row = document.querySelector<HTMLElement>(".player-stage")!.parentElement!.parentElement!;
expect(row.style.maxWidth).toBe( expect(row.style.maxWidth).toBe("min(56rem, max(36rem, (100dvh - 3rem - 160px) * 16 / 9))");
"min(63rem, calc(max(36rem, (100dvh - 3rem - 160px) * 16 / 9) + 7rem))",
);
}); });
}); });
@@ -587,13 +598,52 @@ describe("PlayerModal — the side panel on a wide window", () => {
// The description is shown outright; there is no ⓘ to open it. // The description is shown outright; there is no ⓘ to open it.
expect(await screen.findByText("A long description")).toBeTruthy(); expect(await screen.findByText("A long description")).toBeTruthy();
expect(screen.queryByRole("button", { name: "player.description" })).toBeNull(); 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. // The row's width: the video at the window's height, plus the panel.
expect(card.parentElement!.style.maxWidth).toBe( expect(card.parentElement!.style.maxWidth).toBe("calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem)");
"calc((100dvh - 3rem - 0px) * 16 / 9 + 20rem + 7rem)", // 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); wideWindow(true);
vi.spyOn(api, "videoDetail").mockResolvedValue({ vi.spyOn(api, "videoDetail").mockResolvedValue({
description: "A long description", 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 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(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; top = 400;
expect(spin(text, 100)).toBe(true); // at its end: the volume expect(spin(text, 100)).toBe(false); // and at its end, where it once fell through to the volume
expect(spin(text, -100)).toBe(false);
expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video 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. // 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, ArrowLeft,
Check, Check,
CheckCheck, CheckCheck,
ChevronLeft,
ChevronRight,
ExternalLink, ExternalLink,
Info, Info,
Maximize, Maximize,
Minimize, Minimize,
Minimize2, Minimize2,
PanelRightClose,
PanelRightOpen,
Settings, Settings,
SkipBack,
SkipForward,
Trash2, Trash2,
Volume2, Volume2,
VolumeX, VolumeX,
@@ -74,7 +76,6 @@ import {
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton"; import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { SURFACE } from "./ui/surfaces"; import { SURFACE } from "./ui/surfaces";
import { IconLink, LinkButton } from "./ui/LinkButton"; import { IconLink, LinkButton } from "./ui/LinkButton";
import { SectionLabel } from "./ui/Headings"; 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. // a rem width, so it grows with the text size; the video takes what is left.
const WIDE_PLAYER_PX = 1280; const WIDE_PLAYER_PX = 1280;
const SIDE_PANEL = "20rem"; 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). // The narrowest the stacked video gets to make room for the chrome under it (see the card's maxWidth).
const MIN_VIDEO = "36rem"; const MIN_VIDEO = "36rem";
// YouTube's embed draws its controls at fixed CSS px from the iframe's edges, whatever its size // 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 // 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. // 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); 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(() => { useLayoutEffect(() => {
const card = cardRef.current; const card = cardRef.current;
const stage = stageRef.current; const stage = stageRef.current;
@@ -646,9 +666,22 @@ export default function PlayerModal({
onLoadVideo: loadVideo, 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 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 // 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 // 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-modal="true"
aria-labelledby={titleId} aria-labelledby={titleId}
> >
{/* The card, flanked by full-height glassy strips that step through the feed's order (or a {/* The card. Previous / next live in the panel (its transport row, or the shut panel's strip) and
playlist). Each strip sits in a gutter of the card's row, spans its full height, and fades on Shift+← / →: the full-height strips that once flanked the card cost the video 7rem of width
out at the ends; the gutter keeps it on the screen (C3: outside the card, a 1024 window cut for a second copy of the same two buttons (owner). */}
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. */}
<div <div
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`} className="relative flex w-full max-h-full"
// The design width (63rem with the side strips, else 56rem), narrowed so the 16:9 video plus the // The design width (56rem), narrowed so the 16:9 video plus the chrome under it fits the
// chrome under it fits the window's height less the backdrop's padding (`p-6` = 3rem). // 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, // 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 // 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. // 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. // (a laptop at 100%, a 1080p screen at 175%). Past the floor the stacked card scrolls instead.
style={{ style={{
maxWidth: wide maxWidth: wide
? `calc(${fitWidth} + ${SIDE_PANEL}${gutters})` ? `calc(${fitWidth} + ${panelWidth})`
: `min(${hasQueue ? "63rem" : "56rem"}, calc(max(${MIN_VIDEO}, ${fitWidth})${gutters}))`, : `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 <div
ref={cardRef} ref={cardRef}
tabIndex={-1} 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 ${ className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
wide ? "flex overflow-hidden" : "overflow-y-auto" wide ? "flex overflow-hidden" : "overflow-y-auto"
}`} }`}
@@ -710,6 +729,8 @@ export default function PlayerModal({
> >
<div <div
ref={stageRef} 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 ${ 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" wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
} ${maximized ? "player-stage--max" : ""}`} } ${maximized ? "player-stage--max" : ""}`}
@@ -891,275 +912,315 @@ export default function PlayerModal({
description never makes the card taller than the video. */} description never makes the card taller than the video. */}
<div <div
className={wide ? "relative shrink-0 border-l border-border" : undefined} className={wide ? "relative shrink-0 border-l border-border" : undefined}
style={wide ? { width: SIDE_PANEL } : undefined} style={wide ? { width: panelWidth } : undefined}
> >
<div {wide && panelShut ? (
className={wide ? "absolute inset-0 overflow-y-auto" : undefined} <div
data-testid="player-info" data-testid="player-strip"
// The wheel scrolls this panel while it can (lib/playerModalHooks), the volume elsewhere. className="absolute inset-0 flex flex-col items-center gap-1 py-2"
data-wheel-scrolls={wide ? "" : undefined} >
> {closeButton}
{hasQueue && ( <IconButton
<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"> ref={panelToggleRef}
<PlayerTransportControls variant="ghost"
variant="modal" onClick={() => togglePanel(false)}
index={index} label={t("player.expandPanel")}
queueLength={queue!.length} aria-expanded={false}
autoMode={autoMode} >
loopMode={loopMode} <PanelRightOpen />
onPrev={goPrev} </IconButton>
onNext={goNext} {hasQueue && (
onCycleAuto={cycleAuto} <>
onCycleLoop={cycleLoop} <span className="flex-1" />
/>
</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 && (
<IconButton <IconButton
ref={setDescAnchor}
{...descPop.getReferenceProps()}
variant="ghost" variant="ghost"
aria-expanded={showDesc} onClick={goPrev}
label={t("player.description")} disabled={index === 0}
label={t("player.previous")}
title={`${t("player.previous")} · Shift+←`}
> >
<Info /> <SkipBack />
</IconButton> </IconButton>
)} <IconButton
{navigated && (
<Button
variant="ghost" variant="ghost"
size="sm" onClick={goNext}
onClick={() => loadVideo(active.id, null)} disabled={index >= queue!.length - 1}
title={t("player.backToOriginal")} label={t("player.next")}
icon={<ArrowLeft />} title={`${t("player.next")} · Shift+→`}
> >
{t("player.back")} <SkipForward />
</Button> </IconButton>
)} </>
<PopoverSurface )}
popover={descPop} </div>
className={`${SURFACE.popover} w-[min(35rem,calc(100vw-2rem))] p-4`} ) : (
> <div
<SectionLabel level={3} className="mb-2"> className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
{t("player.description")} data-testid="player-info"
</SectionLabel> // A panel with something to scroll takes the wheel for that alone (lib/playerModalHooks).
<PlayerDescription data-wheel-scrolls={wide ? "" : undefined}
ref={descFadeRef} >
style={descFadeStyle} {wide && (
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed" <div className="flex items-center justify-between px-2 pt-2">
{...descriptionProps} <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> </div>
<Button )}
variant="ghost"
size="sm"
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
>
{t("player.close")}
<X />
</Button>
</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), 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. 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 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 group. Unwrapped, a narrow card (a tablet, a zoomed window) squeezed the meta into a
one-word column between the name and the buttons. */} one-word column between the name and the buttons. */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3"> <div className="flex flex-wrap items-center gap-x-3 gap-y-2 mt-3">
{!navigated && ( {!navigated && (
<Avatar <Avatar
src={active.channel_thumbnail} src={active.channel_thumbnail}
fallback={active.channel_title ?? ""} fallback={active.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0" 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"
/> />
<IconLink )}
href={active.channel_url} {navigated ? (
target="_blank" detail.data?.channel_id ? (
rel="noreferrer" <a
label={t("channels.row.openOnYouTube")} href={channelYouTubeUrl(detail.data.channel_id)}
className="shrink-0" 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 /> <ChannelNameLink
</IconLink> channelId={active.channel_id}
</div> title={active.channel_title}
)} onOpenInApp={() => {
{!navigated ? ( // openChannel closes this player first and pushes only once its
<div // Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`} // and clicking the name merely closes the player
> // (lib/history.closeOverlaysThenNavigate).
{active.view_count != null && ( 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> <span>
{metaSep} {metaSep}
{t("player.views", { {relativeTime(active.published_at, lang, t)}
count: active.view_count, {active.published_at && ` · ${fullDate(active.published_at)}`}
formattedCount: formatCompact(active.view_count, lang),
})}
</span> </span>
)} {active.duration_seconds != null && (
<span> <span>
{metaSep} {metaSep}
{relativeTime(active.published_at, lang, t)} {formatDuration(active.duration_seconds)}
{active.published_at && ` · ${fullDate(active.published_at)}`} </span>
</span> )}
{active.duration_seconds != null && ( {active.live_status === "was_live" && (
<span> <span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
{metaSep} {t("player.stream")}
{formatDuration(active.duration_seconds)} </span>
</span> )}
)} </div>
{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"> // Linked video's stats come from the (already-fetched) video detail.
{t("player.stream")} <div
</span> className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`}
)} >
</div> {detail.data?.view_count != null && (
) : ( <span>
// Linked video's stats come from the (already-fetched) video detail. {metaSep}
<div {t("player.views", {
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted ${wide ? META_OWN_LINE : META_MIN}`} count: detail.data.view_count,
> formattedCount: formatCompact(detail.data.view_count, lang),
{detail.data?.view_count != null && ( })}
<span> </span>
{metaSep} )}
{t("player.views", { {detail.data?.published_at && (
count: detail.data.view_count, <span>
formattedCount: formatCompact(detail.data.view_count, lang), {metaSep}
})} {relativeTime(detail.data.published_at, lang, t)} ·{" "}
</span> {fullDate(detail.data.published_at)}
)} </span>
{detail.data?.published_at && ( )}
<span> {detail.data?.duration_seconds != null && (
{metaSep} <span>
{relativeTime(detail.data.published_at, lang, t)} ·{" "} {metaSep}
{fullDate(detail.data.published_at)} {formatDuration(detail.data.duration_seconds)}
</span> </span>
)} )}
{detail.data?.duration_seconds != null && ( </div>
<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 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 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 own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
the docked bar offers OUR true fullscreen. */} the docked bar offers OUR true fullscreen. */}
{!navigated && ( {!navigated && (
// Narrow: the actions end the channel line. Wide: their own line in the panel. // 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"}`}> <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 && (
<IconButton <IconButton
variant="outline-danger" variant="outline"
size="md" size="md"
onClick={() => void removeFromList()} onClick={toggleFullscreen}
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen / label={t("player.fullscreen")}
// Download / Mark watched reads as "delete this video". title={`${t("player.fullscreen")} · F`}
label={t("playlists.removeItemOf", { title: active.title })}
title={t("playlists.removeItem")}
> >
<Trash2 /> <Maximize />
</IconButton> </IconButton>
)} <AddToPlaylist videoId={active.id} variant="outline" size="md" />
<Button <DownloadButton
variant="outline" videoId={active.id}
size="sm" title={active.title}
pressed={watched} variant="outline"
onClick={() => setWatched(!watched)} size="md"
title={watched ? t("player.watchedUnmark") : t("player.markWatched")} />
icon={watched ? <CheckCheck /> : <Check />} {onRemoveFromList && (
> <IconButton
{watched ? t("player.watched") : t("player.markWatched")} variant="outline-danger"
</Button> size="md"
</div> 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> </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> </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> </div>
{/* Toasts raised while the player is open (a failed Remove, a lost connection) — the app's own {/* 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 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 type { AutoMode, LoopMode } from "../lib/playerModes";
import { Button } from "./ui/Button"; import { Button } from "./ui/Button";
import { StateMessage } from "./ui/QueryState"; import { StateMessage } from "./ui/QueryState";
import { ClampedText } from "./ui/ClampedText";
import { renderDescription } from "./descriptionLinks"; import { renderDescription } from "./descriptionLinks";
// Presentational controls for the YouTube modal player (PlayerModal), split out of the god component — // 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 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 * 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). * 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({ export function PlayerDescription({
detail, detail,
currentId, currentId,
@@ -118,6 +120,7 @@ export function PlayerDescription({
className, className,
style, style,
ref, ref,
clamp = false,
}: { }: {
detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown }; detail: { isLoading: boolean; isError: boolean; data?: VideoDetail; refetch: () => unknown };
currentId: string; currentId: string;
@@ -126,6 +129,7 @@ export function PlayerDescription({
className: string; className: string;
style?: CSSProperties; style?: CSSProperties;
ref?: Ref<HTMLDivElement>; ref?: Ref<HTMLDivElement>;
clamp?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
if (detail.isLoading) return <div className="text-sm text-muted">{t("player.loading")}</div>; 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; const text = detail.data?.description;
if (!text) return <div className="text-sm text-muted">{t("player.noDescription")}</div>; 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 ( return (
<div ref={ref} style={style} className={className}> <div ref={ref} style={style} className={className}>
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })} {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"; import { Button } from "./Button";
// Static class names, so Tailwind sees them. // 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 * 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 * 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 * 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({ export function ClampedText({
text, text,
lines, lines,
moreLabel, moreLabel,
lessLabel, lessLabel,
className, className,
textClassName = "measure whitespace-pre-line text-sm leading-relaxed text-muted",
children,
}: { }: {
text: string; text: string;
lines: keyof typeof CLAMP; lines: keyof typeof CLAMP;
moreLabel: string; moreLabel: string;
lessLabel: string; lessLabel: string;
className?: 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 [expanded, setExpanded] = useState(false);
const [clipped, setClipped] = useState(false); const [clipped, setClipped] = useState(false);
@@ -29,7 +35,7 @@ export function ClampedText({
setShown(text); setShown(text);
setExpanded(false); setExpanded(false);
} }
const ref = useRef<HTMLParagraphElement>(null); const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
const el = ref.current; const el = ref.current;
if (!el || expanded) return; if (!el || expanded) return;
@@ -41,12 +47,9 @@ export function ClampedText({
}, [text, expanded]); }, [text, expanded]);
return ( return (
<div className={className}> <div className={className}>
<p <div ref={ref} className={`${textClassName} ${expanded ? "" : CLAMP[lines]}`}>
ref={ref} {children ?? text}
className={`measure whitespace-pre-line text-sm leading-relaxed text-muted ${expanded ? "" : CLAMP[lines]}`} </div>
>
{text}
</p>
{(clipped || expanded) && ( {(clipped || expanded) && (
<Button <Button
variant="link" variant="link"
@@ -24,7 +24,6 @@ const PLAIN: Record<string, [number, string]> = {
"LanguageSwitcher.tsx": [1, "an option row"], "LanguageSwitcher.tsx": [1, "an option row"],
"NavSidebar.tsx": [4, "the account trigger and its menu rows"], "NavSidebar.tsx": [4, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"], "PaletteLauncher.tsx": [2, "launcher controls"],
"PlayerModal.tsx": [2, "previous / next arrows"],
"PlaylistsRail.tsx": [1, "the load-error retry"], "PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"], "PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"], "PlexInfo.tsx": [1, "the rating filter (a number)"],
+4
View File
@@ -24,6 +24,10 @@
"description": "Description", "description": "Description",
"noDescription": "No description.", "noDescription": "No description.",
"descriptionFailed": "Couldn't load the description", "descriptionFailed": "Couldn't load the description",
"descriptionMore": "More",
"descriptionLess": "Less",
"collapsePanel": "Hide the details",
"expandPanel": "Show the details",
"channel": "Channel", "channel": "Channel",
"views": "{{formattedCount}} views", "views": "{{formattedCount}} views",
"stream": "stream", "stream": "stream",
+4
View File
@@ -24,6 +24,10 @@
"description": "Leírás", "description": "Leírás",
"noDescription": "Nincs leírás.", "noDescription": "Nincs leírás.",
"descriptionFailed": "Nem sikerült betölteni a leírást", "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", "channel": "Csatorna",
"views": "{{formattedCount}} megtekintés", "views": "{{formattedCount}} megtekintés",
"stream": "élő adá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 * 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. * 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 — * 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({ export function useYtVolume({
playerRef, playerRef,
dialogRef, dialogRef,
@@ -154,15 +154,17 @@ export function useYtVolume({
const onWheelSpin = useEffectEvent((e: WheelEvent) => { const onWheelSpin = useEffectEvent((e: WheelEvent) => {
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it. // Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
if (e.ctrlKey || e.metaKey) return; 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 // One gesture, one meaning (owner): over a panel with something to scroll the wheel only ever
// holds the description, which a wheel spent on the volume could never be read past. // scrolls it — at its end too, where it once fell through to the volume mid-spin. Over the video,
const panel = // or a panel that fits, it is the volume. The nearer mark wins: the video inside a stacked card
e.target instanceof Element ? e.target.closest<HTMLElement>("[data-wheel-scrolls]") : null; // that scrolls is still the volume.
const zone =
e.target instanceof Element
? e.target.closest<HTMLElement>("[data-wheel-scrolls],[data-wheel-volume]")
: null;
if ( if (
panel && zone?.hasAttribute("data-wheel-scrolls") &&
(e.deltaY < 0 zone.scrollHeight > zone.clientHeight + 1 // +1: sub-pixel heights
? panel.scrollTop > 0
: panel.scrollTop + panel.clientHeight < panel.scrollHeight - 1) // -1: sub-pixel heights
) )
return; return;
e.preventDefault(); e.preventDefault();