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
16 changes: 8 additions & 8 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ 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 @@ -651,11 +650,13 @@ function App() {

useEffect(() => {
if (!catalogGrid) return;
const measure = () =>
const measure = () => {
if (!catalogGrid.clientWidth) return;
setGridColumns(
getComputedStyle(catalogGrid).gridTemplateColumns.split(" ").length ||
1,
);
};
const observer = new ResizeObserver(measure);
measure();
observer.observe(catalogGrid);
Expand Down Expand Up @@ -1343,11 +1344,12 @@ function App() {
selectionRef.current !== selectedId
)
return;
if (
restoreOriginScroll() ||
scrolledExpansion.current === selectedId
)
if (originScrollToRestore.current !== null) {
// Motion restores height:auto after the completion callback.
window.requestAnimationFrame(restoreOriginScroll);
return;
}
if (scrolledExpansion.current === selectedId) return;
scrolledExpansion.current = selectedId;
expandedArea.current?.scrollIntoView({
block: "start",
Expand Down Expand Up @@ -2474,14 +2476,12 @@ 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
168 changes: 50 additions & 118 deletions web/src/lib/viewport.ts
Original file line number Diff line number Diff line change
@@ -1,132 +1,64 @@
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() {
/** Recover Chrome on iPad retaining its portrait viewport after fullscreen. */
export function trackViewportRecovery() {
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;
if (!viewport || !/CriOS\//.test(navigator.userAgent)) 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 = () => {
const original = viewport.content;
let appliedWidth = 0;
let timer: number | undefined;
const update = () => {
const width = window.outerWidth;
const scale = window.visualViewport?.scale ?? 1;
// iOS can fit the previous explicit width to a smaller window on rotation.
const fittedToWindow =
appliedWidth > width && Math.abs(scale - width / appliedWidth) < 0.01;
const video = document.querySelector("video") as
(HTMLVideoElement & { webkitDisplayingFullscreen?: boolean }) | null;
if (
!width ||
document.hidden ||
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();
video?.webkitDisplayingFullscreen ||
(Math.abs(scale - 1) > 0.01 && !fittedToWindow)
)
return;
if (
(!appliedWidth || appliedWidth === width) &&
Math.abs(window.innerWidth - width) <= 1
)
return;

// On the affected iPad, outerWidth correctly becomes 1180 while innerWidth,
// screen.width and screen.orientation remain at their portrait values.
// Keep this explicit width in sync with subsequent rotation/window changes.
appliedWidth = width;
viewport.content = original.replace(
/width\s*=\s*[^,;]+/i,
`width=${width}`,
);
};
const visibilityChanged = () => {
if (!document.hidden && pending) schedule();
const schedule = () => {
window.clearTimeout(timer);
timer = window.setTimeout(update, 300);
};

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);
for (const event of ["resize", "orientationchange"])
window.addEventListener(event, schedule, options);
for (const event of [
"fullscreenchange",
"webkitendfullscreen",
"visibilitychange",
])
document.addEventListener(event, schedule, { ...options, capture: true });
window.visualViewport?.addEventListener("resize", schedule, options);
schedule();

return () => {
listeners.abort();
clearTimeout(timer);
cancelAnimationFrame(frame);
if (frame) restore();
window.clearTimeout(timer);
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 { trackViewportOrientation } from "./lib/viewport";
import { trackViewportRecovery } from "./lib/viewport";
import "@fontsource-variable/manrope";
import "./styles.css";

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