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:
@@ -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 video and always starts at 100, so the level lives in a ref and onReady re-applies it. Level 0 IS
|
||||
* the muted state (that's how YouTube's own control behaves), so there's no separate `muted` flag.
|
||||
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area. */
|
||||
* Mouse-wheel over the WHOLE modal (backdrop + card) adjusts volume, not just the small player area —
|
||||
* except over a `[data-wheel-scrolls]` panel that can still scroll that way. */
|
||||
export function useYtVolume({
|
||||
playerRef,
|
||||
dialogRef,
|
||||
@@ -137,6 +138,17 @@ export function useYtVolume({
|
||||
const onWheelSpin = useEffectEvent((e: WheelEvent) => {
|
||||
// Ctrl/⌘+wheel is the browser's zoom gesture — never swallow it.
|
||||
if (e.ctrlKey || e.metaKey) return;
|
||||
// Over a panel that can still scroll that way, the wheel scrolls it: the wide player's side panel
|
||||
// holds the description, which a wheel spent on the volume could never be read past (E31).
|
||||
const panel =
|
||||
e.target instanceof Element ? e.target.closest<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();
|
||||
focusModal(); // re-grab focus if it had slipped into the native controls
|
||||
nudgeVolume(e.deltaY < 0 ? 5 : -5);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useLayoutEffect } from "react";
|
||||
import { usePref, useSavePref } from "./usePref";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { PlexCard } from "./api";
|
||||
import type { PlexCard, PlexTmdb } from "./api";
|
||||
|
||||
// The non-component half of the shared Plex "detail page" UI (see plexDetailUi): the per-user display
|
||||
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
|
||||
@@ -31,6 +31,56 @@ export function useDetailPrefs() {
|
||||
};
|
||||
}
|
||||
|
||||
// What a detail page's facts are read from: a movie's item detail and a show's detail both carry these.
|
||||
export interface DetailFacts {
|
||||
year?: number | null;
|
||||
duration_seconds?: number | null;
|
||||
content_rating?: string | null;
|
||||
studio?: string | null;
|
||||
tmdb?: PlexTmdb | null;
|
||||
}
|
||||
|
||||
export type AsideSection = "facts" | "keywords" | "crew";
|
||||
|
||||
// The detail pages' second column (plexDetailAside, E31): which of its three sections — the TMDB facts,
|
||||
// keywords and key crew — have something to show, which the reader keeps on (three per-user prefs,
|
||||
// default ON, the same on the movie and the show page), and the toggles' flips.
|
||||
export function useDetailAside(f: DetailFacts) {
|
||||
const [facts, setFacts] = usePref<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
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user