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