fix(plex): the backdrop crossfade toggle wins over reduced motion once set

prefers-reduced-motion hard-disabled the rotation whatever the toggle said,
and Windows reports it whenever its animation effects are off — so the
owner never saw the crossfade. Untouched, the setting now follows the
system (and the toggle shows that state); a pick made on the toggle is
stored and wins on every device.
This commit is contained in:
2026-10-03 04:38:50 +02:00
parent e9c15baae0
commit 3e96211db0
2 changed files with 81 additions and 21 deletions
+28 -13
View File
@@ -1,4 +1,4 @@
import { useLayoutEffect } from "react";
import { useLayoutEffect, useSyncExternalStore } from "react";
import { usePref, useSavePref } from "./usePref";
import type { TFunction } from "i18next";
import type { PlexCard, PlexTmdb } from "./api";
@@ -7,6 +7,18 @@ import type { PlexCard, PlexTmdb } from "./api";
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
// plexDetailUi exports only components (Fast Refresh).
const REDUCED_MOTION = "(prefers-reduced-motion: reduce)";
const reducedMotion = () =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia(REDUCED_MOTION).matches;
const onReducedMotionChange = (cb: () => void) => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return () => {};
const m = window.matchMedia(REDUCED_MOTION);
m.addEventListener("change", cb);
return () => m.removeEventListener("change", cb);
};
export function useDetailPrefs() {
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
// undefined → true, only an explicit false hides. The detail pages mount after `me` is cached, so
@@ -14,9 +26,16 @@ export function useDetailPrefs() {
const [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
// E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent
// of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle.
const [crossfade, setCrossfade] = usePref<boolean>("plexInfoArtCrossfade", true);
// E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Untouched,
// it follows the system: off where the reader asked for less motion (prefers-reduced-motion — which
// Windows also reports whenever its animation effects are off). Once the reader turns it on or off
// here, that pick wins on every device: the owner never saw the crossfade, because the system signal
// used to switch it off whatever the toggle said (2026-10-03). The toggle shows the state in force.
const reduce = useSyncExternalStore(onReducedMotionChange, reducedMotion, () => false);
const [crossfadePick, setCrossfade] = usePref<boolean | null>("plexInfoArtCrossfade", null, {
parse: (raw) => (typeof raw === "boolean" ? raw : null),
});
const crossfade = crossfadePick ?? !reduce;
const savePref = useSavePref();
return {
artBg,
@@ -103,8 +122,8 @@ export function tmdbBackdropUrl(path: string): string {
// content, so the dissolve is visible under the chrome. Once faded, the settled image is committed to
// #root and the overlay reset to transparent — a double buffer, so only ever ONE extra DOM node.
//
// The rotation is HARD-OFF under prefers-reduced-motion (shows only the first image, no movement) and
// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored
// The rotation runs only when `animate` says so — the reader's crossfade setting, which already folds in
// prefers-reduced-motion (useDetailPrefs) — and there are at least two images; `attachment:fixed` keeps it viewport-anchored
// while the content scrolls. Cleared on unmount / disable / empty pool.
const BACKDROP_FADE_MS = 1600;
const BACKDROP_INTERVAL_MS = 7000;
@@ -148,13 +167,9 @@ export function useCrossfadeBackdrop(
}
paint(pool[0]!);
const reduce =
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Static case (one image / crossfade off / reduced motion): still MUST clear on unmount — otherwise
// #root keeps the painted backdrop after leaving the page (e.g. switching to another module).
if (!animate || reduce || pool.length < 2) return clear;
// Static case (one image / crossfade off): still MUST clear on unmount — otherwise #root keeps the
// painted backdrop after leaving the page (e.g. switching to another module).
if (!animate || pool.length < 2) return clear;
const layer = document.createElement("div");
Object.assign(layer.style, {