Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 18 additions & 12 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
type RefObject,
} from "react";
import { useAuth } from "./auth";
import { appScroller } from "./lib/app-scroll";
import type Hls from "hls.js";
import hlsWorkerUrl from "hls.js/dist/hls.worker.js?url";
import {
Expand Down Expand Up @@ -321,7 +322,7 @@ function App() {
return false;
originScrollToRestore.current = null;
scrolledExpansion.current = selectionRef.current;
window.scrollTo({ top, behavior: "instant" });
appScroller().scrollTo({ top, behavior: "instant" });
const target =
area.querySelector<HTMLElement>(".play-action:not(:disabled)") ||
area.querySelector<HTMLElement>("h1");
Expand Down Expand Up @@ -512,7 +513,7 @@ function App() {
setEpisodeDestination(next.episodeDestination);
setSelected((old) => (old?.id === next.item ? old : null));
window.requestAnimationFrame(() =>
window.scrollTo({
appScroller().scrollTo({
behavior: "instant",
top: next.player
? 0
Expand Down Expand Up @@ -846,7 +847,7 @@ function App() {
.querySelector<HTMLInputElement>('input[aria-label="Search library"]')
?.focus(),
);
window.scrollTo({ top: 0 });
appScroller().scrollTo({ top: 0 });
} else if (next === "dashboard") {
if (dashboardOpen) return;

Expand All @@ -859,15 +860,15 @@ function App() {
setPlaybackDetailsId("");
setEpisodeDestination(null);
setDetailError("");
window.scrollTo({ top: 0 });
appScroller().scrollTo({ top: 0 });
} else if (next === "settings") {
if (settingsOpen) return;
libraryScroll.current = window.scrollY;
libraryScroll.current = appScroller().scrollTop;
writeLocation(tab, category, search, false, selectedId, true);
setSettingsOpen(true);
setDashboardOpen(false);
setSearchOpen(false);
window.scrollTo({ top: 0 });
appScroller().scrollTo({ top: 0 });
} else selectCategory(next);
};
const openDetails = (
Expand Down Expand Up @@ -964,7 +965,11 @@ function App() {
).href;
window.history.replaceState(window.history.state, "", url);
}
playerOrigin.current = { url, initialURL: url, scroll: window.scrollY };
playerOrigin.current = {
url,
initialURL: url,
scroll: appScroller().scrollTop,
};
}
const originSelection = playerOrigin.current
? locationSelection(playerOrigin.current.url)
Expand Down Expand Up @@ -1261,7 +1266,7 @@ function App() {
episodeTarget,
);
} else {
window.scrollTo({ top: 0, behavior: "smooth" });
appScroller().scrollTo({ top: 0, behavior: "smooth" });
}
};
const sectionCategories = useMemo(
Expand All @@ -1287,7 +1292,7 @@ function App() {
}
if (element) opener.current = element;
if (searchContext) setTab(item.kind as Tab);
libraryScroll.current = window.scrollY;
libraryScroll.current = appScroller().scrollTop;
scrolledExpansion.current = "";

setDetailError("");
Expand Down Expand Up @@ -1436,10 +1441,10 @@ function App() {
? lastVodTab.current
: destination;
navigateSection(next);
window.scrollTo({ top: 0 });
appScroller().scrollTo({ top: 0 });
}}
/>
<div className="library-layout">
<div className="library-layout" id="app-scroll">
<main id="main-content" className="main-content">
{providerSetupRequired && !settingsOpen && (
<AnimatedAlert variant="info" aria-label="Provider setup">
Expand Down Expand Up @@ -1585,7 +1590,7 @@ function App() {
now={now}
onBrowse={(kind) => {
selectCategory(kind);
window.scrollTo({ top: 0 });
appScroller().scrollTo({ top: 0 });
}}
onOpen={openHistory}
onResume={resumeDashboardTitle}
Expand Down Expand Up @@ -1891,6 +1896,7 @@ function App() {
(tab === "live" && items.length > visibleCount) ||
(tab !== "live" && hasMore && !browseError)) && (
<InfiniteScroll
scrollRoot="#app-scroll"
isLoading={browseLoading}
hasMore={
!discoveryContext &&
Expand Down
2 changes: 1 addition & 1 deletion web/src/components/discovery-dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,7 @@
}

.discovery-history-undo {
position: fixed;
position: absolute;
z-index: 60;
bottom: 24px;
left: calc((100% + var(--sidebar-width)) / 2);
Expand Down
6 changes: 4 additions & 2 deletions web/src/components/discovery-hero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import type { DiscoveryTitle, Item } from "../api";
import { appScroller } from "../lib/app-scroll";
import { isContinueWatching, type WatchHistoryEntry } from "../watch-history";
import { PlayButton } from "./ui/play-button";
import { Spinner } from "./spectrumui/spinner-dependencies";
Expand Down Expand Up @@ -94,8 +95,9 @@ export function DiscoveryHero({
useEffect(() => {
const update = () => setVisible(!document.hidden);
document.addEventListener("visibilitychange", update);
const observer = new IntersectionObserver(([entry]) =>
setInView(entry.isIntersecting),
const observer = new IntersectionObserver(
([entry]) => setInView(entry.isIntersecting),
{ root: appScroller() },
);
if (element.current) observer.observe(element.current);
return () => {
Expand Down
13 changes: 5 additions & 8 deletions web/src/components/navigation.css
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
:root {
--sidebar-width: 88px;
}
.app-shell {
padding-left: var(--sidebar-width);
}
.app-sidebar {
--navigation-control-size: 56px;
position: fixed;
inset: 0 auto 0 0;
grid-column: 1;
grid-row: 1;
min-height: 0;
z-index: 50;
display: grid;
grid-template-rows: var(--navigation-control-size) minmax(0, 1fr);
gap: 16px;
justify-items: center;
width: var(--sidebar-width);
height: var(--sidebar-viewport-height, 100%);
padding: max(16px, env(safe-area-inset-top)) 16px
max(16px, env(safe-area-inset-bottom));
background: transparent;
Expand Down Expand Up @@ -117,8 +114,8 @@
display: none;
}
.mobile-bottom-nav {
position: fixed;
inset: auto 0 0;
grid-column: 1;
grid-row: 2;
z-index: 50;
display: flex;
align-items: center;
Expand Down
1 change: 1 addition & 0 deletions web/src/components/search-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,7 @@ export function SearchPage({
})}
{showResults && hasMore && (
<InfiniteScroll
scrollRoot="#app-scroll"
isLoading={loading}
hasMore={hasMore}
next={() => {
Expand Down
25 changes: 14 additions & 11 deletions web/src/components/settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "react";
import { Fingerprint, RefreshCw, SlidersHorizontal } from "lucide-react";
import { useAuth } from "../auth";
import { appScroller } from "../lib/app-scroll";
import {
getSettings,
updateSettings,
Expand Down Expand Up @@ -130,6 +131,7 @@ export function SettingsPage({
};
useEffect(() => {
if (canAdmin && !settings) return;
const scroller = appScroller();
let frame = 0;
const update = () => {
frame = 0;
Expand All @@ -143,13 +145,17 @@ export function SettingsPage({
let current = sections[0].id;
for (const section of sections) {
const element = document.getElementById(section.id);
if (element && element.getBoundingClientRect().top <= offset)
if (
element &&
element.getBoundingClientRect().top <=
scroller.getBoundingClientRect().top + offset
)
current = section.id;
}
if (
window.scrollY > 0 &&
Math.ceil(window.scrollY + window.innerHeight) >=
document.documentElement.scrollHeight - 2
scroller.scrollTop > 0 &&
Math.ceil(scroller.scrollTop + scroller.clientHeight) >=
scroller.scrollHeight - 2
)
// Short final sections share a clamped scroll position.
current = sections.at(-1)!.id;
Expand All @@ -163,11 +169,7 @@ export function SettingsPage({
schedule();
};
const clearScrollbarJump = (event: PointerEvent) => {
if (
event.target === document.documentElement ||
event.target === document.body
)
clearJump();
if (event.target === scroller) clearJump();
};
const scrollKey = (event: KeyboardEvent) => {
if (event.defaultPrevented) return;
Expand Down Expand Up @@ -197,7 +199,7 @@ export function SettingsPage({
)
clearJump();
};
window.addEventListener("scroll", schedule, { passive: true });
scroller.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", clearJump);
window.addEventListener("wheel", clearJump, { passive: true });
window.addEventListener("touchmove", clearJump, { passive: true });
Expand All @@ -207,10 +209,11 @@ export function SettingsPage({
window.addEventListener("keydown", scrollKey);
const observer = new ResizeObserver(schedule);
if (pageRef.current) observer.observe(pageRef.current);
observer.observe(scroller);
schedule();
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", schedule);
scroller.removeEventListener("scroll", schedule);
window.removeEventListener("resize", clearJump);
window.removeEventListener("wheel", clearJump);
window.removeEventListener("touchmove", clearJump);
Expand Down
10 changes: 8 additions & 2 deletions web/src/components/spectrumui/infinite-scroll.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ interface InfiniteScrollProps {
hasMore: boolean;
next: () => void;
children: React.ReactNode;
scrollRoot?: string;
}

export default function InfiniteScroll({
isLoading,
hasMore,
next,
children,
scrollRoot,
}: InfiniteScrollProps) {
const observer = React.useRef<IntersectionObserver | null>(null);
const observerRef = React.useCallback(
Expand All @@ -31,12 +33,16 @@ export default function InfiniteScroll({
observer.current = null;
next();
},
{ threshold: 0, root: null, rootMargin: "300px 0px" },
{
threshold: 0,
root: scrollRoot ? element.closest(scrollRoot) : null,
rootMargin: "300px 0px",
},
);
observer.current = current;
current.observe(element);
},
[hasMore, isLoading, next],
[hasMore, isLoading, next, scrollRoot],
);
React.useEffect(() => () => observer.current?.disconnect(), []);
const items = React.Children.toArray(children);
Expand Down
4 changes: 4 additions & 0 deletions web/src/lib/app-scroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** The app owns one content scroller; navigation stays outside it. */
export function appScroller() {
return document.getElementById("app-scroll")!;
}
26 changes: 16 additions & 10 deletions web/src/lib/viewport.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/** Recover Chrome on iPad retaining its portrait viewport after fullscreen. */
export function trackViewportRecovery() {
/** Contain Chrome iOS fullscreen viewport inconsistencies at the app boundary. */
export function trackAppViewport() {
const viewport = document.querySelector<HTMLMetaElement>(
'meta[name="viewport"]',
);
Expand All @@ -8,8 +8,9 @@ export function trackViewportRecovery() {
const original = viewport.content;
const root = document.documentElement;
let appliedWidth = 0;
let timer: number | undefined;
let frame = 0;
const update = () => {
frame = 0;
const width = window.outerWidth;
const scale = window.visualViewport?.scale ?? 1;
// iOS can fit the previous explicit width to a smaller window on rotation.
Expand All @@ -26,11 +27,15 @@ export function trackViewportRecovery() {
)
return;

// The layout height can stay at 1499px while only 682px is visible, even
// after the width recovers. Keep fixed navigation inside the visible area.
// The shell occupies the visible viewport. Its content scrolls independently
// of the stale layout viewport, so navigation and content share one frame.
const height = window.visualViewport?.height ?? window.innerHeight;
if (height > 0)
root.style.setProperty("--sidebar-viewport-height", `${height}px`);
root.style.setProperty("--app-viewport-height", `${height}px`);
root.style.setProperty(
"--app-viewport-top",
`${window.visualViewport?.offsetTop ?? 0}px`,
);
if (
(!appliedWidth || appliedWidth === width) &&
Math.abs(window.innerWidth - width) <= 1
Expand All @@ -47,8 +52,7 @@ export function trackViewportRecovery() {
);
};
const schedule = () => {
window.clearTimeout(timer);
timer = window.setTimeout(update, 300);
if (!frame) frame = window.requestAnimationFrame(update);
};
const listeners = new AbortController();
const options = { signal: listeners.signal };
Expand All @@ -61,12 +65,14 @@ export function trackViewportRecovery() {
])
document.addEventListener(event, schedule, { ...options, capture: true });
window.visualViewport?.addEventListener("resize", schedule, options);
window.visualViewport?.addEventListener("scroll", schedule, options);
schedule();

return () => {
listeners.abort();
window.clearTimeout(timer);
root.style.removeProperty("--sidebar-viewport-height");
window.cancelAnimationFrame(frame);
root.style.removeProperty("--app-viewport-height");
root.style.removeProperty("--app-viewport-top");
if (appliedWidth) viewport.content = original;
};
}
4 changes: 2 additions & 2 deletions web/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { createRoot } from "react-dom/client";
import App from "./App";
import { AuthGate } from "./auth";
import { trackInputModality } from "./lib/input-modality";
import { trackViewportRecovery } from "./lib/viewport";
import { trackAppViewport } from "./lib/viewport";
import "@fontsource-variable/manrope";
import "./styles.css";

const stopTrackingInput = trackInputModality();
const stopTrackingViewport = trackViewportRecovery();
const stopTrackingViewport = trackAppViewport();
if (import.meta.hot)
import.meta.hot.dispose(() => {
stopTrackingInput();
Expand Down
Loading
Loading