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