feat(ui): E31 S4c — the channel header folds, the Plex pages fill their hero, the player gets a side panel
- Channel page: the banner and identity block fold away once the grid scrolls (ui/scrollFold: folds past 32px only if the page can stay scrolled, unfolds at the very top) and the name rides in the tab row. One Back button in both states keeps its focus; folded at a tablet width the actions wrap instead of pushing the page sideways. - Plex movie and show pages: the TMDB facts over the key crew are the hero's second column (plexDetailAside), the keywords sit under the text; the show page shows them for the first time, with the same three toggles. A collection's hero is sized to its text. - YouTube player, 1280px and wider: the title, channel, actions, description and queue sit in a side panel beside the video, which takes the window's height (1438x809 at 1920x911, was ~1008 wide). The wheel scrolls that panel while it can and turns the volume elsewhere. - DESIGN.md decision log: the keywords' place under the text.
This commit is contained in:
@@ -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 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 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-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 |
|
||||||
|
|||||||
@@ -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<typeof import("react-i18next")>()),
|
||||||
|
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<typeof import("./ui/PageShell")>()),
|
||||||
|
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 <main>), 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(
|
||||||
|
<QueryClientProvider
|
||||||
|
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||||
|
>
|
||||||
|
<ChannelPage
|
||||||
|
channelId={CH}
|
||||||
|
initialName="StarTalk"
|
||||||
|
me={{ id: 1, can_write: true } as unknown as Me}
|
||||||
|
view="cards"
|
||||||
|
setView={() => {}}
|
||||||
|
onShowInFeed={() => {}}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "./ui/Avatar";
|
||||||
import Tabs from "./ui/Tabs";
|
import Tabs from "./ui/Tabs";
|
||||||
import { useTabPanel } from "./ui/tabPanel";
|
import { useTabPanel } from "./ui/tabPanel";
|
||||||
|
import { useScrollFold } from "./ui/scrollFold";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
import ChannelAboutContent from "./ChannelAbout";
|
import ChannelAboutContent from "./ChannelAbout";
|
||||||
import { PageToolbar } from "./ui/PageShell";
|
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
|
// 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.
|
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
|
||||||
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
|
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(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<HTMLDivElement>(null);
|
||||||
|
const folded = useScrollFold(foldable);
|
||||||
// The banner comes from Google's image CDN, which refuses any request carrying a Referer from our
|
// 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 <img> sends none
|
// origin — every time, not only in a burst (measured 2026-09-26) — so the <img> sends none
|
||||||
// (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss,
|
// (`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,
|
: null,
|
||||||
joined ? t("channel.joined", { date: joined }) : null,
|
joined ? t("channel.joined", { date: joined }) : null,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
// The page's name and its state, shown in the identity block or, folded, in the tab row.
|
||||||
|
const heading = (
|
||||||
|
<PageHeading
|
||||||
|
data-testid="channel-title"
|
||||||
|
className={`${folded ? "text-base" : "text-xl"} font-semibold truncate`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</PageHeading>
|
||||||
|
);
|
||||||
|
const stateBadge = ch?.blocked ? (
|
||||||
|
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||||
|
{t("channel.blockedBadge")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
ch?.explored &&
|
||||||
|
!ch?.subscribed && (
|
||||||
|
<span className="shrink-0 text-xs px-2 py-0.5 rounded-full bg-warn/15 text-warn">
|
||||||
|
{t("channel.exploringBadge")}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
);
|
||||||
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
||||||
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
|
// whole chrome is fixed — wrapped in <PageToolbar> 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
|
const bannerSrc = ch?.banner_url
|
||||||
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
|
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
|
||||||
: null;
|
: null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
<div className="relative">
|
||||||
<div className="relative px-4 sm:px-6 pt-3">
|
{!folded && (
|
||||||
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
<div ref={foldable}>
|
||||||
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
{/* Banner — OPTION C: inset, rounded card (not full-bleed) */}
|
||||||
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
<div className="px-4 sm:px-6 pt-3">
|
||||||
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||||
<div
|
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
||||||
className="w-full overflow-hidden rounded-2xl bg-surface"
|
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
||||||
>
|
<div
|
||||||
<img
|
className="w-full overflow-hidden rounded-2xl bg-surface"
|
||||||
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
|
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||||
// browser's negative cache (googleusercontent accepts an extra query param).
|
>
|
||||||
key={bannerAttempt}
|
<img
|
||||||
src={bannerSrc}
|
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
|
||||||
referrerPolicy="no-referrer"
|
// browser's negative cache (googleusercontent accepts an extra query param).
|
||||||
alt=""
|
key={bannerAttempt}
|
||||||
className={`w-full h-full object-cover object-center ${
|
src={bannerSrc}
|
||||||
bannerLoaded === bannerSrc ? "" : "opacity-0"
|
referrerPolicy="no-referrer"
|
||||||
}`}
|
alt=""
|
||||||
onLoad={() => setBannerLoaded(bannerSrc)}
|
className={`w-full h-full object-cover object-center ${
|
||||||
onError={() => {
|
bannerLoaded === bannerSrc ? "" : "opacity-0"
|
||||||
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
}`}
|
||||||
window.setTimeout(
|
onLoad={() => setBannerLoaded(bannerSrc)}
|
||||||
() => setBannerAttempt((a) => a + 1),
|
onError={() => {
|
||||||
500 * (bannerAttempt + 1),
|
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
||||||
);
|
window.setTimeout(
|
||||||
}
|
() => setBannerAttempt((a) => a + 1),
|
||||||
}}
|
500 * (bannerAttempt + 1),
|
||||||
/>
|
);
|
||||||
</div>
|
}
|
||||||
) : (
|
}}
|
||||||
// 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
|
</div>
|
||||||
// let the -mt-8 avatar ride up into the Back button).
|
|
||||||
<div
|
|
||||||
className="w-full rounded-2xl bg-surface"
|
|
||||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="media"
|
|
||||||
data-testid="channel-back"
|
|
||||||
icon={<ArrowLeft />}
|
|
||||||
onClick={closeChannel}
|
|
||||||
className="absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"
|
|
||||||
>
|
|
||||||
{t("channel.back")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="px-4 sm:px-6">
|
|
||||||
{/* 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). */}
|
|
||||||
<div className="relative z-base flex items-end gap-4 -mt-8">
|
|
||||||
<Avatar
|
|
||||||
src={ch?.thumbnail_url ?? null}
|
|
||||||
fallback={name}
|
|
||||||
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
|
|
||||||
/>
|
|
||||||
<div className="flex-1 min-w-0 pb-1">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<PageHeading data-testid="channel-title" className="text-xl font-semibold truncate">
|
|
||||||
{name}
|
|
||||||
</PageHeading>
|
|
||||||
{ch?.blocked ? (
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
|
||||||
{t("channel.blockedBadge")}
|
|
||||||
</span>
|
|
||||||
) : (
|
) : (
|
||||||
ch?.explored &&
|
// No banner (or a dead one): a neutral bar the SAME height as a real banner, so the
|
||||||
!ch?.subscribed && (
|
// overlapping avatar + the Back button have the same room and don't collide (a short bar
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-warn/15 text-warn">
|
// let the -mt-8 avatar ride up into the Back button).
|
||||||
{t("channel.exploringBadge")}
|
<div
|
||||||
</span>
|
className="w-full rounded-2xl bg-surface"
|
||||||
)
|
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
|
||||||
{metaParts.map((part, i) => (
|
|
||||||
<span key={i}>
|
|
||||||
{i > 0 && <span className="mr-1.5">·</span>}
|
|
||||||
{part}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{exploring && (
|
<div className="px-4 sm:px-6">
|
||||||
<div className="flex items-center gap-2 text-xs text-muted mt-2">
|
{/* Identity. relative+z-base so the avatar that overlaps up into the banner paints
|
||||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
ABOVE it (the banner's positioned container would otherwise cover it). */}
|
||||||
{t("channel.loadingVideos")}
|
<div className="relative z-base flex items-end gap-4 -mt-8">
|
||||||
|
<Avatar
|
||||||
|
src={ch?.thumbnail_url ?? null}
|
||||||
|
fallback={name}
|
||||||
|
className="w-20 h-20 rounded-full border-4 border-bg shrink-0 bg-bg"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 pb-1">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{heading}
|
||||||
|
{stateBadge}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted mt-0.5 flex flex-wrap gap-x-1.5 gap-y-0.5">
|
||||||
|
{metaParts.map((part, i) => (
|
||||||
|
<span key={i}>
|
||||||
|
{i > 0 && <span className="mr-1.5">·</span>}
|
||||||
|
{part}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Tabs — the channel actions ride along here rather than off in the header's top-right
|
<div className="px-4 sm:px-6">
|
||||||
|
{exploring && (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted mt-2">
|
||||||
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||||
|
{t("channel.loadingVideos")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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
|
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. */}
|
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". */}
|
the whole page scrolled sideways (E31 S1). The tag lane takes what is left and folds into "+N". */}
|
||||||
<div className="flex flex-wrap items-center gap-2 mt-3 border-b border-border pb-2">
|
<div
|
||||||
<Tabs
|
className={`flex flex-wrap items-center gap-2 border-b border-border pb-2 ${folded ? "pt-3" : "mt-3"}`}
|
||||||
idBase={panel.idBase}
|
>
|
||||||
className="flex gap-1"
|
{/* One Back button in both states, so a focused Back keeps its focus when the header folds:
|
||||||
active={tab}
|
over the banner's corner (absolute to the chrome) while unfolded, first in this row folded. */}
|
||||||
onChange={(id) => setTab(id as "videos" | "about")}
|
<Button
|
||||||
tabs={[
|
variant={folded ? "outline" : "media"}
|
||||||
{ id: "videos", label: t("channel.tabVideos") },
|
data-testid="channel-back"
|
||||||
{ id: "about", label: t("channel.tabAbout") },
|
icon={<ArrowLeft />}
|
||||||
]}
|
onClick={closeChannel}
|
||||||
/>
|
className={folded ? "" : "absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"}
|
||||||
<div className="ml-2 flex min-w-0 flex-1 items-center gap-2">
|
|
||||||
<IconLink
|
|
||||||
href={ytUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
label={t("channels.row.openOnYouTube")}
|
|
||||||
size="md"
|
|
||||||
variant="outline"
|
|
||||||
>
|
>
|
||||||
<ExternalLink />
|
{t("channel.back")}
|
||||||
</IconLink>
|
</Button>
|
||||||
{ch?.subscribed && (
|
{folded && (
|
||||||
<IconButton
|
<div
|
||||||
label={t("channel.showInFeed")}
|
className="flex min-w-0 max-w-[40%] items-center gap-2"
|
||||||
size="md"
|
data-testid="channel-folded"
|
||||||
variant="outline"
|
|
||||||
onClick={onShowInFeed}
|
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="w-4 h-4" />
|
<Avatar
|
||||||
</IconButton>
|
src={ch?.thumbnail_url ?? null}
|
||||||
)}
|
fallback={name}
|
||||||
{ch?.subscribed && !me.is_demo && (
|
className="w-8 h-8 rounded-full shrink-0"
|
||||||
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
|
|
||||||
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
|
|
||||||
<PriorityStepper
|
|
||||||
priority={ch.priority}
|
|
||||||
onPriority={(d) => bumpPriority(channelId, d)}
|
|
||||||
onSet={(v) => setPriority(channelId, v)}
|
|
||||||
size="md"
|
|
||||||
hint={t("channels.row.priorityHint")}
|
|
||||||
/>
|
/>
|
||||||
|
{heading}
|
||||||
|
{stateBadge}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
|
<Tabs
|
||||||
|
idBase={panel.idBase}
|
||||||
|
className="flex gap-1"
|
||||||
|
active={tab}
|
||||||
|
onChange={(id) => setTab(id as "videos" | "about")}
|
||||||
|
tabs={[
|
||||||
|
{ id: "videos", label: t("channel.tabVideos") },
|
||||||
|
{ id: "about", label: t("channel.tabAbout") },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{/* Unfolded, the actions share the tabs' line and the tag lane gives way ("+N"). Folded, Back
|
||||||
|
and the name take that room, so at a tablet width the actions wrap onto a line of their
|
||||||
|
own rather than push the page sideways (min-w-0 let them overflow: 1072 px at 1024). */}
|
||||||
|
<div className={`ml-2 flex flex-1 items-center gap-2 ${folded ? "" : "min-w-0"}`}>
|
||||||
|
<IconLink
|
||||||
|
href={ytUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
label={t("channels.row.openOnYouTube")}
|
||||||
|
size="md"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
<ExternalLink />
|
||||||
|
</IconLink>
|
||||||
|
{ch?.subscribed && (
|
||||||
|
<IconButton
|
||||||
|
label={t("channel.showInFeed")}
|
||||||
|
size="md"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onShowInFeed}
|
||||||
|
>
|
||||||
|
<SlidersHorizontal className="w-4 h-4" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{ch?.subscribed && !me.is_demo && (
|
||||||
|
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
|
||||||
|
<span className="text-xs text-muted">{t("channels.cols.priority")}</span>
|
||||||
|
<PriorityStepper
|
||||||
|
priority={ch.priority}
|
||||||
|
onPriority={(d) => bumpPriority(channelId, d)}
|
||||||
|
onSet={(v) => setPriority(channelId, v)}
|
||||||
|
size="md"
|
||||||
|
hint={t("channels.row.priorityHint")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{/* Only the recent subset is stored → offer to pull the channel's full back-catalogue.
|
||||||
Once queued (deep_requested) it shows as loading; hidden once the backfill is done.
|
Once queued (deep_requested) it shows as loading; hidden once the backfill is done.
|
||||||
While loading, the label carries the live stored/total count (the detail poll refreshes
|
While loading, the label carries the live stored/total count (the detail poll refreshes
|
||||||
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
it) so a large channel's paced tail reads as visible progress rather than a stuck
|
||||||
spinner. */}
|
spinner. */}
|
||||||
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
{ch?.subscribed && !ch.backfill_done && !me.is_demo && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
onClick={() => !ch.deep_requested && deepBackfill.mutate()}
|
||||||
disabled={ch.deep_requested || deepBackfill.isPending}
|
disabled={ch.deep_requested || deepBackfill.isPending}
|
||||||
loading={ch.deep_requested}
|
loading={ch.deep_requested}
|
||||||
icon={<History className="w-4 h-4" />}
|
icon={<History className="w-4 h-4" />}
|
||||||
title={
|
title={
|
||||||
ch.deep_requested
|
ch.deep_requested
|
||||||
? t("channel.fullHistoryLoadingHint")
|
? t("channel.fullHistoryLoadingHint")
|
||||||
: t("channel.fullHistory")
|
: t("channel.fullHistory")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline whitespace-nowrap">
|
<span className="hidden sm:inline whitespace-nowrap">
|
||||||
{!ch.deep_requested
|
{!ch.deep_requested
|
||||||
? t("channel.fullHistory")
|
? t("channel.fullHistory")
|
||||||
: ch.video_count != null && ch.stored_videos < ch.video_count
|
: ch.video_count != null && ch.stored_videos < ch.video_count
|
||||||
? t("channel.fullHistoryProgress", {
|
? t("channel.fullHistoryProgress", {
|
||||||
stored: ch.stored_videos,
|
stored: ch.stored_videos,
|
||||||
total: ch.video_count,
|
total: ch.video_count,
|
||||||
})
|
})
|
||||||
: t("channel.fullHistoryLoading")}
|
: t("channel.fullHistoryLoading")}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!me.is_demo && (
|
{!me.is_demo && (
|
||||||
<IconButton
|
<IconButton
|
||||||
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
||||||
// light up on hover, as if a click would do something.
|
// light up on hover, as if a click would do something.
|
||||||
size="md"
|
size="md"
|
||||||
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
|
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
|
||||||
onClick={() => block.mutate()}
|
onClick={() => block.mutate()}
|
||||||
loading={block.isPending}
|
loading={block.isPending}
|
||||||
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||||
>
|
>
|
||||||
<Ban />
|
<Ban />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
|
{/* 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
|
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
|
||||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||||
{ch?.subscribed && !me.is_demo && (
|
{ch?.subscribed && !me.is_demo && (
|
||||||
<IconButton
|
<IconButton
|
||||||
size="md"
|
size="md"
|
||||||
variant={ch.hidden ? "outline-accent" : "outline"}
|
variant={ch.hidden ? "outline-accent" : "outline"}
|
||||||
onClick={() => toggleHidden.mutate()}
|
onClick={() => toggleHidden.mutate()}
|
||||||
loading={toggleHidden.isPending}
|
loading={toggleHidden.isPending}
|
||||||
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||||
>
|
|
||||||
{ch.hidden ? <EyeOff /> : <Eye />}
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
{!me.is_demo &&
|
|
||||||
!ch?.blocked &&
|
|
||||||
(ch?.subscribed ? (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={onUnsubscribe}
|
|
||||||
loading={unsubscribe.isPending}
|
|
||||||
title={t("channel.unsubscribe")}
|
|
||||||
icon={<Check className="w-4 h-4" />}
|
|
||||||
>
|
>
|
||||||
{t("channel.subscribedState")}
|
{ch.hidden ? <EyeOff /> : <Eye />}
|
||||||
</Button>
|
</IconButton>
|
||||||
) : (
|
)}
|
||||||
<Button
|
{!me.is_demo &&
|
||||||
variant="outline"
|
!ch?.blocked &&
|
||||||
onClick={() => subscribe.mutate()}
|
(ch?.subscribed ? (
|
||||||
loading={subscribe.isPending}
|
<Button
|
||||||
icon={<Plus className="w-4 h-4" />}
|
variant="outline"
|
||||||
>
|
onClick={onUnsubscribe}
|
||||||
{t("channel.subscribe")}
|
loading={unsubscribe.isPending}
|
||||||
</Button>
|
title={t("channel.unsubscribe")}
|
||||||
))}
|
icon={<Check className="w-4 h-4" />}
|
||||||
{/* Tagging is a subscription-scoped action (attach_tag → _user_subscription 404s
|
>
|
||||||
|
{t("channel.subscribedState")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => subscribe.mutate()}
|
||||||
|
loading={subscribe.isPending}
|
||||||
|
icon={<Plus className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t("channel.subscribe")}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{/* 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
|
otherwise), and personal tags are for your subscriptions — so only offer it on a
|
||||||
channel you actually follow. */}
|
channel you actually follow. */}
|
||||||
{ch?.subscribed && !me.is_demo && (
|
{ch?.subscribed && !me.is_demo && (
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<ChannelTagsControl
|
<ChannelTagsControl
|
||||||
userTags={userTags}
|
userTags={userTags}
|
||||||
attachedIds={ch.tag_ids}
|
attachedIds={ch.tag_ids}
|
||||||
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
|
onToggle={(tagId) => toggleTag(channelId, tagId, ch.tag_ids.includes(tagId))}
|
||||||
onCreate={(name) => createAndAttachTag(channelId, name)}
|
onCreate={(name) => createAndAttachTag(channelId, name)}
|
||||||
// The header row's leftover width: tags inline, "+N" only when they do not fit.
|
// The header row's leftover width: tags inline, "+N" only when they do not fit.
|
||||||
bounded={false}
|
bounded={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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))");
|
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<ReturnType<typeof api.videoDetail>>);
|
||||||
|
const A = vid("A", 0);
|
||||||
|
const B = vid("B", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A, B]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
const stage = document.querySelector<HTMLElement>(".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<ReturnType<typeof api.videoDetail>>);
|
||||||
|
const A = vid("A", 0);
|
||||||
|
renderModal(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} 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(
|
||||||
|
<PlayerModal video={A} queue={[A]} startIndex={0} onClose={() => {}} onState={() => {}} />,
|
||||||
|
);
|
||||||
|
const card = document.querySelector<HTMLElement>(".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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ import { Pressable } from "./ui/Pressable";
|
|||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./ui/surfaces";
|
||||||
import { IconLink, LinkButton } from "./ui/LinkButton";
|
import { IconLink, LinkButton } from "./ui/LinkButton";
|
||||||
import { SectionLabel } from "./ui/Headings";
|
import { SectionLabel } from "./ui/Headings";
|
||||||
|
import { useMediaQuery } from "../lib/useMediaQuery";
|
||||||
|
|
||||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
// 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
|
// 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;
|
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 = (
|
||||||
|
<span aria-hidden="true" data-meta-sep="">
|
||||||
|
·{" "}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
const META_OWN_LINE = "w-full [&>span:first-child>[data-meta-sep]]:hidden";
|
||||||
|
|
||||||
export default function PlayerModal({
|
export default function PlayerModal({
|
||||||
video,
|
video,
|
||||||
startAt,
|
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
|
// 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.
|
// height, measured (it wraps with the title and the text size), and caps the card's width below.
|
||||||
const [below, setBelow] = useState(0);
|
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(() => {
|
useLayoutEffect(() => {
|
||||||
const card = cardRef.current;
|
const card = cardRef.current;
|
||||||
const stage = stageRef.current;
|
const stage = stageRef.current;
|
||||||
@@ -450,7 +469,8 @@ export default function PlayerModal({
|
|||||||
queryFn: () => api.videoDetail(currentVideoId),
|
queryFn: () => api.videoDetail(currentVideoId),
|
||||||
// On opening the description, and eagerly when navigated, so we can link the
|
// On opening the description, and eagerly when navigated, so we can link the
|
||||||
// linked video's channel. Both share the one cached result.
|
// 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,
|
staleTime: 5 * 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -609,10 +629,13 @@ export default function PlayerModal({
|
|||||||
className={`relative flex w-full max-h-full ${hasQueue ? "sm:px-14" : ""}`}
|
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
|
// 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).
|
// 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={{
|
style={{
|
||||||
maxWidth: `min(${hasQueue ? "63rem" : "56rem"}, calc((100dvh - 3rem - ${below}px) * 16 / 9${
|
maxWidth: wide
|
||||||
hasQueue ? " + 7rem" : ""
|
? `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 && (
|
{hasQueue && (
|
||||||
@@ -632,14 +655,16 @@ export default function PlayerModal({
|
|||||||
<div
|
<div
|
||||||
ref={cardRef}
|
ref={cardRef}
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full overflow-y-auto outline-hidden`}
|
className={`${SURFACE.panel} relative flex-1 min-w-0 max-h-full outline-hidden ${
|
||||||
|
wide ? "flex overflow-hidden" : "overflow-y-auto"
|
||||||
|
}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={stageRef}
|
ref={stageRef}
|
||||||
className={`player-stage relative aspect-video w-full bg-black rounded-t-2xl overflow-hidden ${
|
className={`player-stage relative aspect-video w-full bg-black overflow-hidden ${
|
||||||
maximized ? "player-stage--max" : ""
|
wide ? "flex-1 min-w-0 self-start rounded-l-2xl" : "rounded-t-2xl"
|
||||||
}`}
|
} ${maximized ? "player-stage--max" : ""}`}
|
||||||
>
|
>
|
||||||
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
{/* Hide the iframe entirely on error so YouTube's own error screen can't bleed
|
||||||
through our overlay. */}
|
through our overlay. */}
|
||||||
@@ -798,234 +823,284 @@ export default function PlayerModal({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{hasQueue && (
|
{/* Wide: the side panel. Its content scrolls inside a box the video's height, so a long
|
||||||
<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">
|
description never makes the card taller than the video. */}
|
||||||
<PlayerTransportControls
|
<div
|
||||||
variant="modal"
|
className={wide ? "relative shrink-0 border-l border-border" : undefined}
|
||||||
index={index}
|
style={wide ? { width: SIDE_PANEL } : undefined}
|
||||||
queueLength={queue!.length}
|
>
|
||||||
autoMode={autoMode}
|
<div
|
||||||
loopMode={loopMode}
|
className={wide ? "absolute inset-0 overflow-y-auto" : undefined}
|
||||||
onPrev={goPrev}
|
data-testid="player-info"
|
||||||
onNext={goNext}
|
// The wheel scrolls this panel while it can (lib/playerModalHooks), the volume elsewhere.
|
||||||
onCycleAuto={cycleAuto}
|
data-wheel-scrolls={wide ? "" : undefined}
|
||||||
onCycleLoop={cycleLoop}
|
>
|
||||||
/>
|
{hasQueue && (
|
||||||
</div>
|
<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"
|
||||||
<div className="p-4 sm:p-5">
|
index={index}
|
||||||
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
|
queueLength={queue!.length}
|
||||||
<div className="flex items-start gap-3">
|
autoMode={autoMode}
|
||||||
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
loopMode={loopMode}
|
||||||
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
onPrev={goPrev}
|
||||||
</h2>
|
onNext={goNext}
|
||||||
<IconButton
|
onCycleAuto={cycleAuto}
|
||||||
ref={descPop.refs.setReference}
|
onCycleLoop={cycleLoop}
|
||||||
{...descPop.getReferenceProps()}
|
/>
|
||||||
variant="ghost"
|
</div>
|
||||||
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>
|
|
||||||
{detail.isLoading ? (
|
|
||||||
<div className="text-sm text-muted">{t("player.loading")}</div>
|
|
||||||
) : detail.data?.description ? (
|
|
||||||
<div
|
|
||||||
ref={descFade.ref}
|
|
||||||
style={descFade.style}
|
|
||||||
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
|
||||||
>
|
|
||||||
{renderDescription(detail.data.description, {
|
|
||||||
currentId: currentVideoId,
|
|
||||||
onSeek: seekTo,
|
|
||||||
onLoadVideo: loadVideo,
|
|
||||||
t,
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
|
||||||
)}
|
|
||||||
</PopoverSurface>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
data-testid="player-close"
|
|
||||||
onClick={onClose}
|
|
||||||
title={t("player.closeEsc")}
|
|
||||||
>
|
|
||||||
{t("player.close")}
|
|
||||||
<X />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Channel + meta on one line, with the watched toggle pushed to the right.
|
<div className="p-4 sm:p-5">
|
||||||
|
{/* Title row — the title and its ⓘ (the description) on the left, Close on the right. */}
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<h2 id={titleId} className="min-w-0 flex-1 text-lg font-semibold leading-snug">
|
||||||
|
{navigated ? (liveData?.title ?? t("player.loading")) : active.title}
|
||||||
|
</h2>
|
||||||
|
{!wide && (
|
||||||
|
<IconButton
|
||||||
|
ref={descPop.refs.setReference}
|
||||||
|
{...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>
|
||||||
|
{detail.isLoading ? (
|
||||||
|
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||||
|
) : detail.data?.description ? (
|
||||||
|
<div
|
||||||
|
ref={descFade.ref}
|
||||||
|
style={descFade.style}
|
||||||
|
className="text-sm whitespace-pre-wrap wrap-break-word max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
|
||||||
|
>
|
||||||
|
{renderDescription(detail.data.description, {
|
||||||
|
currentId: currentVideoId,
|
||||||
|
onSeek: seekTo,
|
||||||
|
onLoadVideo: loadVideo,
|
||||||
|
t,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||||
|
)}
|
||||||
|
</PopoverSurface>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
data-testid="player-close"
|
||||||
|
onClick={onClose}
|
||||||
|
title={t("player.closeEsc")}
|
||||||
|
>
|
||||||
|
{t("player.close")}
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel + meta on one line, with the watched toggle pushed to the right.
|
||||||
When navigated to a linked video we only have its author (from the player),
|
When navigated to a linked video we only have its author (from the player),
|
||||||
so we show that as plain text and hide feed-video-specific bits. */}
|
so we show that as plain text and hide feed-video-specific bits. */}
|
||||||
<div className="flex items-center gap-3 mt-3">
|
<div className={`flex items-center gap-3 mt-3 ${wide ? "flex-wrap" : ""}`}>
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<Avatar
|
<Avatar
|
||||||
src={active.channel_thumbnail}
|
src={active.channel_thumbnail}
|
||||||
fallback={active.channel_title ?? ""}
|
fallback={active.channel_title ?? ""}
|
||||||
className="w-9 h-9 rounded-full shrink-0"
|
className="w-9 h-9 rounded-full shrink-0"
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
{navigated ? (
|
|
||||||
detail.data?.channel_id ? (
|
|
||||||
<a
|
|
||||||
href={channelYouTubeUrl(detail.data.channel_id)}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="font-medium hover:text-accent shrink-0"
|
|
||||||
>
|
|
||||||
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
|
|
||||||
<ChannelNameLink
|
|
||||||
channelId={active.channel_id}
|
|
||||||
title={active.channel_title}
|
|
||||||
onOpenInApp={() => {
|
|
||||||
// openChannel closes this player first and pushes only once its
|
|
||||||
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
|
||||||
// and clicking the name merely closes the player
|
|
||||||
// (lib/history.closeOverlaysThenNavigate).
|
|
||||||
openChannel(active.channel_id, active.channel_title ?? undefined);
|
|
||||||
}}
|
|
||||||
className="font-medium hover:text-accent truncate text-left"
|
|
||||||
/>
|
|
||||||
<IconLink
|
|
||||||
href={active.channel_url}
|
|
||||||
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 min-w-0">
|
|
||||||
{active.view_count != null && (
|
|
||||||
<span>
|
|
||||||
·{" "}
|
|
||||||
{t("player.views", {
|
|
||||||
count: active.view_count,
|
|
||||||
formattedCount: formatCompact(active.view_count, lang),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
<span>
|
{navigated ? (
|
||||||
· {relativeTime(active.published_at, lang)}
|
detail.data?.channel_id ? (
|
||||||
{active.published_at && ` · ${fullDate(active.published_at)}`}
|
<a
|
||||||
</span>
|
href={channelYouTubeUrl(detail.data.channel_id)}
|
||||||
{active.duration_seconds != null && (
|
target="_blank"
|
||||||
<span>· {formatDuration(active.duration_seconds)}</span>
|
rel="noreferrer"
|
||||||
|
className="font-medium hover:text-accent shrink-0"
|
||||||
|
>
|
||||||
|
{liveData?.author ?? detail.data.channel_title ?? t("player.channel")}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="font-medium shrink-0">{liveData?.author ?? ""}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
|
||||||
|
<ChannelNameLink
|
||||||
|
channelId={active.channel_id}
|
||||||
|
title={active.channel_title}
|
||||||
|
onOpenInApp={() => {
|
||||||
|
// openChannel closes this player first and pushes only once its
|
||||||
|
// Back-to-close entry is reclaimed — otherwise that reclaim undoes the push
|
||||||
|
// and clicking the name merely closes the player
|
||||||
|
// (lib/history.closeOverlaysThenNavigate).
|
||||||
|
openChannel(active.channel_id, active.channel_title ?? undefined);
|
||||||
|
}}
|
||||||
|
className="font-medium hover:text-accent truncate text-left"
|
||||||
|
/>
|
||||||
|
<IconLink
|
||||||
|
href={active.channel_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
label={t("channels.row.openOnYouTube")}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
<ExternalLink />
|
||||||
|
</IconLink>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{active.live_status === "was_live" && (
|
{!navigated ? (
|
||||||
<span className="bg-accent text-accent-fg text-3xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded-sm">
|
<div
|
||||||
{t("player.stream")}
|
className={`flex flex-wrap items-center gap-x-2 gap-y-0.5 text-sm text-muted min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
||||||
</span>
|
>
|
||||||
|
{active.view_count != null && (
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{t("player.views", {
|
||||||
|
count: active.view_count,
|
||||||
|
formattedCount: formatCompact(active.view_count, lang),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{relativeTime(active.published_at, lang)}
|
||||||
|
{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 min-w-0 ${wide ? META_OWN_LINE : ""}`}
|
||||||
|
>
|
||||||
|
{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)} ·{" "}
|
||||||
|
{fullDate(detail.data.published_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{detail.data?.duration_seconds != null && (
|
||||||
|
<span>
|
||||||
|
{metaSep}
|
||||||
|
{formatDuration(detail.data.duration_seconds)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</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 min-w-0">
|
|
||||||
{detail.data?.view_count != null && (
|
|
||||||
<span>
|
|
||||||
·{" "}
|
|
||||||
{t("player.views", {
|
|
||||||
count: detail.data.view_count,
|
|
||||||
formattedCount: formatCompact(detail.data.view_count, lang),
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail.data?.published_at && (
|
|
||||||
<span>
|
|
||||||
· {relativeTime(detail.data.published_at, lang)} ·{" "}
|
|
||||||
{fullDate(detail.data.published_at)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{detail.data?.duration_seconds != null && (
|
|
||||||
<span>· {formatDuration(detail.data.duration_seconds)}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reliable, discoverable path into our big view straight from the windowed player —
|
{/* Reliable, discoverable path into our big view straight from the windowed player —
|
||||||
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
|
the docked bar's controls (incl. our own true-fullscreen ⛶) are off-screen here, and
|
||||||
the only fullscreen button previously reachable in the windowed video was YouTube's
|
the only fullscreen button previously reachable in the windowed video was YouTube's
|
||||||
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
own (which hides our chrome). Mirrors the F key (maximise, a CSS big view); from there
|
||||||
the docked bar offers OUR true fullscreen. */}
|
the docked bar offers OUR true fullscreen. */}
|
||||||
{!navigated && (
|
{!navigated && (
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={toggleFullscreen}
|
onClick={toggleFullscreen}
|
||||||
label={t("player.fullscreen")}
|
label={t("player.fullscreen")}
|
||||||
title={`${t("player.fullscreen")} · F`}
|
title={`${t("player.fullscreen")} · F`}
|
||||||
className="ml-auto"
|
// Narrow: the actions end the channel line. Wide: their own line in the panel.
|
||||||
>
|
className={wide ? undefined : "ml-auto"}
|
||||||
<Maximize />
|
>
|
||||||
</IconButton>
|
<Maximize />
|
||||||
)}
|
</IconButton>
|
||||||
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
)}
|
||||||
{!navigated && (
|
{!navigated && <AddToPlaylist videoId={active.id} variant="outline" size="md" />}
|
||||||
<DownloadButton
|
{!navigated && (
|
||||||
videoId={active.id}
|
<DownloadButton
|
||||||
title={active.title}
|
videoId={active.id}
|
||||||
variant="outline"
|
title={active.title}
|
||||||
size="md"
|
variant="outline"
|
||||||
/>
|
size="md"
|
||||||
)}
|
/>
|
||||||
{!navigated && onRemoveFromList && (
|
)}
|
||||||
<IconButton
|
{!navigated && onRemoveFromList && (
|
||||||
variant="outline-danger"
|
<IconButton
|
||||||
size="md"
|
variant="outline-danger"
|
||||||
onClick={() => void removeFromList()}
|
size="md"
|
||||||
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
onClick={() => void removeFromList()}
|
||||||
// Download / Mark watched reads as "delete this video".
|
// Named with its object, like the list's menu row: a bare "Remove" among Fullscreen /
|
||||||
label={t("playlists.removeItemOf", { title: active.title })}
|
// Download / Mark watched reads as "delete this video".
|
||||||
title={t("playlists.removeItem")}
|
label={t("playlists.removeItemOf", { title: active.title })}
|
||||||
>
|
title={t("playlists.removeItem")}
|
||||||
<Trash2 />
|
>
|
||||||
</IconButton>
|
<Trash2 />
|
||||||
)}
|
</IconButton>
|
||||||
{!navigated && (
|
)}
|
||||||
<Button
|
{!navigated && (
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
pressed={watched}
|
size="sm"
|
||||||
onClick={() => setWatched(!watched)}
|
pressed={watched}
|
||||||
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
onClick={() => setWatched(!watched)}
|
||||||
icon={watched ? <CheckCheck /> : <Check />}
|
title={watched ? t("player.watchedUnmark") : t("player.markWatched")}
|
||||||
>
|
icon={watched ? <CheckCheck /> : <Check />}
|
||||||
{watched ? t("player.watched") : t("player.markWatched")}
|
>
|
||||||
</Button>
|
{watched ? t("player.watched") : t("player.markWatched")}
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{wide && (
|
||||||
|
<section className="mt-5" data-testid="player-description">
|
||||||
|
<SectionLabel level={3} className="mb-2">
|
||||||
|
{t("player.description")}
|
||||||
|
</SectionLabel>
|
||||||
|
{detail.isLoading ? (
|
||||||
|
<div className="text-sm text-muted">{t("player.loading")}</div>
|
||||||
|
) : detail.data?.description ? (
|
||||||
|
<div className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||||
|
{renderDescription(detail.data.description, {
|
||||||
|
currentId: currentVideoId,
|
||||||
|
onSeek: seekTo,
|
||||||
|
onLoadVideo: loadVideo,
|
||||||
|
t,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-muted">{t("player.noDescription")}</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -286,6 +286,9 @@ export default function PlexCollectionView({
|
|||||||
posterDisabled={!canPlay || queueBusy}
|
posterDisabled={!canPlay || queueBusy}
|
||||||
showPlayOverlay={canPlay}
|
showPlayOverlay={canPlay}
|
||||||
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
|
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}
|
customizeMenu={null}
|
||||||
>
|
>
|
||||||
{renaming ? (
|
{renaming ? (
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// facts (details, keywords); a column with nothing in it is not rendered and the other takes the width.
|
||||||
describe("PlexInfo layout B (page)", () => {
|
// E31 (the owner's pick "A"): the hero's text stopped at ~55% of the panel. The facts and the key crew are
|
||||||
const TWO_COLUMNS = "lg:grid-cols-[minmax(0,1fr)_20rem]";
|
// 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.
|
// The strips are Carousels, which watch their size.
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
@@ -186,7 +189,8 @@ describe("PlexInfo layout B (page)", () => {
|
|||||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||||
});
|
});
|
||||||
const heading = (name: string) => screen.getByRole("heading", { name });
|
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 = {
|
const full: PlexItemDetail = {
|
||||||
...detail,
|
...detail,
|
||||||
cast: [{ name: "An Actor", role: "Hero" }],
|
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(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
wrap(<PlexInfo onPlayItem={noop} detail={full} variant="page" />);
|
||||||
|
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 coll = heading("The Collection");
|
||||||
const crew = heading("plex.info.crew");
|
|
||||||
const cast = heading("plex.info.cast");
|
const cast = heading("plex.info.cast");
|
||||||
const facts = heading("plex.info.facts.title");
|
expect(hero.contains(coll) || hero.contains(cast)).toBe(false);
|
||||||
const follows = (a: HTMLElement, b: HTMLElement) =>
|
expect(follows(hero, coll) && follows(coll, cast)).toBe(true);
|
||||||
!!(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]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("with nothing for the main column, the sidebar's sections take the full width", () => {
|
it("without facts or crew there is no second column; the keywords still sit under the text", () => {
|
||||||
wrap(
|
const bare: PlexItemDetail = {
|
||||||
<PlexInfo
|
...detail,
|
||||||
onPlayItem={noop}
|
year: null,
|
||||||
detail={{ ...detail, tmdb: { ...detail.tmdb!, crew: [] } }}
|
duration_seconds: null,
|
||||||
variant="page"
|
content_rating: null,
|
||||||
/>,
|
studio: null,
|
||||||
);
|
tmdb: {
|
||||||
expect(screen.queryByRole("heading", { name: "plex.info.crew" })).toBeNull();
|
...detail.tmdb!,
|
||||||
const side = column(heading("plex.info.facts.title"));
|
budget: null,
|
||||||
expect(side).toBe(column(heading("plex.info.keywords")));
|
revenue: null,
|
||||||
expect(side.parentElement!.className).not.toContain(TWO_COLUMNS);
|
studios: [],
|
||||||
expect([...side.parentElement!.children]).toEqual([side]);
|
production_countries: [],
|
||||||
|
crew: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
wrap(<PlexInfo onPlayItem={noop} detail={bare} variant="page" />);
|
||||||
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||||
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../lib/api";
|
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 { notifyActionError } from "../lib/actionErrors";
|
||||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||||
import { tmdbBackdropUrl, useCrossfadeBackdrop, useDetailPrefs } from "../lib/plexDetail";
|
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||||
import { usePref } from "../lib/usePref";
|
import {
|
||||||
|
tmdbBackdropUrl,
|
||||||
|
useCrossfadeBackdrop,
|
||||||
|
useDetailAside,
|
||||||
|
useDetailPrefs,
|
||||||
|
} from "../lib/plexDetail";
|
||||||
import { Carousel } from "./ui/Carousel";
|
import { Carousel } from "./ui/Carousel";
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./PlexCollectionEditor";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
||||||
@@ -21,13 +26,12 @@ import { SURFACE } from "./ui/surfaces";
|
|||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "./ui/Badge";
|
||||||
import { IconLink } from "./ui/LinkButton";
|
import { IconLink } from "./ui/LinkButton";
|
||||||
import { PageHeading } from "./ui/Headings";
|
import { PageHeading } from "./ui/Headings";
|
||||||
import clsx from "clsx";
|
|
||||||
import { PlexPosterCard } from "./plexCards";
|
import { PlexPosterCard } from "./plexCards";
|
||||||
import { PlexMenu } from "./PlexMenu";
|
import { PlexMenu } from "./PlexMenu";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "./ui/useContextMenu";
|
||||||
import { usePlexCardActions } from "./usePlexCardActions";
|
import { usePlexCardActions } from "./usePlexCardActions";
|
||||||
import { toggleWatchState } from "../lib/plexWatch";
|
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,
|
// 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
|
// 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<PlexFilters>) => 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: (
|
|
||||||
<Filterable
|
|
||||||
tag
|
|
||||||
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
|
|
||||||
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
|
|
||||||
>
|
|
||||||
{year}
|
|
||||||
</Filterable>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
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: (
|
|
||||||
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
|
||||||
{vocab(t, "certificate", cert)}
|
|
||||||
</Filterable>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
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: (
|
|
||||||
<span className="flex flex-wrap gap-1.5">
|
|
||||||
{studios.map((s) => (
|
|
||||||
<Filterable
|
|
||||||
key={s}
|
|
||||||
tag
|
|
||||||
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
|
||||||
>
|
|
||||||
{s}
|
|
||||||
</Filterable>
|
|
||||||
))}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
});
|
|
||||||
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 (
|
|
||||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
|
||||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
|
||||||
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
|
|
||||||
{rows.map((r) => (
|
|
||||||
<div key={r.key} className="contents">
|
|
||||||
<dt className="text-muted">{r.label}</dt>
|
|
||||||
<dd className="min-w-0 font-medium">{r.node}</dd>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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<PlexFilters>) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
if (keywords.length === 0) return null;
|
|
||||||
return (
|
|
||||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
|
||||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{keywords.map((k) => (
|
|
||||||
<Filterable
|
|
||||||
key={k}
|
|
||||||
tag
|
|
||||||
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
|
|
||||||
>
|
|
||||||
{k}
|
|
||||||
</Filterable>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
|
||||||
<div className={`${SURFACE.panel} p-4 sm:p-5`}>
|
|
||||||
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
|
||||||
{/* In the main column (layout B): as many name columns as its width holds. */}
|
|
||||||
<div className="grid grid-cols-[repeat(auto-fill,minmax(11rem,1fr))] gap-x-3 gap-y-1.5">
|
|
||||||
{crew.map((c, i) => (
|
|
||||||
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
|
||||||
{onOpenPerson ? (
|
|
||||||
<Pressable
|
|
||||||
selectable
|
|
||||||
onClick={() => 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}
|
|
||||||
</Pressable>
|
|
||||||
) : (
|
|
||||||
<div className="truncate text-sm font-medium">{c.name}</div>
|
|
||||||
)}
|
|
||||||
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The playback-class chip (dot + Plex-native label), with a muted codec · bit-depth subtitle when
|
// 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,
|
// 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.
|
// 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.
|
// is PlexInfo-specific state, persisted through the same savePref.
|
||||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
|
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade, savePref } =
|
||||||
useDetailPrefs();
|
useDetailPrefs();
|
||||||
// The three TMDB-enriched side panels are each independently hideable (default ON), like the cast row.
|
// The TMDB facts / keywords / crew: the hero's second column, each section a per-user toggle.
|
||||||
const [showFacts, setShowFacts] = usePref<boolean>("plexInfoFacts", true);
|
const aside = useDetailAside(detail);
|
||||||
const [showKeywords, setShowKeywords] = usePref<boolean>("plexInfoKeywords", true);
|
|
||||||
const [showCrew, setShowCrew] = usePref<boolean>("plexInfoCrew", true);
|
|
||||||
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
const prefs = (qc.getQueryData<{ preferences?: Record<string, unknown> }>(qk.me())?.preferences ??
|
||||||
{}) as { plexHiddenStripSources?: string[] };
|
{}) as { plexHiddenStripSources?: string[] };
|
||||||
// Which collection-strip source buckets the user has HIDDEN (default: none → all shown). Toggled
|
// 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) =>
|
const sourceLabel = (src: string) =>
|
||||||
t(`plex.info.stripSource.${src}`, { defaultValue: src.toUpperCase() });
|
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
|
const asideShown = !overlay && aside.column;
|
||||||
// (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 infoTitle =
|
const infoTitle =
|
||||||
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
detail.kind === "episode" && detail.show_title ? detail.show_title : detail.title;
|
||||||
|
|
||||||
@@ -483,7 +304,7 @@ export default function PlexInfo({
|
|||||||
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
<div className={overlay ? "relative w-full max-w-3xl" : "relative glass-media"}>
|
||||||
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
|
<div className={overlay ? "p-5" : "flex flex-col gap-3 p-3 sm:p-4"}>
|
||||||
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
{/* Hero block: floats as a frosted glass panel over the fixed art backdrop (page variant). */}
|
||||||
<div className={overlay ? "" : `${SURFACE.panel} p-4 sm:p-6`}>
|
<div className={overlay ? "" : `${SURFACE.panel} @container p-4 sm:p-6`}>
|
||||||
{/* Header: close (overlay) / customize */}
|
{/* Header: close (overlay) / customize */}
|
||||||
<div className="mb-4 flex items-start gap-3">
|
<div className="mb-4 flex items-start gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -510,27 +331,7 @@ export default function PlexInfo({
|
|||||||
hasCast={detail.cast.length > 0}
|
hasCast={detail.cast.length > 0}
|
||||||
extra={
|
extra={
|
||||||
<>
|
<>
|
||||||
{hasFacts && (
|
{!overlay && <DetailAsideToggles aside={aside} />}
|
||||||
<PrefToggle
|
|
||||||
label={t("plex.info.prefFacts")}
|
|
||||||
on={showFacts}
|
|
||||||
onClick={() => setShowFacts(!showFacts)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasKeywords && (
|
|
||||||
<PrefToggle
|
|
||||||
label={t("plex.info.prefKeywords")}
|
|
||||||
on={showKeywords}
|
|
||||||
onClick={() => setShowKeywords(!showKeywords)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{hasCrew && (
|
|
||||||
<PrefToggle
|
|
||||||
label={t("plex.info.prefCrew")}
|
|
||||||
on={showCrew}
|
|
||||||
onClick={() => setShowCrew(!showCrew)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{presentSources.map((src) => (
|
{presentSources.map((src) => (
|
||||||
<PrefToggle
|
<PrefToggle
|
||||||
key={src}
|
key={src}
|
||||||
@@ -554,171 +355,192 @@ export default function PlexInfo({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-4 sm:gap-6">
|
{/* The poster + text, and beside them (page variant, a wide hero) the facts / keywords / crew. */}
|
||||||
{/* Poster */}
|
<div
|
||||||
<img
|
className={
|
||||||
src={detail.thumb}
|
asideShown ? "grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]" : undefined
|
||||||
alt=""
|
}
|
||||||
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
|
>
|
||||||
/>
|
<div className="flex gap-4 sm:gap-6">
|
||||||
|
{/* Poster */}
|
||||||
|
<img
|
||||||
|
src={detail.thumb}
|
||||||
|
alt=""
|
||||||
|
className={`shrink-0 rounded-xl border border-border object-cover ${overlay ? "w-24 sm:w-28" : "w-32 sm:w-44"} aspect-2/3`}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
<div className={`min-w-0 flex-1 ${overlay ? "text-white/90" : ""}`}>
|
||||||
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
|
{/* Meta line + IMDb rating/link (each value can set the matching filter). */}
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
|
||||||
{detail.year != null && (
|
{detail.year != null && (
|
||||||
<Filterable
|
|
||||||
tag
|
|
||||||
onDark={overlay}
|
|
||||||
title={onFilter ? t("plex.info.filterYear", { year: detail.year }) : undefined}
|
|
||||||
onClick={
|
|
||||||
onFilter
|
|
||||||
? () => onFilter({ yearMin: detail.year, yearMax: detail.year })
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{detail.year}
|
|
||||||
</Filterable>
|
|
||||||
)}
|
|
||||||
{detail.content_rating && (
|
|
||||||
<Filterable
|
|
||||||
tag
|
|
||||||
onDark={overlay}
|
|
||||||
onClick={
|
|
||||||
onFilter
|
|
||||||
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{vocab(t, "certificate", detail.content_rating)}
|
|
||||||
</Filterable>
|
|
||||||
)}
|
|
||||||
{detail.duration_seconds ? (
|
|
||||||
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
|
|
||||||
) : null}
|
|
||||||
{detail.studio && (
|
|
||||||
<Filterable
|
|
||||||
tag
|
|
||||||
onDark={overlay}
|
|
||||||
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
|
||||||
>
|
|
||||||
{detail.studio}
|
|
||||||
</Filterable>
|
|
||||||
)}
|
|
||||||
<PlaybackChip detail={detail} overlay={overlay} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
|
||||||
<div className="mt-2">
|
|
||||||
<RatingsRow
|
|
||||||
imdbRating={detail.imdb_rating}
|
|
||||||
imdbUrl={detail.imdb_url}
|
|
||||||
audience={detail.rating}
|
|
||||||
tmdbRating={detail.tmdb?.rating}
|
|
||||||
tmdbVotes={detail.tmdb?.vote_count}
|
|
||||||
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
|
|
||||||
onDark={overlay}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{detail.tagline && (
|
|
||||||
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
|
|
||||||
{detail.tagline}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detail.genres.length > 0 && (
|
|
||||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
|
||||||
{detail.genres.map((g) => (
|
|
||||||
<Filterable
|
<Filterable
|
||||||
key={g}
|
|
||||||
tag
|
tag
|
||||||
onDark={overlay}
|
onDark={overlay}
|
||||||
onClick={onFilter ? () => 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}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
))}
|
)}
|
||||||
</div>
|
{detail.content_rating && (
|
||||||
)}
|
|
||||||
|
|
||||||
{detail.directors.length > 0 && (
|
|
||||||
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
|
|
||||||
<span>{t("plex.info.director")}:</span>
|
|
||||||
{detail.directors.map((d) => (
|
|
||||||
<Filterable
|
<Filterable
|
||||||
key={d}
|
|
||||||
tag
|
tag
|
||||||
onDark={overlay}
|
onDark={overlay}
|
||||||
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
onClick={
|
||||||
|
onFilter
|
||||||
|
? () => onFilter({ contentRatings: [detail.content_rating!] })
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{d}
|
{vocab(t, "certificate", detail.content_rating)}
|
||||||
</Filterable>
|
</Filterable>
|
||||||
))}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detail.summary && (
|
|
||||||
<p
|
|
||||||
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
|
||||||
>
|
|
||||||
{detail.summary}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Page actions: Play/Resume + watch controls */}
|
|
||||||
{!overlay && (
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
|
||||||
{onPlay && (
|
|
||||||
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
|
|
||||||
{inProgress ? t("plex.resume") : t("plex.play")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
{detail.status === "watched" ? (
|
{detail.duration_seconds ? (
|
||||||
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
|
||||||
{t("plex.info.markUnwatched")}
|
) : null}
|
||||||
</Button>
|
{detail.studio && (
|
||||||
) : (
|
<Filterable
|
||||||
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
|
tag
|
||||||
{t("plex.info.markWatched")}
|
onDark={overlay}
|
||||||
</Button>
|
onClick={onFilter ? () => onFilter({ studios: [detail.studio!] }) : undefined}
|
||||||
)}
|
|
||||||
{inProgress && (
|
|
||||||
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
|
||||||
{t("plex.info.clearResume")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{detail.kind === "movie" && (
|
|
||||||
<Button
|
|
||||||
variant="glass"
|
|
||||||
onClick={() => setPlaylistOpen(true)}
|
|
||||||
icon={<ListPlus />}
|
|
||||||
>
|
>
|
||||||
{t("plex.playlistAdd.manage")}
|
{detail.studio}
|
||||||
</Button>
|
</Filterable>
|
||||||
)}
|
|
||||||
{isAdmin && detail.kind === "movie" && library && (
|
|
||||||
<Button
|
|
||||||
variant="glass"
|
|
||||||
onClick={() => setEditorOpen(true)}
|
|
||||||
icon={<FolderPlus />}
|
|
||||||
>
|
|
||||||
{t("plex.collEditor.manage")}
|
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
|
<PlaybackChip detail={detail} overlay={overlay} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
{/* Multi-source ratings row (E5 S5): IMDb / TMDb / audience, whichever are present. */}
|
||||||
|
<div className="mt-2">
|
||||||
|
<RatingsRow
|
||||||
|
imdbRating={detail.imdb_rating}
|
||||||
|
imdbUrl={detail.imdb_url}
|
||||||
|
audience={detail.rating}
|
||||||
|
tmdbRating={detail.tmdb?.rating}
|
||||||
|
tmdbVotes={detail.tmdb?.vote_count}
|
||||||
|
onFilterRating={onFilter ? (n) => onFilter({ ratingMin: n }) : undefined}
|
||||||
|
onDark={overlay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detail.tagline && (
|
||||||
|
<p className={`mt-2 text-sm italic ${overlay ? "text-white/60" : "text-muted"}`}>
|
||||||
|
{detail.tagline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.genres.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{detail.genres.map((g) => (
|
||||||
|
<Filterable
|
||||||
|
key={g}
|
||||||
|
tag
|
||||||
|
onDark={overlay}
|
||||||
|
onClick={onFilter ? () => onFilter({ genres: [g] }) : undefined}
|
||||||
|
>
|
||||||
|
{g}
|
||||||
|
</Filterable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.directors.length > 0 && (
|
||||||
|
<p className={`mt-2 flex flex-wrap items-center gap-1.5 text-sm ${mutedCls}`}>
|
||||||
|
<span>{t("plex.info.director")}:</span>
|
||||||
|
{detail.directors.map((d) => (
|
||||||
|
<Filterable
|
||||||
|
key={d}
|
||||||
|
tag
|
||||||
|
onDark={overlay}
|
||||||
|
onClick={onFilter ? () => onFilter({ directors: [d] }) : undefined}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</Filterable>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.summary && (
|
||||||
|
<p
|
||||||
|
className={`measure mt-3 text-sm leading-relaxed ${overlay ? "text-white/85 line-clamp-5" : ""}`}
|
||||||
|
>
|
||||||
|
{detail.summary}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page actions: Play/Resume + watch controls */}
|
||||||
|
{!overlay && (
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
|
{onPlay && (
|
||||||
|
<Button onClick={onPlay} icon={<Play className="fill-current" />}>
|
||||||
|
{inProgress ? t("plex.resume") : t("plex.play")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{detail.status === "watched" ? (
|
||||||
|
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
||||||
|
{t("plex.info.markUnwatched")}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="glass" onClick={() => setState("watched")} icon={<Check />}>
|
||||||
|
{t("plex.info.markWatched")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{inProgress && (
|
||||||
|
<Button variant="glass" onClick={() => setState("new")} icon={<RotateCcw />}>
|
||||||
|
{t("plex.info.clearResume")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{detail.kind === "movie" && (
|
||||||
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
onClick={() => setPlaylistOpen(true)}
|
||||||
|
icon={<ListPlus />}
|
||||||
|
>
|
||||||
|
{t("plex.playlistAdd.manage")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{isAdmin && detail.kind === "movie" && library && (
|
||||||
|
<Button
|
||||||
|
variant="glass"
|
||||||
|
onClick={() => setEditorOpen(true)}
|
||||||
|
icon={<FolderPlus />}
|
||||||
|
>
|
||||||
|
{t("plex.collEditor.manage")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!overlay && aside.shown.keywords && (
|
||||||
|
<DetailKeywords
|
||||||
|
keywords={detail.tmdb?.keywords ?? []}
|
||||||
|
onFilter={onFilter}
|
||||||
|
className="mt-5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{asideShown && (
|
||||||
|
<DetailAside
|
||||||
|
facts={detail}
|
||||||
|
shown={aside.shown}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onOpenPerson={onOpenPerson}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* End hero panel. */}
|
{/* End hero panel. */}
|
||||||
|
|
||||||
{/* Below the hero (E28 S3h, the owner's layout B, 2026-09-26): a main column — the collection
|
{/* Below the hero: the collection strips first (the owner's most used), then the cast, each
|
||||||
strips first (the owner's most used), then the crew, then the cast — beside a narrow
|
per-user toggleable, stacked so a hidden one leaves no hole. The facts / keywords / crew that
|
||||||
sidebar of facts (details, keywords), the TMDB/IMDb shape. Every section is per-user
|
sat beside them (E28 S3h) moved up into the hero's second column (E31). The overlay (over
|
||||||
toggleable, so the layout is built from what is ON: a column with nothing in it is not
|
the running player) is the same stack, lean. */}
|
||||||
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. */}
|
|
||||||
{(() => {
|
{(() => {
|
||||||
const collections = (detail.collections ?? []).filter(
|
const collections = (detail.collections ?? []).filter(
|
||||||
(col) => !hiddenSources.includes(col.source),
|
(col) => !hiddenSources.includes(col.source),
|
||||||
@@ -845,38 +667,11 @@ export default function PlexInfo({
|
|||||||
{castRow}
|
{castRow}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
const crewBox = showCrew && hasCrew && (
|
if (strips.length === 0 && !castRow) return null;
|
||||||
<CrewPanel crew={tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />
|
|
||||||
);
|
|
||||||
const factsBox = showFacts && hasFacts && (
|
|
||||||
<FactsPanel detail={detail} onFilter={onFilter} />
|
|
||||||
);
|
|
||||||
const keywordsBox = showKeywords && hasKeywords && (
|
|
||||||
<KeywordsPanel keywords={tmdb?.keywords ?? []} onFilter={onFilter} />
|
|
||||||
);
|
|
||||||
const hasMain = strips.length > 0 || !!crewBox || !!castRow;
|
|
||||||
const hasSide = !!factsBox || !!keywordsBox;
|
|
||||||
if (!hasMain && !hasSide) return null;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex min-w-0 flex-col gap-3">
|
||||||
className={clsx(
|
{strips}
|
||||||
"grid items-start gap-3",
|
{castRow}
|
||||||
hasMain && hasSide && "lg:grid-cols-[minmax(0,1fr)_20rem]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{hasMain && (
|
|
||||||
<div className="flex min-w-0 flex-col gap-3">
|
|
||||||
{strips}
|
|
||||||
{crewBox}
|
|
||||||
{castRow}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{hasSide && (
|
|
||||||
<div className="flex min-w-0 flex-col gap-3">
|
|
||||||
{factsBox}
|
|
||||||
{keywordsBox}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -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<PlexFilters>) => 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<AsideSection, string> = {
|
||||||
|
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) => (
|
||||||
|
<PrefToggle key={k} label={label[k]} on={aside.on[k]} onClick={() => 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 (
|
||||||
|
<div data-testid="detail-aside" className="flex min-w-0 flex-col gap-5">
|
||||||
|
{shown.facts && <FactsSection facts={facts} onFilter={onFilter} />}
|
||||||
|
{shown.crew && <CrewSection crew={facts.tmdb?.crew ?? []} onOpenPerson={onOpenPerson} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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: (
|
||||||
|
<Filterable
|
||||||
|
tag
|
||||||
|
title={onFilter ? t("plex.info.filterYear", { year }) : undefined}
|
||||||
|
onClick={onFilter ? () => onFilter({ yearMin: year, yearMax: year }) : undefined}
|
||||||
|
>
|
||||||
|
{year}
|
||||||
|
</Filterable>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
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: (
|
||||||
|
<Filterable tag onClick={onFilter ? () => onFilter({ contentRatings: [cert] }) : undefined}>
|
||||||
|
{vocab(t, "certificate", cert)}
|
||||||
|
</Filterable>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
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: (
|
||||||
|
<span className="flex flex-wrap gap-1.5">
|
||||||
|
{studios.map((s) => (
|
||||||
|
<Filterable
|
||||||
|
key={s}
|
||||||
|
tag
|
||||||
|
onClick={onFilter ? () => onFilter({ studios: [s] }) : undefined}
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</Filterable>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
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 (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.facts.title")}</h2>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] items-baseline gap-x-4 gap-y-2 text-sm">
|
||||||
|
{rows.map((r) => (
|
||||||
|
<div key={r.key} className="contents">
|
||||||
|
<dt className="text-muted">{r.label}</dt>
|
||||||
|
<dd className="min-w-0 font-medium">{r.node}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<section data-testid="detail-keywords" className={className}>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.keywords")}</h2>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{keywords.map((k) => (
|
||||||
|
<Filterable
|
||||||
|
key={k}
|
||||||
|
tag
|
||||||
|
onClick={onFilter ? () => onFilter({ keywords: [k] }) : undefined}
|
||||||
|
>
|
||||||
|
{k}
|
||||||
|
</Filterable>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-3 text-sm font-semibold">{t("plex.info.crew")}</h2>
|
||||||
|
{/* As many name columns as the column holds: two in the hero's side column, one when narrow. */}
|
||||||
|
<div className="grid grid-cols-[repeat(auto-fill,minmax(10rem,1fr))] gap-x-3 gap-y-1.5">
|
||||||
|
{crew.map((c, i) => (
|
||||||
|
<div key={`${c.name}:${c.job}:${i}`} className="min-w-0">
|
||||||
|
{onOpenPerson ? (
|
||||||
|
<Pressable
|
||||||
|
selectable
|
||||||
|
onClick={() => 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}
|
||||||
|
</Pressable>
|
||||||
|
) : (
|
||||||
|
<div className="truncate text-sm font-medium">{c.name}</div>
|
||||||
|
)}
|
||||||
|
<div className="truncate text-2xs text-muted">{vocab(t, "crewJob", c.job)}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
/** 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,
|
* 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
|
* 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({
|
export function DetailHero({
|
||||||
thumb,
|
thumb,
|
||||||
posterTitle,
|
posterTitle,
|
||||||
@@ -239,6 +241,7 @@ export function DetailHero({
|
|||||||
onPosterClick,
|
onPosterClick,
|
||||||
posterSizeClass = "w-32 sm:w-44",
|
posterSizeClass = "w-32 sm:w-44",
|
||||||
customizeMenu,
|
customizeMenu,
|
||||||
|
aside,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
thumb: string;
|
thumb: string;
|
||||||
@@ -248,11 +251,11 @@ export function DetailHero({
|
|||||||
onPosterClick: () => void;
|
onPosterClick: () => void;
|
||||||
posterSizeClass?: string;
|
posterSizeClass?: string;
|
||||||
customizeMenu: ReactNode;
|
customizeMenu: ReactNode;
|
||||||
|
aside?: ReactNode;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
const main = (
|
||||||
<div className={`${SURFACE.panel} relative p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5`}>
|
<div className="flex gap-4 sm:gap-5">
|
||||||
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
|
||||||
<Pressable
|
<Pressable
|
||||||
onClick={onPosterClick}
|
onClick={onPosterClick}
|
||||||
disabled={posterDisabled}
|
disabled={posterDisabled}
|
||||||
@@ -273,6 +276,19 @@ export function DetailHero({
|
|||||||
<div className="min-w-0 flex-1">{children}</div>
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
return (
|
||||||
|
<div className={`${SURFACE.panel} @container relative p-4 sm:p-5 mb-8`}>
|
||||||
|
<div className="absolute right-3 top-3 z-base">{customizeMenu}</div>
|
||||||
|
{aside ? (
|
||||||
|
<div className="grid gap-6 @5xl:grid-cols-[minmax(0,1fr)_minmax(0,24rem)]">
|
||||||
|
{main}
|
||||||
|
{aside}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
main
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
|
/** The "mark whole show/season watched" hero action (icon + label flip on state). Verbatim in both
|
||||||
|
|||||||
@@ -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<typeof import("react-i18next")>();
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PlexShowView
|
||||||
|
showId="show"
|
||||||
|
onBack={() => {}}
|
||||||
|
onPlay={() => {}}
|
||||||
|
onOpenSeason={() => {}}
|
||||||
|
onOpenShow={() => {}}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onOpenPerson={onOpenPerson}
|
||||||
|
/>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -34,12 +34,14 @@ import {
|
|||||||
Filterable,
|
Filterable,
|
||||||
WatchedToggle,
|
WatchedToggle,
|
||||||
} from "./plexDetailUi";
|
} from "./plexDetailUi";
|
||||||
|
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||||
import {
|
import {
|
||||||
epLabel,
|
epLabel,
|
||||||
seasonTitle,
|
seasonTitle,
|
||||||
tmdbBackdropUrl,
|
tmdbBackdropUrl,
|
||||||
useCrossfadeBackdrop,
|
useCrossfadeBackdrop,
|
||||||
useDetailPrefs,
|
useDetailPrefs,
|
||||||
|
useDetailAside,
|
||||||
} from "../lib/plexDetail";
|
} from "../lib/plexDetail";
|
||||||
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
import { CastStrip, EpisodeCard, PlexPosterCard, RelatedStrip, SeasonCard } from "./plexCards";
|
||||||
import { PlexMenu } from "./PlexMenu";
|
import { PlexMenu } from "./PlexMenu";
|
||||||
@@ -449,6 +451,8 @@ export function PlexShowView({
|
|||||||
toggleRelated,
|
toggleRelated,
|
||||||
toggleCrossfade,
|
toggleCrossfade,
|
||||||
} = useDetailPrefs();
|
} = useDetailPrefs();
|
||||||
|
// The facts / keywords / crew (E31): TMDB enriches shows too, and the page showed none of it.
|
||||||
|
const aside = useDetailAside(show ?? {});
|
||||||
useCrossfadeBackdrop(
|
useCrossfadeBackdrop(
|
||||||
[
|
[
|
||||||
show?.art,
|
show?.art,
|
||||||
@@ -554,8 +558,19 @@ export function PlexShowView({
|
|||||||
showRelated={showRelated}
|
showRelated={showRelated}
|
||||||
onToggleRelated={toggleRelated}
|
onToggleRelated={toggleRelated}
|
||||||
hasRelated={d.related.length > 0}
|
hasRelated={d.related.length > 0}
|
||||||
|
extra={<DetailAsideToggles aside={aside} />}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
aside={
|
||||||
|
aside.column && (
|
||||||
|
<DetailAside
|
||||||
|
facts={show}
|
||||||
|
shown={aside.shown}
|
||||||
|
onFilter={onFilter}
|
||||||
|
onOpenPerson={onOpenPerson}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
|
<PageHeading className="text-2xl font-semibold leading-tight">{show.title}</PageHeading>
|
||||||
{/* The same meta row as the media-info page: filtering values are chips, plain facts flat
|
{/* The same meta row as the media-info page: filtering values are chips, plain facts flat
|
||||||
@@ -677,6 +692,13 @@ export function PlexShowView({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{aside.shown.keywords && (
|
||||||
|
<DetailKeywords
|
||||||
|
keywords={show.tmdb?.keywords ?? []}
|
||||||
|
onFilter={onFilter}
|
||||||
|
className="mt-5"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DetailHero>
|
</DetailHero>
|
||||||
|
|
||||||
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
{/* Seasons — in a glassy panel like the movie info-page strips. */}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 <main>). `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<HTMLElement | null>): 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;
|
||||||
|
}
|
||||||
@@ -60,7 +60,8 @@ function loadYouTubeApi(): Promise<YTNamespace> {
|
|||||||
/** Per-account volume for the YouTube player, mirroring Plex's usePlayerVolume. A YT.Player is created
|
/** 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
|
* per video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
|
||||||
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
|
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
|
||||||
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area. */
|
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area —
|
||||||
|
* except over a `[data-wheel-scrolls]` panel that can still scroll that way. */
|
||||||
export function useYtVolume({
|
export function useYtVolume({
|
||||||
playerRef,
|
playerRef,
|
||||||
dialogRef,
|
dialogRef,
|
||||||
@@ -137,6 +138,17 @@ export function useYtVolume({
|
|||||||
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
|
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
|
||||||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
|
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
|
||||||
if (e.ctrlKey || e.metaKey) return;
|
if (e.ctrlKey || e.metaKey) return;
|
||||||
|
// Over a panel that can still scroll that way, the wheel scrolls it: the wide player's side panel
|
||||||
|
// 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<HTMLElement>("[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();
|
e.preventDefault();
|
||||||
focusModal(); // re-grab focus if it had slipped into the native controls
|
focusModal(); // re-grab focus if it had slipped into the native controls
|
||||||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useLayoutEffect } from "react";
|
import { useLayoutEffect } from "react";
|
||||||
import { usePref, useSavePref } from "./usePref";
|
import { usePref, useSavePref } from "./usePref";
|
||||||
import type { TFunction } from "i18next";
|
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
|
// 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
|
// 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<boolean>("plexInfoFacts", true);
|
||||||
|
const [keywords, setKeywords] = usePref<boolean>("plexInfoKeywords", true);
|
||||||
|
const [crew, setCrew] = usePref<boolean>("plexInfoCrew", true);
|
||||||
|
const has: Record<AsideSection, boolean> = {
|
||||||
|
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<AsideSection, boolean> = { facts, keywords, crew };
|
||||||
|
const shown: Record<AsideSection, boolean> = {
|
||||||
|
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<typeof useDetailAside>;
|
||||||
|
|
||||||
// A stored TMDB backdrop is a bare file path ("/xyz.jpg"); the CSP allows the TMDB image CDN (only
|
// 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
|
// 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.
|
// crossfade even on a lone movie with no collection.
|
||||||
|
|||||||
Reference in New Issue
Block a user