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
57 changes: 43 additions & 14 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "./components/watch-suggestions";
import { useWatchHistory, type WatchHistoryEntry } from "./watch-history";
import { createContentPolicy } from "./content-preferences";
import { requestViewportRecovery } from "./lib/viewport";
import { Spinner } from "./components/spectrumui/spinner-dependencies";
import InfiniteScroll from "./components/spectrumui/infinite-scroll";
import { AnimatedAlert } from "./components/spectrumui/animated-alert";
Expand Down Expand Up @@ -260,11 +261,13 @@ function App() {
const [playerRetrying, setPlayerRetrying] = useState(false);
const playerRetryMessage = useRef("");
const playerOrigin = useRef<PlayerOrigin | null>(playerOriginFromHistory());
const originScrollToRestore = useRef<number | null>(null);
const playerOpenRef = useRef(playerOpen);
playerOpenRef.current = playerOpen;
const pendingPlayback = useRef<AbortController | null>(null);
const navigationRevision = useRef(0);
const cancelPlaybackIntent = useCallback(() => {
originScrollToRestore.current = null;
navigationRevision.current++;
pendingPlayback.current?.abort();
pendingPlayback.current = null;
Expand Down Expand Up @@ -310,6 +313,22 @@ function App() {
const scrolledExpansion = useRef("");
const selectionRef = useRef(selectedId);
selectionRef.current = selectedId;
const restoreOriginScroll = useCallback(() => {
const top = originScrollToRestore.current;
const area = expandedArea.current;
// Search animates its returned details. Wait for their final height so the
// browser can restore positions near the bottom without clamping the scroll.
if (top === null || playerOpenRef.current || area?.style.height !== "auto")
return false;
originScrollToRestore.current = null;
scrolledExpansion.current = selectionRef.current;
window.scrollTo({ top, behavior: "instant" });
const target =
area.querySelector<HTMLElement>(".play-action:not(:disabled)") ||
area.querySelector<HTMLElement>("h1");
target?.focus({ preventScroll: true });
return true;
}, []);
const [catalogGrid, setCatalogGrid] = useState<HTMLDivElement | null>(null);
const [gridColumns, setGridColumns] = useState(1);
const libraryScroll = useRef(0);
Expand Down Expand Up @@ -474,6 +493,8 @@ function App() {
previousOrigin &&
new URL(previousOrigin.url, window.location.href).href ===
window.location.href;
if (!next.player && returningToOrigin && next.item && previousOrigin)
originScrollToRestore.current = previousOrigin.scroll;
playerOpenRef.current = next.player;
playerOrigin.current = next.player ? playerOriginFromHistory() : null;

Expand All @@ -493,6 +514,7 @@ function App() {
setSelected((old) => (old?.id === next.item ? old : null));
window.requestAnimationFrame(() =>
window.scrollTo({
behavior: "instant",
top: next.player
? 0
: returningToOrigin && previousOrigin
Expand All @@ -504,6 +526,9 @@ function App() {
window.addEventListener("popstate", restore);
return () => window.removeEventListener("popstate", restore);
}, [cancelPlaybackIntent]);
useLayoutEffect(() => {
if (!playerOpen) restoreOriginScroll();
}, [playerOpen, selectedId, selected, restoreOriginScroll]);
useEffect(() => {
if (!selectedId || selected?.id === selectedId || !status?.configured)
return;
Expand Down Expand Up @@ -985,7 +1010,6 @@ function App() {
title: playbackTitle,
});
setPlaybackDuration(0);
window.scrollTo({ top: 0 });
},
[liveItems, browseItems, categories, tab, category, cancelPlaybackIntent],
);
Expand Down Expand Up @@ -1040,14 +1064,14 @@ function App() {
searchContext,
]);
const stopPlayback = () => {
if (!playerOpenRef.current) return;
cancelPlaybackIntent();
playerOpenRef.current = false;
const title = playback?.title || selected;
const episode = playback?.item.kind === "episode" ? playback.item : null;
const origin = playerOrigin.current;
setPlayback(null);
setPlayerOpen(false);
setPlayerError("");
// Let route restoration close the player and reveal its origin together.
// Closing it before asynchronous history.back() temporarily unmounts Search.
if (origin && origin.initialURL && origin.url !== origin.initialURL) {
window.history.replaceState({}, "", origin.url);
window.dispatchEvent(new PopStateEvent("popstate"));
Expand Down Expand Up @@ -1195,15 +1219,6 @@ function App() {
status?.configured,
playerRetry,
]);
useEffect(() => {
if (!playerOpen || !playback) return;
const frame = window.requestAnimationFrame(() =>
document
.querySelector<HTMLElement>(".watch-sidebar .dock-title")
?.focus({ preventScroll: true }),
);
return () => window.cancelAnimationFrame(frame);
}, [playerOpen, playback?.item.id]);
const playbackCategory =
playback &&
categories.find(
Expand Down Expand Up @@ -1325,7 +1340,11 @@ function App() {
if (
(playerOpenRef.current &&
playingTitleRef.current === selectedId) ||
selectionRef.current !== selectedId ||
selectionRef.current !== selectedId
)
return;
if (
restoreOriginScroll() ||
scrolledExpansion.current === selectedId
)
return;
Expand Down Expand Up @@ -2378,6 +2397,14 @@ function PlaybackPlayer({
const historyFinished = useRef(false);
const playbackArea = useRef<HTMLDivElement>(null);
const video = useRef<HTMLVideoElement>(null);
useLayoutEffect(() => {
const area = playbackArea.current;
if (!isPresent || !area) return;
// Reveal the committed player before loading the stream. An instant scroll
// also cancels any smooth title reveal left over from the browsing view.
area.scrollIntoView({ block: "start", behavior: "instant" });
area.focus({ preventScroll: true });
}, [isPresent, selection]);
const { style: subtitleStyle } = useSubtitleAppearance();
const sessionId = useRef<string | null>(null);
const seekVersion = useRef(0);
Expand Down Expand Up @@ -2447,12 +2474,14 @@ function PlaybackPlayer({
action: "play" | "pause";
} | null>(null);
const feedbackSequence = useRef(0);
useEffect(() => () => requestViewportRecovery(), []);
useLayoutEffect(() => {
if (isPresent) return;
saveProgressRef.current();
onFlushProgress();
pendingPlayUrlRef.current = null;
clearMediaRef.current?.();
requestViewportRecovery();
}, [isPresent, onFlushProgress]);
useEffect(() => {
if (!isPresent) return;
Expand Down
48 changes: 29 additions & 19 deletions web/src/components/navigation.css
Original file line number Diff line number Diff line change
Expand Up @@ -5,60 +5,62 @@
padding-left: var(--sidebar-width);
}
.app-sidebar {
--navigation-control-size: 56px;
position: fixed;
inset: 0 auto 0 0;
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
display: grid;
grid-template-rows: var(--navigation-control-size) minmax(0, 1fr);
gap: 16px;
justify-items: center;
width: var(--sidebar-width);
height: 100dvh;
padding: 16px 16px max(16px, env(safe-area-inset-bottom));
padding: max(16px, env(safe-area-inset-top)) 16px
max(16px, env(safe-area-inset-bottom));
background: transparent;
}
.sidebar-brand {
display: grid;
flex: none;
place-items: center;
width: 56px;
height: 56px;
width: var(--navigation-control-size);
height: var(--navigation-control-size);
padding: 0;
border: 0;
border-radius: 8px;
background: transparent;
}
.side-navigation {
display: flex;
flex: 1;
flex-direction: column;
display: grid;
grid-template-rows: minmax(0, 1fr) var(--navigation-control-size);
gap: 16px;
align-items: center;
width: 100%;
min-height: 0;
}
.navigation-main {
position: absolute;
top: 50%;
left: 16px;
right: 16px;
display: flex;
flex-direction: column;
align-items: center;
align-self: center;
gap: 16px;
transform: translateY(-50%);
width: 100%;
max-height: 100%;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
}
.navigation-footer {
display: grid;
flex: none;
place-items: center;
width: 100%;
height: 56px;
margin-top: auto;
height: var(--navigation-control-size);
}
.navigation-icon {
--focus-indicator-bottom: 6px;
position: relative;
width: 56px;
height: 56px;
width: var(--navigation-control-size, 56px);
height: var(--navigation-control-size, 56px);
padding: 0;
border: 0;
border-radius: 8px;
Expand Down Expand Up @@ -98,6 +100,14 @@
.mobile-bottom-nav {
display: none;
}
@media (max-height: 500px) {
.app-sidebar {
--navigation-control-size: 44px;
}
.navigation-main {
gap: 8px;
}
}
@media (max-width: 820px) {
:root {
--sidebar-width: 0px;
Expand Down
132 changes: 132 additions & 0 deletions web/src/lib/viewport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
const viewportCheckEvent = "restream:viewport-check";

export function requestViewportRecovery() {
window.dispatchEvent(new Event(viewportCheckEvent));
}

/** Recover stale mobile viewport constraints after rotation or fullscreen playback. */
export function trackViewportOrientation() {
const viewport = document.querySelector<HTMLMetaElement>(
'meta[name="viewport"]',
);
const touch =
navigator.maxTouchPoints > 0 ||
window.matchMedia("(any-pointer: coarse)").matches;
if (!viewport || !touch) return () => {};

const content = viewport.content;
let timer: ReturnType<typeof setTimeout> | undefined;
let frame = 0;
let pending = false;
let fullscreenExitPending = false;
let nativeFullscreen: HTMLVideoElement | null = null;
const fullscreenDocument = document as Document & {
webkitFullscreenElement?: Element | null;
};

const restore = () => {
viewport.content = content;
frame = 0;
};
const resized = () => {
if (pending) schedule();
};
const schedule = () => {
pending = true;
clearTimeout(timer);
// Fullscreen and orientation events can precede iOS browser chrome resizing.
timer = setTimeout(() => {
if (nativeFullscreen && !nativeFullscreen.isConnected)
nativeFullscreen = null;
if (
document.hidden ||
document.fullscreenElement ||
fullscreenDocument.webkitFullscreenElement ||
nativeFullscreen
)
return;
pending = false;
const width = document.documentElement.clientWidth;
const landscape = screen.orientation
? screen.orientation.type.startsWith("landscape")
: Math.abs(window.orientation) === 90;
const screenWidth = landscape
? Math.max(screen.width, screen.height)
: Math.min(screen.width, screen.height);
const scale = window.visualViewport?.scale ?? 1;
// Fullscreen can leave the orientation reading stale as well as the viewport.
const needsRecovery =
(fullscreenExitPending || Math.abs(width - screenWidth) > 1) &&
Math.abs(scale - 1) < 0.01;
fullscreenExitPending = false;
if (!needsRecovery) return;

// Reapply device-width through a temporary explicit width. This asks the
// browser to recalculate its viewport without reloading React or locking zoom.
viewport.content = content.replace(
/width\s*=\s*device-width/i,
`width=${width}`,
);
cancelAnimationFrame(frame);
frame = requestAnimationFrame(restore);
}, 300);
};
const fullscreenChanged = () => {
if (
document.fullscreenElement ||
fullscreenDocument.webkitFullscreenElement ||
nativeFullscreen
) {
clearTimeout(timer);
if (frame) {
cancelAnimationFrame(frame);
restore();
}
} else {
fullscreenExitPending = true;
schedule();
}
};
const nativeFullscreenBegan = (event: Event) => {
if (!(event.target instanceof HTMLVideoElement)) return;
nativeFullscreen = event.target;
fullscreenChanged();
};
const nativeFullscreenEnded = () => {
nativeFullscreen = null;
fullscreenExitPending = true;
schedule();
};
const visibilityChanged = () => {
if (!document.hidden && pending) schedule();
};

const listeners = new AbortController();
const options = { signal: listeners.signal };
screen.orientation?.addEventListener("change", schedule, options);
window.addEventListener("orientationchange", schedule, options);
window.addEventListener(viewportCheckEvent, schedule, options);
window.addEventListener("resize", resized, options);
window.visualViewport?.addEventListener("resize", resized, options);
document.addEventListener("fullscreenchange", fullscreenChanged, options);
document.addEventListener(
"webkitfullscreenchange",
fullscreenChanged,
options,
);
document.addEventListener("webkitbeginfullscreen", nativeFullscreenBegan, {
...options,
capture: true,
});
document.addEventListener("webkitendfullscreen", nativeFullscreenEnded, {
...options,
capture: true,
});
document.addEventListener("visibilitychange", visibilityChanged, options);
return () => {
listeners.abort();
clearTimeout(timer);
cancelAnimationFrame(frame);
if (frame) restore();
};
}
Loading
Loading