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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user