- Files toolbar link box (paste button, Enter); a lone http(s) link pasted onto any page opens the one app-wide download dialog, which now names the link; the palette's "Download from a link…" focuses the box on its history entry. - The download tray, bottom right on every page: what waits, runs, is paused or failed (reason + Retry, the queue's right-click / "…" menu), and what finished in this tab as "Done · Open" until closed; folds to a pill (per-account pref); closed, it stays shut — across a reload — until the work changes or something finishes. Back to top steps up over it, the page end keeps clear. - A finish is announced once: "Downloaded: …", whether a phone plays it, Copy link + Open (toasts and notices may carry a second action). - The inbox's ready notice opens the file in its folder: download_done carries job_id + folder_id. - GET /api/downloads is the queue only (in flight, failed, finished in the last 10 minutes), with each row's source key.
233 lines
11 KiB
TypeScript
233 lines
11 KiB
TypeScript
import {
|
|
useCallback,
|
|
useEffectEvent,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { useScrollFade } from "../../lib/useScrollFade";
|
|
import { FOCUS_REST_ATTRS, useFocusKeeper } from "../../lib/useFocusKeeper";
|
|
import { StickyTopProvider } from "./stickyTop";
|
|
import { useBackToTopShown, useCornerLift } from "./backToTopState";
|
|
import { CONTENT_ID } from "./landmarks";
|
|
import { ensureNavId, peekNavId, subscribeNav } from "../../lib/history";
|
|
|
|
// Saved scroll offsets, keyed by `${historyEntryId}:${scrollKey}`. The entry id ties an offset to the
|
|
// exact history entry, so Back restores where you left it and a forward navigation (a new entry) opens
|
|
// at the top; the scrollKey ties it to the logical content, so a new feed filter still starts at the
|
|
// top WITHIN the same entry. Module-level so a view's position survives its scroller unmounting
|
|
// (opening a channel page swaps in a different shell/scroller) and is restored when it returns.
|
|
const savedScroll = new Map<string, number>();
|
|
// The key embeds an ever-increasing history-entry id, so without a bound this map would grow one
|
|
// entry per scrolled navigation for the life of the session. Cap it with a small LRU (Map preserves
|
|
// insertion order): re-insert on write to mark most-recently-used, evict the oldest past the cap.
|
|
const SCROLL_MEMORY_CAP = 200;
|
|
function rememberScroll(key: string, top: number): void {
|
|
savedScroll.delete(key);
|
|
savedScroll.set(key, top);
|
|
if (savedScroll.size > SCROLL_MEMORY_CAP) {
|
|
const oldest = savedScroll.keys().next().value;
|
|
if (oldest !== undefined) savedScroll.delete(oldest);
|
|
}
|
|
}
|
|
|
|
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
|
|
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
|
|
// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now).
|
|
// A spot the page can no longer reach — the last stretch of a page scrolled to its very end, whose
|
|
// Back-to-top spacer only shows once scrolled — goes as far as the page does: it used to be left at
|
|
// the top. Only a spacer's worth short, and only once the height has held still, is
|
|
// that answer early; a list still loading is further short than that, and gets the full wait. And
|
|
// landing there is not the end of it: that scroll is what shows Back to top, whose spacer then
|
|
// makes the spot reachable, so the loop watches a few frames more and finishes the way.
|
|
//
|
|
// The reader scrolling meanwhile — a wheel, a finger, a key, the scrollbar — takes the page over:
|
|
// the restore stops where they are, instead of pulling them back or dropping them at the end.
|
|
const SETTLED_FRAMES = 10;
|
|
const SPACER_REM = 6;
|
|
function restoreScroll(el: HTMLElement, top: number): () => void {
|
|
if (top <= 0) {
|
|
el.scrollTop = 0;
|
|
return () => {};
|
|
}
|
|
let raf = 0;
|
|
let frames = 0;
|
|
let still = 0;
|
|
let lastMax = -1;
|
|
let landedAt = -1;
|
|
let cancelled = false;
|
|
const stop = () => {
|
|
cancelled = true;
|
|
cancelAnimationFrame(raf);
|
|
el.removeEventListener("wheel", stop);
|
|
el.removeEventListener("touchstart", stop);
|
|
el.removeEventListener("pointerdown", stop);
|
|
document.removeEventListener("keydown", stop, true);
|
|
};
|
|
el.addEventListener("wheel", stop, { passive: true });
|
|
el.addEventListener("touchstart", stop, { passive: true });
|
|
el.addEventListener("pointerdown", stop);
|
|
document.addEventListener("keydown", stop, true);
|
|
const tick = () => {
|
|
if (cancelled) return;
|
|
const max = el.scrollHeight - el.clientHeight;
|
|
if (max >= top) el.scrollTop = top;
|
|
if (el.scrollTop >= top - 1) return stop(); // reached
|
|
if (landedAt >= 0) {
|
|
if (frames - landedAt > SETTLED_FRAMES) return stop(); // no spacer came: the end it is
|
|
} else {
|
|
still = max === lastMax ? still + 1 : 0;
|
|
lastMax = max;
|
|
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
|
|
const nearEnd = max > 0 && top - max <= SPACER_REM * rem;
|
|
if ((nearEnd && still >= SETTLED_FRAMES) || frames > 90) {
|
|
el.scrollTop = max; // as near as the page reaches now
|
|
landedAt = frames;
|
|
}
|
|
}
|
|
frames++;
|
|
raf = requestAnimationFrame(tick);
|
|
};
|
|
raf = requestAnimationFrame(tick);
|
|
return stop;
|
|
}
|
|
|
|
// The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the
|
|
// channel page and Plex browse all scroll inside it rather than owning a scroller of their own,
|
|
// which is why the edge-fade and the scroll restore/reset policy live here once instead of three
|
|
// times.
|
|
//
|
|
// Two invariants this element carries, both learned the hard way:
|
|
//
|
|
// - `scrollbar-gutter: stable` is load-bearing, not cosmetic. The feed derives its column count
|
|
// from this element's clientWidth and its thumbnails are aspect-ratio boxes, so when the content
|
|
// lands within a scrollbar's width of the viewport height the two feed each other: scrollbar
|
|
// appears -> 15px narrower -> shorter thumbs -> content fits -> scrollbar goes -> wider ->
|
|
// taller -> repeat, every frame. Reserving the gutter keeps clientWidth constant and breaks the
|
|
// loop. On the channel page it also stops the header/logo/buttons shifting sideways when you
|
|
// switch from the tall Videos tab to the short About tab.
|
|
// - Consequently the bar STAYS visible here: `.no-scrollbar` (scrollbar-width: none) would zero
|
|
// the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the
|
|
// fade next to its scrollbar, not instead of it.
|
|
//
|
|
// Scroll restore/reset policy (`scrollKey` identifies the logical view):
|
|
// - returning to a view with the SAME key — feed → open a channel → Back, feed filters unchanged —
|
|
// restores where you were (bug 1). The channel page is a different scroller, so the feed's
|
|
// scroller unmounts and remounts; the saved offset survives in `savedScroll` and is re-applied.
|
|
// - a NEW key — the feed's filters changed, i.e. a fresh result set — starts at the top (bug 3).
|
|
// The offset is recorded on every scroll rather than at unmount: by the time an unmount cleanup
|
|
// runs the scroller's scrollTop has already been zeroed by the teardown, so a cleanup-time read
|
|
// saves 0 and the restore is lost. Recording live sidesteps that (and StrictMode's synthetic
|
|
// unmount, which would otherwise persist a 0 too).
|
|
//
|
|
// The fade state lives in here rather than in App so that a flip re-renders this component alone —
|
|
// `children` arrives as an already-built element, so React skips the page subtree underneath.
|
|
export default function PageScroller({
|
|
children,
|
|
scrollKey,
|
|
}: {
|
|
children: ReactNode;
|
|
scrollKey?: string;
|
|
}) {
|
|
// How many sticky elements sit at the scroller's top right now (see ./stickyTop): the top fade is
|
|
// off while any is mounted — a table layout — and back on the moment none is (a card layout).
|
|
const [stickyTops, setStickyTops] = useState(0);
|
|
const registerStickyTop = useCallback(() => {
|
|
setStickyTops((n) => n + 1);
|
|
return () => setStickyTops((n) => n - 1);
|
|
}, []);
|
|
const { ref: fadeRef, style: fadeStyle } = useScrollFade(20, stickyTops === 0);
|
|
const fabUp = useBackToTopShown();
|
|
const cornerLift = useCornerLift();
|
|
const elRef = useRef<HTMLElement | null>(null);
|
|
// Stable identity: an inline ref callback changes every render, which makes React detach and
|
|
// re-attach the ref each commit — and since fadeRef is a state setter, that re-attach loops it
|
|
// into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
|
|
// once here is safe.
|
|
const setRef = useCallback(
|
|
(node: HTMLElement | null) => {
|
|
elRef.current = node;
|
|
fadeRef(node);
|
|
},
|
|
[fadeRef],
|
|
);
|
|
|
|
// A focused control removed by its own action (a row gone after Approve, a toolbar after Clear
|
|
// all) leaves the focus in its slot rather than on <body> — see useFocusKeeper.
|
|
useFocusKeeper(elRef);
|
|
|
|
const lastComposite = useRef<string | null>(null);
|
|
const cancelRef = useRef<() => void>(() => {});
|
|
|
|
// Restore (or reset to top) whenever the (history entry + content) identity changes — a page/sub-view
|
|
// navigation, a Back/Forward, or a feed filter change. Idempotent: a no-op when the composite is
|
|
// unchanged, so it's safe to call from several triggers. An effect event: the listeners below are
|
|
// bound once, not per scrollKey, and still read the live one.
|
|
const resolve = useEffectEvent(() => {
|
|
const el = elRef.current;
|
|
if (!el || scrollKey == null) return;
|
|
const key = `${ensureNavId()}:${scrollKey}`;
|
|
if (key === lastComposite.current) return;
|
|
lastComposite.current = key;
|
|
cancelRef.current();
|
|
cancelRef.current = restoreScroll(el, savedScroll.get(key) ?? 0);
|
|
});
|
|
const remember = useEffectEvent((el: HTMLElement) => {
|
|
if (scrollKey == null) return;
|
|
rememberScroll(`${peekNavId()}:${scrollKey}`, el.scrollTop);
|
|
});
|
|
|
|
// Trigger 1: mount + a content-key change (page switch, feed filter) — both re-render this component.
|
|
useLayoutEffect(() => {
|
|
resolve();
|
|
}, [scrollKey]);
|
|
|
|
// Trigger 2: Back/Forward (popstate) and a Plex sub-view open (nav bus) — neither necessarily
|
|
// re-renders this component. Also records the position continuously (not at unmount): the scroller's
|
|
// scrollTop is unreliable by the time an unmount cleanup runs, so a save-on-scroll survives teardown.
|
|
useLayoutEffect(() => {
|
|
const el = elRef.current;
|
|
if (!el) return;
|
|
const onScroll = () => remember(el);
|
|
const onNav = () => resolve();
|
|
el.addEventListener("scroll", onScroll, { passive: true });
|
|
window.addEventListener("popstate", onNav);
|
|
const unsubNav = subscribeNav(onNav);
|
|
return () => {
|
|
cancelRef.current();
|
|
el.removeEventListener("scroll", onScroll);
|
|
window.removeEventListener("popstate", onNav);
|
|
unsubNav();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
// `id` + `tabIndex={-1}`: the skip link's target (ui/SkipLink) takes the focus, so the next Tab
|
|
// lands on the page's first control. A click on the page's background focuses it too, which is
|
|
// the focus RESTING, not a choice a Back restore must respect (`FOCUS_REST_ATTRS`).
|
|
<main
|
|
ref={setRef}
|
|
id={CONTENT_ID}
|
|
tabIndex={-1}
|
|
{...FOCUS_REST_ATTRS}
|
|
style={fadeStyle}
|
|
className="flex-1 min-w-0 overflow-y-auto scrollbar-gutter-stable focus:outline-none"
|
|
>
|
|
<StickyTopProvider value={registerStickyTop}>{children}</StickyTopProvider>
|
|
{/* While Back to top floats over the corner, the page ends a button's height (3rem + its 1.25rem
|
|
inset) later, so its last row or pager can scroll out from under it. Only then: a spacer
|
|
always there would give a full-height layout a scrollbar it does not need. */}
|
|
{cornerLift > 0 ? (
|
|
// The download tray sits in the same corner (and the button, when up, over it).
|
|
<div
|
|
aria-hidden="true"
|
|
style={{ height: `calc(${cornerLift}px + ${fabUp ? "5.25rem" : "1.5rem"})` }}
|
|
/>
|
|
) : (
|
|
fabUp && <div aria-hidden="true" className="h-18" />
|
|
)}
|
|
</main>
|
|
);
|
|
}
|