diff --git a/docs/DESIGN.md b/docs/DESIGN.md index a82749f6..fbc82c21 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -700,3 +700,4 @@ of a full-width panel and nothing on the right. | 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible | | 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page | | 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site | +| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it | diff --git a/frontend/src/components/ChannelPage.fold.dom.test.tsx b/frontend/src/components/ChannelPage.fold.dom.test.tsx new file mode 100644 index 00000000..03730394 --- /dev/null +++ b/frontend/src/components/ChannelPage.fold.dom.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom +// +// E31 (the owner's pick): the channel page's chrome is fixed, and its banner + identity block kept ~40% of a +// 133% screen before the first video for as long as the page was open. Once the grid scrolls they fold +// away and the name rides in the tab row; back at the top they return. The Back button is one element in +// both states, so a focused Back keeps its focus through the fold. +import type { ReactNode } from "react"; +import { describe, it, expect, vi, beforeAll, afterEach } from "vitest"; +import { render, cleanup, screen, act, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), +})); +vi.mock("./navigationContext", () => ({ + useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }), +})); +vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true })); +vi.mock("./feedFiltersContext", () => ({ + useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }), +})); +vi.mock("./Feed", () => ({ default: () => null })); +vi.mock("./ChannelAbout", () => ({ default: () => null })); +vi.mock("./ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}, +})); + +import ChannelPage from "./ChannelPage"; +import { CONTENT_ID } from "./ui/landmarks"; +import { api, type ChannelDetail, type Me } from "../lib/api"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; + globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver; +}); +afterEach(() => { + cleanup(); + document.getElementById(CONTENT_ID)?.remove(); + vi.restoreAllMocks(); +}); + +const CH = "UCqoAEDirJPjEUFcF2FklnBA"; +const BANNER = 'img[src^="https://yt3."]'; + +// The page's one scroller (PageScroller's
), with a scroll position jsdom does not lay out. +function scroller() { + const main = document.createElement("main"); + main.id = CONTENT_ID; + let top = 0; + Object.defineProperty(main, "scrollTop", { get: () => top, configurable: true }); + Object.defineProperty(main, "scrollHeight", { value: 6000, configurable: true }); + Object.defineProperty(main, "clientHeight", { value: 600, configurable: true }); + document.body.appendChild(main); + return (to: number) => + act(() => { + top = to; + main.dispatchEvent(new Event("scroll")); + }); +} + +// Renders the page and waits for the detail to land (the banner shows). +async function renderPage() { + vi.spyOn(api, "channelDetail").mockResolvedValue({ + id: CH, + title: "StarTalk", + banner_url: "https://yt3.googleusercontent.com/banner", + subscribed: true, + blocked: false, + hidden: false, + deep_requested: false, + backfill_done: true, + stored_videos: 10, + tag_ids: [], + } as unknown as ChannelDetail); + vi.spyOn(api, "tags").mockResolvedValue([] as never); + const view = render( + + {}} + onShowInFeed={() => {}} + /> + , + ); + await waitFor(() => expect(view.container.querySelector(BANNER)).not.toBeNull()); + return view; +} + +describe("ChannelPage — the header folds while the grid scrolls", () => { + it("drops the banner and puts the name in the tab row once scrolled, and brings them back at the top", async () => { + const scrollTo = scroller(); + const { container } = await renderPage(); + const banner = () => container.querySelector(BANNER); + expect(banner()).not.toBeNull(); + expect(screen.queryByTestId("channel-folded")).toBeNull(); + + scrollTo(400); + expect(banner()).toBeNull(); + expect(screen.getByTestId("channel-folded").textContent).toContain("StarTalk"); + + scrollTo(10); + expect(screen.queryByTestId("channel-folded")).not.toBeNull(); + scrollTo(0); + expect(banner()).not.toBeNull(); + expect(screen.queryByTestId("channel-folded")).toBeNull(); + }); + + it("keeps a focused Back focused through the fold", async () => { + const scrollTo = scroller(); + await renderPage(); + const back = screen.getByTestId("channel-back"); + back.focus(); + scrollTo(400); + expect(screen.getByTestId("channel-folded")).toBeTruthy(); + expect(document.activeElement).toBe(back); + }); +}); diff --git a/frontend/src/components/ChannelPage.tsx b/frontend/src/components/ChannelPage.tsx index 8fea4170..758a3b78 100644 --- a/frontend/src/components/ChannelPage.tsx +++ b/frontend/src/components/ChannelPage.tsx @@ -24,6 +24,7 @@ import { import Avatar from "./ui/Avatar"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; +import { useScrollFold } from "./ui/scrollFold"; import Feed from "./Feed"; import ChannelAboutContent from "./ChannelAbout"; import { PageToolbar } from "./ui/PageShell"; @@ -89,6 +90,10 @@ export default function ChannelPage({ // where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so // the toolbar lands under the tabs inside the fixed band rather than in the scroller. const [chromeSlot, setChromeSlot] = useState(null); + // The banner and the identity block fold away once the grid scrolls (E31, the owner's pick): the chrome + // is fixed, so unfolded it kept ~40% of a 133% screen for good. Folded, the name rides in the tab row. + const foldable = useRef(null); + const folded = useScrollFold(foldable); // The banner comes from Google's image CDN, which refuses any request carrying a Referer from our // origin — every time, not only in a burst (measured 2026-09-26) — so the sends none // (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss, @@ -320,255 +325,292 @@ export default function ChannelPage({ : null, joined ? t("channel.joined", { date: joined }) : null, ].filter(Boolean) as string[]; + // The page's name and its state, shown in the identity block or, folded, in the tab row. + const heading = ( + + {name} + + ); + const stateBadge = ch?.blocked ? ( + + {t("channel.blockedBadge")} + + ) : ( + ch?.explored && + !ch?.subscribed && ( + + {t("channel.exploringBadge")} + + ) + ); // The channel page owns no scroller of its own (PageShell provides the one app scroller). Its // whole chrome is fixed — wrapped in it portals into the shell's fixed band, so the - // banner/identity/tabs/toolbar stay put and only the video grid scrolls. + // tabs/toolbar stay put and only the video grid scrolls; the banner/identity fold away meanwhile. const bannerSrc = ch?.banner_url ? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}` : null; return ( <> - {/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */} -
- {bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? ( - // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a - // low default size, so request a crisp wide version (=w1707) and object-cover the - // desktop "safe area" — the centre 2560×423 (~6:1) band. -
- per attempt; the ?r= cache-buster on a retry bypasses the - // browser's negative cache (googleusercontent accepts an extra query param). - key={bannerAttempt} - src={bannerSrc} - referrerPolicy="no-referrer" - alt="" - className={`w-full h-full object-cover object-center ${ - bannerLoaded === bannerSrc ? "" : "opacity-0" - }`} - onLoad={() => setBannerLoaded(bannerSrc)} - onError={() => { - if (bannerAttempt <= MAX_BANNER_RETRIES) { - window.setTimeout( - () => setBannerAttempt((a) => a + 1), - 500 * (bannerAttempt + 1), - ); - } - }} - /> -
- ) : ( - // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the - // overlapping avatar + the Back button have the same room and don't collide (a short bar - // let the -mt-8 avatar ride up into the Back button). -
- )} - -
- -
- {/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner - paints ABOVE it (the banner's positioned container would otherwise cover it). */} -
- -
-
- - {name} - - {ch?.blocked ? ( - - {t("channel.blockedBadge")} - +
+ {!folded && ( +
+ {/* Banner — OPTION C: inset, rounded card (not full-bleed) */} +
+ {bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? ( + // Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a + // low default size, so request a crisp wide version (=w1707) and object-cover the + // desktop "safe area" — the centre 2560×423 (~6:1) band. +
+ per attempt; the ?r= cache-buster on a retry bypasses the + // browser's negative cache (googleusercontent accepts an extra query param). + key={bannerAttempt} + src={bannerSrc} + referrerPolicy="no-referrer" + alt="" + className={`w-full h-full object-cover object-center ${ + bannerLoaded === bannerSrc ? "" : "opacity-0" + }`} + onLoad={() => setBannerLoaded(bannerSrc)} + onError={() => { + if (bannerAttempt <= MAX_BANNER_RETRIES) { + window.setTimeout( + () => setBannerAttempt((a) => a + 1), + 500 * (bannerAttempt + 1), + ); + } + }} + /> +
) : ( - ch?.explored && - !ch?.subscribed && ( - - {t("channel.exploringBadge")} - - ) + // No banner (or a dead one): a neutral bar the SAME height as a real banner, so the + // overlapping avatar + the Back button have the same room and don't collide (a short bar + // let the -mt-8 avatar ride up into the Back button). +
)}
-
- {metaParts.map((part, i) => ( - - {i > 0 && ·} - {part} - - ))} -
-
-
- {exploring && ( -
- - {t("channel.loadingVideos")} +
+ {/* Identity. relative+z-base so the avatar that overlaps up into the banner paints + ABOVE it (the banner's positioned container would otherwise cover it). */} +
+ +
+
+ {heading} + {stateBadge} +
+
+ {metaParts.map((part, i) => ( + + {i > 0 && ·} + {part} + + ))} +
+
+
+
)} - {/* Tabs — the channel actions ride along here rather than off in the header's top-right +
+ {exploring && ( +
+ + {t("channel.loadingVideos")} +
+ )} + + {/* Tabs — the channel actions ride along here rather than off in the header's top-right corner: there are only ever two tabs, so there's room, and it keeps the things you click within a few pixels of each other instead of across the page. */} - {/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and + {/* Wraps rather than overflows: at a tablet width (1024) the actions + tags ran past the edge and the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */} -
- setTab(id as "videos" | "about")} - tabs={[ - { id: "videos", label: t("channel.tabVideos") }, - { id: "about", label: t("channel.tabAbout") }, - ]} - /> -
- + {/* One Back button in both states, so a focused Back keeps its focus when the header folds: + over the banner's corner (absolute to the chrome) while unfolded, first in this row folded. */} + - )} - {!me.is_demo && ( - block.mutate()} - loading={block.isPending} - label={ch?.blocked ? t("channel.unblock") : t("channel.block")} - > - - - )} - {/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel + {ch?.subscribed && !ch.backfill_done && !me.is_demo && ( + + )} + {!me.is_demo && ( + block.mutate()} + loading={block.isPending} + label={ch?.blocked ? t("channel.unblock") : t("channel.block")} + > + + + )} + {/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel (mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos from the feed. EyeOff = currently hidden (click to unhide). */} - {ch?.subscribed && !me.is_demo && ( - toggleHidden.mutate()} - loading={toggleHidden.isPending} - label={ch.hidden ? t("channel.unhide") : t("channel.hide")} - title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")} - > - {ch.hidden ? : } - - )} - {!me.is_demo && - !ch?.blocked && - (ch?.subscribed ? ( - - ) : ( - - ))} - {/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s + {ch.hidden ? : } + + )} + {!me.is_demo && + !ch?.blocked && + (ch?.subscribed ? ( + + ) : ( + + ))} + {/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s otherwise), and personal tags are for your subscriptions — so only offer it on a channel you actually follow. */} - {ch?.subscribed && !me.is_demo && ( -
- toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))} - onCreate={(name) => createAndAttachTag(channelId, name)} - // The header row's leftover width: tags inline, "+N" only when they do not fit. - bounded={false} - /> -
- )} + {ch?.subscribed && !me.is_demo && ( +
+ toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))} + onCreate={(name) => createAndAttachTag(channelId, name)} + // The header row's leftover width: tags inline, "+N" only when they do not fit. + bounded={false} + /> +
+ )} +
diff --git a/frontend/src/components/PlayerModal.dom.test.tsx b/frontend/src/components/PlayerModal.dom.test.tsx index 10649ea6..098759d6 100644 --- a/frontend/src/components/PlayerModal.dom.test.tsx +++ b/frontend/src/components/PlayerModal.dom.test.tsx @@ -468,3 +468,90 @@ describe("PlayerModal — fits the window's height", () => { expect(row.style.maxWidth).toBe("min(63rem, calc((100dvh - 3rem - 160px) * 16 / 9 + 7rem))"); }); }); + +// E31 (the owner's pick "A"): on a wide window the video was capped at ~1008 px of a 1920 screen, with +// empty space all round. From 1280 px the title, channel, actions, description and queue sit in a side +// panel beside the video, and the video takes the window's height. +describe("PlayerModal — the side panel on a wide window", () => { + const wideWindow = (matches: boolean) => + vi.spyOn(window, "matchMedia").mockImplementation( + (q: string) => + ({ + matches: matches && q === "(min-width: 1280px)", + media: q, + addEventListener: () => {}, + removeEventListener: () => {}, + }) as unknown as MediaQueryList, + ); + beforeEach(() => { + window.matchMedia ??= (() => ({})) as unknown as typeof window.matchMedia; + }); + + it("puts the details beside the video, the description open, and sizes the card for both", async () => { + wideWindow(true); + vi.spyOn(api, "videoDetail").mockResolvedValue({ + description: "A long description", + } as Awaited>); + const A = vid("A", 0); + const B = vid("B", 0); + renderModal( + {}} onState={() => {}} />, + ); + const stage = document.querySelector(".player-stage")!; + const card = stage.parentElement!; + const info = screen.getByTestId("player-info"); + // The card is a row: the video, then the panel holding the title and the channel. + expect(card.className).toMatch(/(^|\s)flex(\s|$)/); + expect(card.contains(info) && !stage.contains(info)).toBe(true); + expect(info.contains(screen.getByRole("heading", { name: "Video A" }))).toBe(true); + expect(stage.compareDocumentPosition(info) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + // 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)", + ); + }); + + it("lets the wheel scroll the panel while it can, and turn the volume everywhere else", async () => { + wideWindow(true); + vi.spyOn(api, "videoDetail").mockResolvedValue({ + description: "A long description", + } as Awaited>); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + const text = await screen.findByText("A long description"); + const panel = screen.getByTestId("player-info"); + let top = 0; + Object.defineProperty(panel, "scrollTop", { get: () => top, configurable: true }); + Object.defineProperty(panel, "scrollHeight", { value: 900, configurable: true }); + Object.defineProperty(panel, "clientHeight", { value: 500, configurable: true }); + const spin = (el: Element, deltaY: number) => { + const e = new WheelEvent("wheel", { deltaY, bubbles: true, cancelable: true }); + el.dispatchEvent(e); + 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 + top = 400; + expect(spin(text, 100)).toBe(true); // at its end: the volume + expect(spin(text, -100)).toBe(false); + expect(spin(document.querySelector(".player-stage")!, 100)).toBe(true); // over the video + }); + + it("keeps the details under the video below 1280 px", () => { + wideWindow(false); + vi.spyOn(api, "videoDetail").mockReturnValue(new Promise(() => {})); + const A = vid("A", 0); + renderModal( + {}} onState={() => {}} />, + ); + const card = document.querySelector(".player-stage")!.parentElement!; + expect(card.className).not.toMatch(/(^|\s)flex(\s|$)/); + expect(screen.getByRole("button", { name: "player.description" })).toBeTruthy(); + expect(screen.queryByTestId("player-description")).toBeNull(); + }); +}); diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index c066371f..526992b7 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -76,6 +76,7 @@ import { Pressable } from "./ui/Pressable"; import { SURFACE } from "./ui/surfaces"; import { IconLink, LinkButton } from "./ui/LinkButton"; import { SectionLabel } from "./ui/Headings"; +import { useMediaQuery } from "../lib/useMediaQuery"; // Experiment (branch experiment/inline-player): play the video in-app via the // YouTube IFrame Player API (not a bare embed) so we can read playback position @@ -98,6 +99,20 @@ function canScrollBy(el: HTMLElement | null, dir: -1 | 1): boolean { return false; } +// From this window width the player's details sit in a side panel beside the video (E31). The panel is +// 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 meta line's items each lead with a "·" that parts them from what stands before: the channel name +// on its line. In the wide panel the meta has a line of its own, where the first "·" would lead nothing. +const metaSep = ( + +); +const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden"; + export default function PlayerModal({ video, startAt, @@ -194,6 +209,10 @@ export default function PlayerModal({ // full-width card ran past the bottom and cut its channel row (E31 S1). `below` is that chrome's // height, measured (it wraps with the title and the text size), and caps the card's width below. const [below, setBelow] = useState(0); + // A wide window (E31, the owner's pick "A"): the title, channel, actions, description and the queue + // 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); useLayoutEffect(() => { const card = cardRef.current; const stage = stageRef.current; @@ -450,7 +469,8 @@ export default function PlayerModal({ queryFn: () => api.videoDetail(currentVideoId), // On opening the description, and eagerly when navigated, so we can link the // linked video's channel. Both share the one cached result. - enabled: showDesc || navigated, + // On a wide window the side panel shows the description outright (a stored video's is a DB read). + enabled: showDesc || navigated || wide, staleTime: 5 * 60_000, }); @@ -609,10 +629,13 @@ export default function PlayerModal({ 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). + // Wide: no design cap — the video is as wide as the window's height allows, plus the side panel. style={{ - maxWidth: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${ - hasQueue ? " + 7rem" : "" - }))`, + maxWidth: wide + ? `calc((100dvh - 3rem - ${below}px) * 16 / 9 + ${SIDE_PANEL}${hasQueue ? " + 7rem" : ""})` + : `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${ + hasQueue ? " + 7rem" : "" + }))`, }} > {hasQueue && ( @@ -632,14 +655,16 @@ export default function PlayerModal({
e.stopPropagation()} >
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed through our overlay. */} @@ -798,234 +823,284 @@ export default function PlayerModal({ )}
- {hasQueue && ( -
- -
- )} - -
- {/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */} -
-

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

- - - - {navigated && ( - + {/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long + description never makes the card taller than the video. */} +
+
+ {hasQueue && ( +
+ +
)} - - - {t("player.description")} - - {detail.isLoading ? ( -
{t("player.loading")}
- ) : detail.data?.description ? ( -
- {renderDescription(detail.data.description, { - currentId: currentVideoId, - onSeek: seekTo, - onLoadVideo: loadVideo, - t, - })} -
- ) : ( -
{t("player.noDescription")}
- )} -
- -
- {/* Channel + meta on one line, with the watched toggle pushed to the right. +
+ {/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */} +
+

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

+ {!wide && ( + + + + )} + {navigated && ( + + )} + + + {t("player.description")} + + {detail.isLoading ? ( +
{t("player.loading")}
+ ) : detail.data?.description ? ( +
+ {renderDescription(detail.data.description, { + currentId: currentVideoId, + onSeek: seekTo, + onLoadVideo: loadVideo, + t, + })} +
+ ) : ( +
{t("player.noDescription")}
+ )} +
+ +
+ + {/* Channel + meta on one line, with the watched toggle 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. */} -
- {!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 ? ( -
- {active.view_count != null && ( - - ·{" "} - {t("player.views", { - count: active.view_count, - formattedCount: formatCompact(active.view_count, lang), - })} - +
+ {!navigated && ( + )} - - · {relativeTime(active.published_at, lang)} - {active.published_at && ` · ${fullDate(active.published_at)}`} - - {active.duration_seconds != null && ( - · {formatDuration(active.duration_seconds)} + {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" + /> + + + +
)} - {active.live_status === "was_live" && ( - - {t("player.stream")} - + {!navigated ? ( +
+ {active.view_count != null && ( + + {metaSep} + {t("player.views", { + count: active.view_count, + formattedCount: formatCompact(active.view_count, lang), + })} + + )} + + {metaSep} + {relativeTime(active.published_at, lang)} + {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)} ·{" "} + {fullDate(detail.data.published_at)} + + )} + {detail.data?.duration_seconds != null && ( + + {metaSep} + {formatDuration(detail.data.duration_seconds)} + + )} +
)} -
- ) : ( - // Linked video's stats come from the (already-fetched) video detail. -
- {detail.data?.view_count != null && ( - - ·{" "} - {t("player.views", { - count: detail.data.view_count, - formattedCount: formatCompact(detail.data.view_count, lang), - })} - - )} - {detail.data?.published_at && ( - - · {relativeTime(detail.data.published_at, lang)} ·{" "} - {fullDate(detail.data.published_at)} - - )} - {detail.data?.duration_seconds != null && ( - · {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 && ( - - - - )} - {!navigated && } - {!navigated && ( - - )} - {!navigated && 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")} - > - - - )} - {!navigated && ( - - )} + {!navigated && ( + + + + )} + {!navigated && } + {!navigated && ( + + )} + {!navigated && 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")} + > + + + )} + {!navigated && ( + + )} +
+ {wide && ( +
+ + {t("player.description")} + + {detail.isLoading ? ( +
{t("player.loading")}
+ ) : detail.data?.description ? ( +
+ {renderDescription(detail.data.description, { + currentId: currentVideoId, + onSeek: seekTo, + onLoadVideo: loadVideo, + t, + })} +
+ ) : ( +
{t("player.noDescription")}
+ )} +
+ )} +
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx index caa640e0..eef531e0 100644 --- a/frontend/src/components/PlexCollectionView.tsx +++ b/frontend/src/components/PlexCollectionView.tsx @@ -286,6 +286,9 @@ export default function PlexCollectionView({ posterDisabled={!canPlay || queueBusy} showPlayOverlay={canPlay} onPosterClick={() => canPlay && !queueBusy && playAll(false)} + // Sized to the text beside it (E31, the owner's pick): a title, one meta line and a short + // description sat beside a show-sized poster, two thirds of the hero empty under them. + posterSizeClass="w-20 sm:w-24" customizeMenu={null} > {renaming ? ( diff --git a/frontend/src/components/PlexInfo.dom.test.tsx b/frontend/src/components/PlexInfo.dom.test.tsx index bbd5cc8f..90b958bf 100644 --- a/frontend/src/components/PlexInfo.dom.test.tsx +++ b/frontend/src/components/PlexInfo.dom.test.tsx @@ -174,8 +174,11 @@ describe("PlexInfo enriched detail (page)", () => { // E28 S3h, the owner's layout B: a main column (collection strips → crew → cast) beside a sidebar of // facts (details, keywords); a column with nothing in it is not rendered and the other takes the width. -describe("PlexInfo layout B (page)", () => { - const TWO_COLUMNS = "lg:grid-cols-[minmax(0,1fr)_20rem]"; +// E31 (the owner's pick "A"): the hero's text stopped at ~55% of the panel. The facts and the key crew are +// its second column now (a grid that splits in a wide hero), the keywords sit under the text, and under +// the hero only the collection strips and the cast remain, stacked. +describe("PlexInfo — the hero's second column (page)", () => { + const TWO_COLUMNS = "@5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]"; // The strips are Carousels, which watch their size. beforeAll(() => { class Stub { @@ -186,7 +189,8 @@ describe("PlexInfo layout B (page)", () => { globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; }); const heading = (name: string) => screen.getByRole("heading", { name }); - const column = (el: HTMLElement) => el.closest("div.flex-col") as HTMLElement; + const follows = (a: HTMLElement, b: HTMLElement) => + !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); const full: PlexItemDetail = { ...detail, cast: [{ name: "An Actor", role: "Hero" }], @@ -200,38 +204,51 @@ describe("PlexInfo layout B (page)", () => { ], }; - it("stacks the collection, the crew and the cast in the main column, the facts beside it", () => { + it("puts the facts over the crew beside the text, the keywords under it, the rest below the hero", () => { wrap(); + const aside = screen.getByTestId("detail-aside"); + expect(aside.contains(heading("plex.info.facts.title"))).toBe(true); + expect(aside.contains(heading("plex.info.crew"))).toBe(true); + expect(follows(heading("plex.info.facts.title"), heading("plex.info.crew"))).toBe(true); + // The grid's two children: the poster + text, and the column. + const grid = aside.parentElement!; + expect(grid.className).toContain(TWO_COLUMNS); + const main = grid.children[0]!; + const side = grid.children[1]; + expect(side).toBe(aside); + expect(main.contains(screen.getByText("A summary."))).toBe(true); + expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true); + // Below the hero (the panel under the title): the collection, then the cast — and neither in it. + const hero = grid.parentElement!; + expect(hero.contains(heading("Test Movie"))).toBe(true); const coll = heading("The Collection"); - const crew = heading("plex.info.crew"); const cast = heading("plex.info.cast"); - const facts = heading("plex.info.facts.title"); - const follows = (a: HTMLElement, b: HTMLElement) => - !!(a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING); - expect(follows(coll, crew) && follows(crew, cast)).toBe(true); - const main = column(coll); - expect(main.contains(crew) && main.contains(cast)).toBe(true); - const side = column(facts); - expect(side).not.toBe(main); - expect(side).toBe(column(heading("plex.info.keywords"))); - // the two columns are the two children of the one grid - expect(main.parentElement!.className).toContain(TWO_COLUMNS); - expect([...main.parentElement!.children]).toEqual([main, side]); + expect(hero.contains(coll) || hero.contains(cast)).toBe(false); + expect(follows(hero, coll) && follows(coll, cast)).toBe(true); }); - it("with nothing for the main column, the sidebar's sections take the full width", () => { - wrap( - , - ); - expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull(); - const side = column(heading("plex.info.facts.title")); - expect(side).toBe(column(heading("plex.info.keywords"))); - expect(side.parentElement!.className).not.toContain(TWO_COLUMNS); - expect([...side.parentElement!.children]).toEqual([side]); + it("without facts or crew there is no second column; the keywords still sit under the text", () => { + const bare: PlexItemDetail = { + ...detail, + year: null, + duration_seconds: null, + content_rating: null, + studio: null, + tmdb: { + ...detail.tmdb!, + budget: null, + revenue: null, + studios: [], + production_countries: [], + crew: [], + }, + }; + wrap(); + expect(screen.queryByTestId("detail-aside")).toBeNull(); + expect(screen.queryByRole("heading", { name: "plex.info.facts.title" })).toBeNull(); + const kw = screen.getByTestId("detail-keywords"); + expect(document.querySelector(`[class*="${TWO_COLUMNS}"]`)).toBeNull(); + expect(follows(heading("Test Movie"), kw)).toBe(true); }); }); diff --git a/frontend/src/components/PlexInfo.tsx b/frontend/src/components/PlexInfo.tsx index c4f589aa..1895f1f6 100644 --- a/frontend/src/components/PlexInfo.tsx +++ b/frontend/src/components/PlexInfo.tsx @@ -1,14 +1,19 @@ -import { useState, type ReactNode } from "react"; +import { useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; import { useQueryClient } from "@tanstack/react-query"; import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react"; import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api"; -import { formatCompact, formatDecimal, formatMoney, formatRuntime } from "../lib/format"; +import { formatCompact, formatDecimal, formatRuntime } from "../lib/format"; import { notifyActionError } from "../lib/actionErrors"; import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi"; -import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail"; -import { usePref } from "../lib/usePref"; +import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; +import { + tmdbBackdropUrl, + useCrossfadeBackdrop, + useDetailAside, + useDetailPrefs, +} from "../lib/plexDetail"; import { Carousel } from "./ui/Carousel"; import PlexCollectionEditor from "./PlexCollectionEditor"; import PlexPlaylistAdd from "./PlexPlaylistAdd"; @@ -21,13 +26,12 @@ import { SURFACE } from "./ui/surfaces"; import { Badge } from "./ui/Badge"; import { IconLink } from "./ui/LinkButton"; import { PageHeading } from "./ui/Headings"; -import clsx from "clsx"; import { PlexPosterCard } from "./plexCards"; import { PlexMenu } from "./PlexMenu"; import { useContextMenu } from "./ui/useContextMenu"; import { usePlexCardActions } from "./usePlexCardActions"; import { toggleWatchState } from "../lib/plexWatch"; -import { regionName, vocab } from "../lib/vocab"; +import { vocab } from "../lib/vocab"; // The rich "media info" surface for a Plex item — poster/art, IMDb score + link, content rating, // genres, director, cast (with photos), summary. Reused in TWO places: a full-page view opened from @@ -143,174 +147,6 @@ function RatingsRow({ ); } -// A structured facts panel (E5 S5): release / runtime / cert / budget / revenue / studios / countries, -// pulling from the item's own fields + the persisted TMDB block. Rendered only when it has something. -// What filters the library is an edged chip, a plain fact stays flat text (E28 S3h, the S3d1 rule). -function FactsPanel({ - detail, - onFilter, -}: { - detail: PlexItemDetail; - onFilter?: (patch: Partial) => void; -}) { - const { t, i18n } = useTranslation(); - const lang = i18n.language; - const tmdb = detail.tmdb; - const studios = tmdb?.studios?.length ? tmdb.studios : detail.studio ? [detail.studio] : []; - const rows: { key: string; label: string; node: ReactNode }[] = []; - // The same values filter here as in the header above (year, certificate, studios). - const year = detail.year; - if (year != null) - rows.push({ - key: "year", - label: t("plex.info.facts.release"), - node: ( - onFilter({ yearMin: year, yearMax: year }) : undefined} - > - {year} - - ), - }); - if (detail.duration_seconds) - rows.push({ - key: "runtime", - label: t("plex.info.facts.runtime"), - node: formatRuntime(detail.duration_seconds, lang), - }); - const cert = detail.content_rating; - if (cert) - rows.push({ - key: "cert", - label: t("plex.info.facts.cert"), - node: ( - onFilter({ contentRatings: [cert] }) : undefined}> - {vocab(t, "certificate", cert)} - - ), - }); - if (tmdb?.budget) - rows.push({ - key: "budget", - label: t("plex.info.facts.budget"), - node: formatMoney(tmdb.budget, lang), - }); - if (tmdb?.revenue) - rows.push({ - key: "revenue", - label: t("plex.info.facts.revenue"), - node: formatMoney(tmdb.revenue, lang), - }); - if (studios.length) - rows.push({ - key: "studios", - label: t("plex.info.facts.studios"), - node: ( - - {studios.map((s) => ( - onFilter({ studios: [s] }) : undefined} - > - {s} - - ))} - - ), - }); - if (tmdb?.production_countries?.length) - rows.push({ - key: "countries", - label: t("plex.info.facts.countries"), - node: tmdb.production_countries - .map((c) => regionName(t, c.code, i18n.language, c.name)) - .join(", "), - }); - if (rows.length === 0) return null; - return ( -
-

{t("plex.info.facts.title")}

-
- {rows.map((r) => ( -
-
{r.label}
-
{r.node}
-
- ))} -
-
- ); -} - -// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword (via applyFilter). -function KeywordsPanel({ - keywords, - onFilter, -}: { - keywords: string[]; - onFilter?: (patch: Partial) => void; -}) { - const { t } = useTranslation(); - if (keywords.length === 0) return null; - return ( -
-

{t("plex.info.keywords")}

-
- {keywords.map((k) => ( - onFilter({ keywords: [k] }) : undefined} - > - {k} - - ))} -
-
- ); -} - -// Full crew (E5 S5) — the TMDB key-crew (Director / Writer / Producer / …), each clickable to open -// their person page. Complements the hero's Director line (which comes from Plex directly). -function CrewPanel({ - crew, - onOpenPerson, -}: { - crew: { name: string; job: string }[]; - onOpenPerson?: (name: string) => void; -}) { - const { t } = useTranslation(); - if (crew.length === 0) return null; - return ( -
-

{t("plex.info.crew")}

- {/* In the main column (layout B): as many name columns as its width holds. */} -
- {crew.map((c, i) => ( -
- {onOpenPerson ? ( - onOpenPerson(c.name)} - title={t("plex.info.viewPerson", { name: c.name })} - className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)" - > - {c.name} - - ) : ( -
{c.name}
- )} -
{vocab(t, "crewJob", c.job)}
-
- ))} -
-
- ); -} - // The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when // known. Class + colour are derived through the SAME shared helper the browse-card corner dot uses, // so the two surfaces can't drift. `overlay` tints the label for the dark player background. @@ -372,10 +208,8 @@ export default function PlexInfo({ // is PlexInfo-specific state, persisted through the same savePref. const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } = useDetailPrefs(); - // The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row. - const [showFacts, setShowFacts] = usePref("plexInfoFacts", true); - const [showKeywords, setShowKeywords] = usePref("plexInfoKeywords", true); - const [showCrew, setShowCrew] = usePref("plexInfoCrew", true); + // The TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle. + const aside = useDetailAside(detail); const prefs = (qc.getQueryData<{ preferences?: Record }>(qk.me())?.preferences ?? {}) as { plexHiddenStripSources?: string[] }; // Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled @@ -462,20 +296,7 @@ export default function PlexInfo({ const sourceLabel = (src: string) => t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() }); - // Whether each enriched side panel has anything to show — drives both its render and its toggle row - // (no toggle for a panel that would be empty anyway). - const tmdb = detail.tmdb; - const hasFacts = - detail.year != null || - !!detail.duration_seconds || - !!detail.content_rating || - !!tmdb?.budget || - !!tmdb?.revenue || - !!tmdb?.studios?.length || - !!detail.studio || - !!tmdb?.production_countries?.length; - const hasKeywords = (tmdb?.keywords?.length ?? 0) > 0; - const hasCrew = (tmdb?.crew?.length ?? 0) > 0; + const asideShown = !overlay && aside.column; const infoTitle = detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title; @@ -483,7 +304,7 @@ export default function PlexInfo({
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */} -
+
{/* Header: close (overlay) / customize */}
@@ -510,27 +331,7 @@ export default function PlexInfo({ hasCast={detail.cast.length > 0} extra={ <> - {hasFacts && ( - setShowFacts(!showFacts)} - /> - )} - {hasKeywords && ( - setShowKeywords(!showKeywords)} - /> - )} - {hasCrew && ( - setShowCrew(!showCrew)} - /> - )} + {!overlay && } {presentSources.map((src) => ( -
- {/* Poster */} - + {/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */} +
+
+ {/* Poster */} + -
- {/* Meta line + IMDb rating/link (each value can set the matching filter). */} -
- {detail.year != null && ( - onFilter({ yearMin: detail.year, yearMax: detail.year }) - : undefined - } - > - {detail.year} - - )} - {detail.content_rating && ( - onFilter({ contentRatings: [detail.content_rating!] }) - : undefined - } - > - {vocab(t, "certificate", detail.content_rating)} - - )} - {detail.duration_seconds ? ( - {formatRuntime(detail.duration_seconds, lang)} - ) : null} - {detail.studio && ( - onFilter({ studios: [detail.studio!] }) : undefined} - > - {detail.studio} - - )} - -
- - {/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */} -
- onFilter({ ratingMin: n }) : undefined} - onDark={overlay} - /> -
- - {detail.tagline && ( -

- {detail.tagline} -

- )} - - {detail.genres.length > 0 && ( -
- {detail.genres.map((g) => ( +
+ {/* Meta line + IMDb rating/link (each value can set the matching filter). */} +
+ {detail.year != null && ( onFilter({ genres: [g] }) : undefined} + title={ + onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined + } + onClick={ + onFilter + ? () => onFilter({ yearMin: detail.year, yearMax: detail.year }) + : undefined + } > - {g} + {detail.year} - ))} -
- )} - - {detail.directors.length > 0 && ( -

- {t("plex.info.director")}: - {detail.directors.map((d) => ( + )} + {detail.content_rating && ( onFilter({ directors: [d] }) : undefined} + onClick={ + onFilter + ? () => onFilter({ contentRatings: [detail.content_rating!] }) + : undefined + } > - {d} + {vocab(t, "certificate", detail.content_rating)} - ))} -

- )} - - {detail.summary && ( -

- {detail.summary} -

- )} - - {/* Page actions: Play/Resume + watch controls */} - {!overlay && ( -
- {onPlay && ( - )} - {detail.status === "watched" ? ( - - ) : ( - - )} - {inProgress && ( - - )} - {detail.kind === "movie" && ( - - )} - {isAdmin && detail.kind === "movie" && library && ( - + {detail.studio} + )} +
- )} + + {/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */} +
+ onFilter({ ratingMin: n }) : undefined} + onDark={overlay} + /> +
+ + {detail.tagline && ( +

+ {detail.tagline} +

+ )} + + {detail.genres.length > 0 && ( +
+ {detail.genres.map((g) => ( + onFilter({ genres: [g] }) : undefined} + > + {g} + + ))} +
+ )} + + {detail.directors.length > 0 && ( +

+ {t("plex.info.director")}: + {detail.directors.map((d) => ( + onFilter({ directors: [d] }) : undefined} + > + {d} + + ))} +

+ )} + + {detail.summary && ( +

+ {detail.summary} +

+ )} + + {/* Page actions: Play/Resume + watch controls */} + {!overlay && ( +
+ {onPlay && ( + + )} + {detail.status === "watched" ? ( + + ) : ( + + )} + {inProgress && ( + + )} + {detail.kind === "movie" && ( + + )} + {isAdmin && detail.kind === "movie" && library && ( + + )} +
+ )} + {!overlay && aside.shown.keywords && ( + + )} +
+ {asideShown && ( + + )}
{/* End hero panel. */} - {/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection - strips first (the owner's most used), then the crew, then the cast — beside a narrow - sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user - toggleable, so the layout is built from what is ON: a column with nothing in it is not - rendered and the other takes the full width; inside a column the sections stack, so a - hidden one leaves no hole. Narrow windows stack the sidebar under the main column. The - overlay (over the running player) stays lean: its strips and its cast, stacked. */} + {/* Below the hero: the collection strips first (the owner's most used), then the cast, each + per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that + sat beside them (E28 S3h) moved up into the hero's second column (E31). The overlay (over + the running player) is the same stack, lean. */} {(() => { const collections = (detail.collections ?? []).filter( (col) => !hiddenSources.includes(col.source), @@ -845,38 +667,11 @@ export default function PlexInfo({ {castRow} ); - const crewBox = showCrew && hasCrew && ( - - ); - const factsBox = showFacts && hasFacts && ( - - ); - const keywordsBox = showKeywords && hasKeywords && ( - - ); - const hasMain = strips.length > 0 || !!crewBox || !!castRow; - const hasSide = !!factsBox || !!keywordsBox; - if (!hasMain && !hasSide) return null; + if (strips.length === 0 && !castRow) return null; return ( -
- {hasMain && ( -
- {strips} - {crewBox} - {castRow} -
- )} - {hasSide && ( -
- {factsBox} - {keywordsBox} -
- )} +
+ {strips} + {castRow}
); })()} diff --git a/frontend/src/components/plexDetailAside.tsx b/frontend/src/components/plexDetailAside.tsx new file mode 100644 index 00000000..977dbc38 --- /dev/null +++ b/frontend/src/components/plexDetailAside.tsx @@ -0,0 +1,218 @@ +import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import type { PlexFilters } from "../lib/api"; +import { formatMoney, formatRuntime } from "../lib/format"; +import type { AsideSection, DetailAsideState, DetailFacts } from "../lib/plexDetail"; +import { regionName, vocab } from "../lib/vocab"; +import { Filterable, PrefToggle } from "./plexDetailUi"; +import { Pressable } from "./ui/Pressable"; + +// The detail pages' second column (E31, the owner's pick "A"): the TMDB facts and key crew of a movie or +// a show beside its description, inside the hero — as the person page sets its known-for rail beside +// the bio (E30 S6) — and its keywords under the text. They used to sit under the hero (movie) or +// nowhere at all (show), while the hero's text stopped at ~55% of the panel and left the rest empty. A +// narrow hero stacks the column under the text. Each section is a per-user toggle (default ON), the same +// three prefs on both pages (lib/plexDetail useDetailAside). + +type OnFilter = (patch: Partial) => void; + +// The customize menu's rows: one per section that has something (no toggle for an empty one). +export function DetailAsideToggles({ aside }: { aside: DetailAsideState }) { + const { t } = useTranslation(); + const label: Record = { + facts: t("plex.info.prefFacts"), + keywords: t("plex.info.prefKeywords"), + crew: t("plex.info.prefCrew"), + }; + return (["facts", "keywords", "crew"] as const) + .filter((k) => aside.has[k]) + .map((k) => ( + aside.toggle(k)} /> + )); +} + +// The hero's second column: the facts over the key crew — two lists, read down. Null when neither is +// shown. The keywords are chips that filter, as the genres are, so they sit under the text instead +// (DetailKeywords): all three in this column ran it to twice the text's height at 20 keywords and 12 +// crew, a hole under the text — the measured worst case (E31 S4c). +export function DetailAside({ + facts, + shown, + onFilter, + onOpenPerson, +}: { + facts: DetailFacts; + shown: DetailAsideState["shown"]; + onFilter?: OnFilter; + onOpenPerson?: (name: string) => void; +}) { + if (!shown.facts && !shown.crew) return null; + return ( +
+ {shown.facts && } + {shown.crew && } +
+ ); +} + +// The structured facts (E5 S5): release / runtime / cert / budget / revenue / studios / countries, from +// the title's own fields + the persisted TMDB block. What filters the library is an edged chip, a plain +// fact stays flat text (E28 S3h, the S3d1 rule). +function FactsSection({ facts, onFilter }: { facts: DetailFacts; onFilter?: OnFilter }) { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const tmdb = facts.tmdb; + const studios = tmdb?.studios?.length ? tmdb.studios : facts.studio ? [facts.studio] : []; + const rows: { key: string; label: string; node: ReactNode }[] = []; + // The same values filter here as in the hero's meta line (year, certificate, studios). + const year = facts.year; + if (year != null) + rows.push({ + key: "year", + label: t("plex.info.facts.release"), + node: ( + onFilter({ yearMin: year, yearMax: year }) : undefined} + > + {year} + + ), + }); + if (facts.duration_seconds) + rows.push({ + key: "runtime", + label: t("plex.info.facts.runtime"), + node: formatRuntime(facts.duration_seconds, lang), + }); + const cert = facts.content_rating; + if (cert) + rows.push({ + key: "cert", + label: t("plex.info.facts.cert"), + node: ( + onFilter({ contentRatings: [cert] }) : undefined}> + {vocab(t, "certificate", cert)} + + ), + }); + if (tmdb?.budget) + rows.push({ + key: "budget", + label: t("plex.info.facts.budget"), + node: formatMoney(tmdb.budget, lang), + }); + if (tmdb?.revenue) + rows.push({ + key: "revenue", + label: t("plex.info.facts.revenue"), + node: formatMoney(tmdb.revenue, lang), + }); + if (studios.length) + rows.push({ + key: "studios", + label: t("plex.info.facts.studios"), + node: ( + + {studios.map((s) => ( + onFilter({ studios: [s] }) : undefined} + > + {s} + + ))} + + ), + }); + if (tmdb?.production_countries?.length) + rows.push({ + key: "countries", + label: t("plex.info.facts.countries"), + node: tmdb.production_countries + .map((c) => regionName(t, c.code, i18n.language, c.name)) + .join(", "), + }); + if (rows.length === 0) return null; + return ( +
+

{t("plex.info.facts.title")}

+
+ {rows.map((r) => ( +
+
{r.label}
+
{r.node}
+
+ ))} +
+
+ ); +} + +// TMDB keyword chips (E5 S5) — tap one to filter the library by that keyword. Under the hero's text. +export function DetailKeywords({ + keywords, + onFilter, + className, +}: { + keywords: string[]; + onFilter?: OnFilter; + className?: string; +}) { + const { t } = useTranslation(); + return ( +
+

{t("plex.info.keywords")}

+
+ {keywords.map((k) => ( + onFilter({ keywords: [k] }) : undefined} + > + {k} + + ))} +
+
+ ); +} + +// The TMDB key crew (E5 S5) — Director / Writer / Producer / …, each opening their person page. It +// complements the hero's Director line (which comes from Plex directly). +function CrewSection({ + crew, + onOpenPerson, +}: { + crew: { name: string; job: string }[]; + onOpenPerson?: (name: string) => void; +}) { + const { t } = useTranslation(); + return ( +
+

{t("plex.info.crew")}

+ {/* As many name columns as the column holds: two in the hero's side column, one when narrow. */} +
+ {crew.map((c, i) => ( +
+ {onOpenPerson ? ( + onOpenPerson(c.name)} + title={t("plex.info.viewPerson", { name: c.name })} + className="max-w-full truncate text-sm leading-6 font-medium hover:text-accent pointer-coarse:leading-(--touch-target)" + > + {c.name} + + ) : ( +
{c.name}
+ )} +
{vocab(t, "crewJob", c.job)}
+
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/plexDetailUi.tsx b/frontend/src/components/plexDetailUi.tsx index 52e15bf2..bab037fc 100644 --- a/frontend/src/components/plexDetailUi.tsx +++ b/frontend/src/components/plexDetailUi.tsx @@ -230,7 +230,9 @@ export function ActionBtn({ /** The shared detail-page hero shell used by BOTH the show page and the season subpage (they were * copy-paste siblings). Owns the glass frame, the play/resume poster button, the customize-menu slot, * and the flex info column; the caller passes the customize menu + the title/metadata/actions as - * `children`. Poster is slightly larger on the show page (default) than the season page. */ + * `children`. Poster is slightly larger on the show page (default) than the season page. `aside` is the + * show page's second column (the facts and crew, plexDetailAside): beside the text in a wide hero, + * under it in a narrow one. */ export function DetailHero({ thumb, posterTitle, @@ -239,6 +241,7 @@ export function DetailHero({ onPosterClick, posterSizeClass = "w-32 sm:w-44", customizeMenu, + aside, children, }: { thumb: string; @@ -248,11 +251,11 @@ export function DetailHero({ onPosterClick: () => void; posterSizeClass?: string; customizeMenu: ReactNode; + aside?: ReactNode; children: ReactNode; }) { - return ( -
-
{customizeMenu}
+ const main = ( +
{children}
); + return ( +
+
{customizeMenu}
+ {aside ? ( +
+ {main} + {aside} +
+ ) : ( + main + )} +
+ ); } /** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both diff --git a/frontend/src/components/plexShow.aside.dom.test.tsx b/frontend/src/components/plexShow.aside.dom.test.tsx new file mode 100644 index 00000000..79fb4368 --- /dev/null +++ b/frontend/src/components/plexShow.aside.dom.test.tsx @@ -0,0 +1,105 @@ +// @vitest-environment jsdom +// +// E31 (the owner's pick "A"): TMDB enriches shows as it does movies — studios, countries, keywords, key +// crew — and the show page showed none of it, beside a hero whose text stopped at ~55% of the panel. Its +// hero now has the movie page's second column (facts over crew) and the keywords under the text. +import { describe, it, expect, afterEach, beforeAll, vi } from "vitest"; +import { render, screen, cleanup, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +beforeAll(() => { + class Stub { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver; +}); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }), + }; +}); + +const detail = { + show: { + id: "show", + title: "Fargo", + summary: "Various chronicles.", + year: 2014, + thumb: "", + art: "", + content_rating: "TV-MA", + studio: "26 Keys", + genres: ["Crime"], + cast: [], + status: "new", + episode_count: 0, + collection_keys: [], + tmdb: { + keywords: ["minnesota", "winter"], + crew: [{ name: "Noah Hawley", job: "Creator" }], + studios: ["26 Keys Productions", "MGM Television"], + production_countries: [{ code: "US", name: "United States" }], + recommendations: [], + backdrops: [], + }, + }, + seasons: [], + related: [], +}; + +vi.mock("../lib/api", () => ({ + api: { + plexShow: () => Promise.resolve(detail), + updatePrefs: () => Promise.resolve({}), + }, + EMPTY_PLEX_FILTERS: {}, + plexFilterCount: () => 0, +})); + +import { PlexShowView } from "./plexViews"; + +afterEach(cleanup); + +function renderShow(onFilter = vi.fn(), onOpenPerson = vi.fn()) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} + onPlay={() => {}} + onOpenSeason={() => {}} + onOpenShow={() => {}} + onFilter={onFilter} + onOpenPerson={onOpenPerson} + /> + , + ); + return { onFilter, onOpenPerson }; +} + +describe("the show page's facts, keywords and crew (E31)", () => { + it("shows the facts over the crew beside the text, and the keywords under it", async () => { + const { onFilter, onOpenPerson } = renderShow(); + const aside = await screen.findByTestId("detail-aside"); + expect(aside.textContent).toContain("MGM Television"); + expect(aside.textContent).toContain("Noah Hawley"); + expect(aside.parentElement!.className).toContain("@5xl:grid-cols-"); + const main = aside.parentElement!.children[0]!; + expect(main.contains(screen.getByTestId("detail-keywords"))).toBe(true); + expect(main.contains(screen.getByText("Various chronicles."))).toBe(true); + // They act as on the movie page: a keyword filters, a crew name opens the person. + fireEvent.click(screen.getByText("minnesota")); + expect(onFilter).toHaveBeenCalledWith({ keywords: ["minnesota"] }); + fireEvent.click(screen.getByText("Noah Hawley")); + expect(onOpenPerson).toHaveBeenCalledWith("Noah Hawley"); + }); +}); diff --git a/frontend/src/components/plexViews.tsx b/frontend/src/components/plexViews.tsx index 189aa715..14542f66 100644 --- a/frontend/src/components/plexViews.tsx +++ b/frontend/src/components/plexViews.tsx @@ -34,12 +34,14 @@ import { Filterable, WatchedToggle, } from "./plexDetailUi"; +import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside"; import { epLabel, seasonTitle, tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs, + useDetailAside, } from "../lib/plexDetail"; import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards"; import { PlexMenu } from "./PlexMenu"; @@ -449,6 +451,8 @@ export function PlexShowView({ toggleRelated, toggleCrossfade, } = useDetailPrefs(); + // The facts / keywords / crew (E31): TMDB enriches shows too, and the page showed none of it. + const aside = useDetailAside(show ?? {}); useCrossfadeBackdrop( [ show?.art, @@ -554,8 +558,19 @@ export function PlexShowView({ showRelated={showRelated} onToggleRelated={toggleRelated} hasRelated={d.related.length > 0} + extra={} /> } + aside={ + aside.column && ( + + ) + } > {show.title} {/* The same meta row as the media-info page: filtering values are chips, plain facts flat @@ -677,6 +692,13 @@ export function PlexShowView({ /> )}
+ {aside.shown.keywords && ( + + )} {/* Seasons — in a glassy panel like the movie info-page strips. */} diff --git a/frontend/src/components/ui/scrollFold.test.ts b/frontend/src/components/ui/scrollFold.test.ts new file mode 100644 index 00000000..24c83e6e --- /dev/null +++ b/frontend/src/components/ui/scrollFold.test.ts @@ -0,0 +1,24 @@ +// E31 (the owner's pick): the channel page's tall header folds to one row once the grid scrolls. The header +// is fixed, so folding gives the scroller its height back — these are the rules that keep that from +// flickering on a page barely taller than the window. +import { describe, expect, it } from "vitest"; +import { FOLD_AT, nextFolded } from "./scrollFold"; + +const SAVING = 200; + +describe("nextFolded", () => { + it("folds once the content scrolls past the fold point", () => { + expect(nextFolded(false, { top: FOLD_AT + 1, room: 5000 }, SAVING)).toBe(true); + expect(nextFolded(false, { top: FOLD_AT, room: 5000 }, SAVING)).toBe(false); + }); + + it("does not fold a page that could not stay scrolled once the header gave its height back", () => { + expect(nextFolded(false, { top: 100, room: SAVING + FOLD_AT }, SAVING)).toBe(false); + expect(nextFolded(false, { top: 100, room: SAVING + FOLD_AT + 1 }, SAVING)).toBe(true); + }); + + it("stays folded until the very top, not back at the fold point", () => { + expect(nextFolded(true, { top: 1, room: 5000 }, SAVING)).toBe(true); + expect(nextFolded(true, { top: 0, room: 5000 }, SAVING)).toBe(false); + }); +}); diff --git a/frontend/src/components/ui/scrollFold.ts b/frontend/src/components/ui/scrollFold.ts new file mode 100644 index 00000000..7a0aee54 --- /dev/null +++ b/frontend/src/components/ui/scrollFold.ts @@ -0,0 +1,60 @@ +import { useEffect, useRef, useState, type RefObject } from "react"; +import { CONTENT_ID } from "./landmarks"; + +// A tall page header that folds to one row once the page's content scrolls (E31, the owner's pick for the +// channel page: the banner and the identity block took ~40% of a 133% screen before the first video). +// +// The header is FIXED (it sits in the shell's toolbar band, outside the scroller), so folding it makes the +// scroller taller, which shortens how far the content can scroll. Two rules keep that from flickering: +// - fold only when the page can still scroll past the fold point once the header has given its height +// back (`room - saving > FOLD_AT`): a page barely taller than the window would otherwise fold, lose its +// scroll, land at the top, unfold, and so on, with every wheel tick; +// - unfold only at the very top, not at the fold point: between the two the header keeps whatever it was. +export const FOLD_AT = 32; + +export interface ScrollPosition { + // The scroller's scrollTop. + top: number; + // How far it can scroll at all: scrollHeight − clientHeight. + room: number; +} + +// `saving` = the height the header gives back when it folds (measured while it is unfolded). +export function nextFolded( + folded: boolean, + { top, room }: ScrollPosition, + saving: number, +): boolean { + if (folded) return top > 0; + return top > FOLD_AT && room - saving > FOLD_AT; +} + +// Whether the header is folded, following the page's one scroller (PageScroller's
). `foldable` is +// the part of the header that folds away; its height, read while it is shown, is the `saving` above. +export function useScrollFold(foldable: RefObject): boolean { + const [folded, setFolded] = useState(false); + const saving = useRef(0); + useEffect(() => { + const update = (el: HTMLElement) => { + if (foldable.current) saving.current = foldable.current.offsetHeight; + setFolded((was) => + nextFolded( + was, + { top: el.scrollTop, room: el.scrollHeight - el.clientHeight }, + saving.current, + ), + ); + }; + // Scroll events do not bubble but they do capture, so one document listener sees the scroller even + // when it mounts after this page (as BackToTop does). A Back that restores the position scrolls it too. + const onScroll = (e: Event) => { + const el = e.target; + if (el instanceof HTMLElement && el.id === CONTENT_ID) update(el); + }; + document.addEventListener("scroll", onScroll, { passive: true, capture: true }); + const main = document.getElementById(CONTENT_ID); + if (main) update(main); + return () => document.removeEventListener("scroll", onScroll, { capture: true }); + }, [foldable]); + return folded; +} diff --git a/frontend/src/lib/playerModalHooks.ts b/frontend/src/lib/playerModalHooks.ts index 164b7ccf..b0b15f88 100644 --- a/frontend/src/lib/playerModalHooks.ts +++ b/frontend/src/lib/playerModalHooks.ts @@ -60,7 +60,8 @@ function loadYouTubeApi(): Promise { /** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created * 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. */ + * 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. */ export function useYtVolume({ playerRef, dialogRef, @@ -137,6 +138,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 (E31). + const panel = + e.target instanceof Element ? e.target.closest("[data-wheel-scrolls]") : null; + if ( + panel && + (e.deltaY < 0 + ? panel.scrollTop > 0 + : panel.scrollTop + panel.clientHeight < panel.scrollHeight - 1) // -1: sub-pixel heights + ) + return; e.preventDefault(); focusModal(); // re-grab focus if it had slipped into the native controls nudgeVolume(e.deltaY < 0 ? 5 : -5); diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index c4d2e692..c9a894d9 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -1,7 +1,7 @@ import { useLayoutEffect } from "react"; import { usePref, useSavePref } from "./usePref"; import type { TFunction } from "i18next"; -import type { PlexCard } from "./api"; +import type { PlexCard, PlexTmdb } from "./api"; // The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display // prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so @@ -31,6 +31,56 @@ export function useDetailPrefs() { }; } +// What a detail page's facts are read from: a movie's item detail and a show's detail both carry these. +export interface DetailFacts { + year?: number | null; + duration_seconds?: number | null; + content_rating?: string | null; + studio?: string | null; + tmdb?: PlexTmdb | null; +} + +export type AsideSection = "facts" | "keywords" | "crew"; + +// The detail pages' second column (plexDetailAside, E31): which of its three sections — the TMDB facts, +// keywords and key crew — have something to show, which the reader keeps on (three per-user prefs, +// default ON, the same on the movie and the show page), and the toggles' flips. +export function useDetailAside(f: DetailFacts) { + const [facts, setFacts] = usePref("plexInfoFacts", true); + const [keywords, setKeywords] = usePref("plexInfoKeywords", true); + const [crew, setCrew] = usePref("plexInfoCrew", true); + const has: Record = { + facts: + f.year != null || + !!f.duration_seconds || + !!f.content_rating || + !!f.tmdb?.budget || + !!f.tmdb?.revenue || + !!f.tmdb?.studios?.length || + !!f.studio || + !!f.tmdb?.production_countries?.length, + keywords: (f.tmdb?.keywords?.length ?? 0) > 0, + crew: (f.tmdb?.crew?.length ?? 0) > 0, + }; + const on: Record = { facts, keywords, crew }; + const shown: Record = { + facts: facts && has.facts, + keywords: keywords && has.keywords, + crew: crew && has.crew, + }; + const set = { facts: setFacts, keywords: setKeywords, crew: setCrew }; + return { + has, + on, + shown, + // Whether the hero's second column has anything (the keywords sit under the text). + column: shown.facts || shown.crew, + toggle: (k: AsideSection) => set[k](!on[k]), + }; +} + +export type DetailAsideState = ReturnType; + // A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only // frame-ancestors is set), so we render it straight from there — a rich multi-image pool for the // crossfade even on a lone movie with no collection.