diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index e7520c90..78109726 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -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( {}} onState={() => {}} />, ); - const arrow = [...document.querySelectorAll("[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(".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( + {}} />, + ); + 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", () => { {}} onState={() => {}} />, ); const row = document.querySelector(".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( + {}} onState={() => {}} />, + ); + const row = () => + document.querySelector(".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 . + 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( + {}} onState={() => {}} />, + ); + const stage = document.querySelector(".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. diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 9998b6ab..1674232d 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -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 , where the player's keys would no longer reach it. + const panelToggleRef = useRef(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 = ( + + + + ); // Portaled to , 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). */}
- {hasQueue && ( - { - 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" - > - - - )}
-
- {hasQueue && ( -
- -
- )} - -
- {/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */} -
-

- {navigated ? (liveData?.title ?? t("player.loading")) : active.title} -

- {!wide && ( + {wide && panelShut ? ( +
+ {closeButton} + togglePanel(false)} + label={t("player.expandPanel")} + aria-expanded={false} + > + + + {hasQueue && ( + <> + - + - )} - {navigated && ( - - )} - - - {t("player.description")} - - + + + )} +
+ ) : ( +
+ {wide && ( +
+ togglePanel(true)} + label={t("player.collapsePanel")} + aria-expanded + > + + + {closeButton} +
+ )} + {hasQueue && ( +
+ - - -
+
+ )} - {/* Channel + meta on one line, with the actions pushed to the right. +
+ {/* Title row — the title and its ⓘ (the description) on the left; stacked, Close on the + right (wide, it heads the panel). */} +
+

+ {navigated ? (liveData?.title ?? t("player.loading")) : active.title} +

+ {!wide && ( + + + + )} + {navigated && ( + + )} + + + {t("player.description")} + + + + {!wide && closeButton} +
+ + {/* 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. */} -
- {!navigated && ( - - )} - {navigated ? ( - detail.data?.channel_id ? ( - - {liveData?.author ?? detail.data.channel_title ?? t("player.channel")} - - ) : ( - {liveData?.author ?? ""} - ) - ) : ( -
- { - // 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" +
+ {!navigated && ( + - + {liveData?.author ?? detail.data.channel_title ?? t("player.channel")} + + ) : ( + + {liveData?.author ?? ""} + + ) + ) : ( + // 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. +
- - -
- )} - {!navigated ? ( -
- {active.view_count != null && ( + { + // 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" + /> + + + +
+ )} + {!navigated ? ( +
+ {active.view_count != null && ( + + {metaSep} + {t("player.views", { + count: active.view_count, + formattedCount: formatCompact(active.view_count, lang), + })} + + )} {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)}`} - )} - - {metaSep} - {relativeTime(active.published_at, lang, t)} - {active.published_at && ` · ${fullDate(active.published_at)}`} - - {active.duration_seconds != null && ( - - {metaSep} - {formatDuration(active.duration_seconds)} - - )} - {active.live_status === "was_live" && ( - - {t("player.stream")} - - )} -
- ) : ( - // Linked video's stats come from the (already-fetched) video detail. -
- {detail.data?.view_count != null && ( - - {metaSep} - {t("player.views", { - count: detail.data.view_count, - formattedCount: formatCompact(detail.data.view_count, lang), - })} - - )} - {detail.data?.published_at && ( - - {metaSep} - {relativeTime(detail.data.published_at, lang, t)} ·{" "} - {fullDate(detail.data.published_at)} - - )} - {detail.data?.duration_seconds != null && ( - - {metaSep} - {formatDuration(detail.data.duration_seconds)} - - )} -
- )} + {active.duration_seconds != null && ( + + {metaSep} + {formatDuration(active.duration_seconds)} + + )} + {active.live_status === "was_live" && ( + + {t("player.stream")} + + )} +
+ ) : ( + // Linked video's stats come from the (already-fetched) video detail. +
+ {detail.data?.view_count != null && ( + + {metaSep} + {t("player.views", { + count: detail.data.view_count, + formattedCount: formatCompact(detail.data.view_count, lang), + })} + + )} + {detail.data?.published_at && ( + + {metaSep} + {relativeTime(detail.data.published_at, lang, t)} ·{" "} + {fullDate(detail.data.published_at)} + + )} + {detail.data?.duration_seconds != null && ( + + {metaSep} + {formatDuration(detail.data.duration_seconds)} + + )} +
+ )} - {/* 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. -
- - - - - - {onRemoveFromList && ( + {!navigated && ( + // Narrow: the actions end the channel line. Wide: their own line in the panel. +
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`} > - + - )} - -
+ + + {onRemoveFromList && ( + 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")} + > + + + )} + +
+ )} +
+ {wide && ( +
+ + {t("player.description")} + + +
)}
- {wide && ( -
- - {t("player.description")} - - -
- )}
-
+ )}
- {hasQueue && ( - { - 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" - > - - - )}
{/* 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 diff --git a/frontend/src/components/playerModalControls.tsx b/frontend/src/components/playerModalControls.tsx index eb92132d..1ad217ab 100644 --- a/frontend/src/components/playerModalControls.tsx +++ b/frontend/src/components/playerModalControls.tsx @@ -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; + clamp?: boolean; }) { const { t } = useTranslation(); if (detail.isLoading) return
{t("player.loading")}
; @@ -137,6 +141,18 @@ export function PlayerDescription({ ); const text = detail.data?.description; if (!text) return
{t("player.noDescription")}
; + if (clamp) + return ( + + {renderDescription(text, { currentId, onSeek, onLoadVideo, t })} + + ); return (
{renderDescription(text, { currentId, onSeek, onLoadVideo, t })} diff --git a/frontend/src/components/ui/ClampedText.tsx b/frontend/src/components/ui/ClampedText.tsx index 41ddf634..a331a29f 100644 --- a/frontend/src/components/ui/ClampedText.tsx +++ b/frontend/src/components/ui/ClampedText.tsx @@ -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(null); + const ref = useRef(null); useLayoutEffect(() => { const el = ref.current; if (!el || expanded) return; @@ -41,12 +47,9 @@ export function ClampedText({ }, [text, expanded]); return (
-

- {text} -

+
+ {children ?? text} +
{(clipped || expanded) && (