From 3e96211db08facfff713e35d3f2d87d7cebf8b2b Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 04:38:50 +0200 Subject: [PATCH] fix(plex): the backdrop crossfade toggle wins over reduced motion once set MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/lib/plexBackdrop.dom.test.tsx | 61 +++++++++++++++++++--- frontend/src/lib/plexDetail.ts | 41 ++++++++++----- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/plexBackdrop.dom.test.tsx b/frontend/src/lib/plexBackdrop.dom.test.tsx index c12e0826..e24c61de 100644 --- a/frontend/src/lib/plexBackdrop.dom.test.tsx +++ b/frontend/src/lib/plexBackdrop.dom.test.tsx @@ -2,12 +2,17 @@ // // E5 S8: the crossfading art backdrop hook. Covers the pieces that don't need real layout — that a // pool paints #root + isolates it (so the z-index:-1 overlay can paint above #root's own background), -// creates exactly one overlay layer when it may animate, and HARD-OFFS the rotation (no overlay, first -// image only) under prefers-reduced-motion, a single image, or when disabled. matchMedia is stubbed -// per-test; the hook reads it at effect time. +// creates exactly one overlay layer when it may animate, and stays static (no overlay, first image +// only) for a single image, the crossfade off, or when disabled. Then the crossfade setting itself: +// untouched it follows prefers-reduced-motion, and the reader's own pick wins over it (the owner, whose +// Windows animation effects are off, never saw the crossfade — 2026-10-03). matchMedia is stubbed per test. import { describe, it, expect, afterEach, vi } from "vitest"; -import { render, cleanup } from "@testing-library/react"; -import { useCrossfadeBackdrop } from "./plexDetail"; +import { render, cleanup, renderHook, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { createElement, type ReactNode } from "react"; +import { api } from "./api"; +import { qk } from "./queryKeys"; +import { useCrossfadeBackdrop, useDetailPrefs } from "./plexDetail"; function mockReducedMotion(reduce: boolean) { window.matchMedia = vi.fn().mockImplementation((query: string) => ({ @@ -64,12 +69,11 @@ describe("useCrossfadeBackdrop", () => { expect(overlays().length).toBe(1); }); - it("hard-offs the rotation under prefers-reduced-motion (static, no overlay)", () => { + it("animates under prefers-reduced-motion once the reader's setting says so", () => { withRoot(); mockReducedMotion(true); render(); - expect(root().style.backgroundImage).toContain('url("/a.jpg")'); // first image still painted - expect(overlays().length).toBe(0); // but no crossfade layer + expect(overlays().length).toBe(1); }); it("stays static for a single image (no overlay)", () => { @@ -101,3 +105,44 @@ describe("useCrossfadeBackdrop", () => { expect(root().style.isolation).toBe(""); }); }); + +describe("the crossfade setting", () => { + const prefsWith = (preferences: Record) => { + const qc = new QueryClient(); + qc.setQueryData(qk.me(), { preferences }); + return ({ children }: { children: ReactNode }) => + createElement(QueryClientProvider, { client: qc }, children); + }; + + it.each([ + [false, true], + [true, false], + ])("untouched, follows the system (reduced motion %s → crossfade %s)", (reduce, on) => { + mockReducedMotion(reduce); + const { result } = renderHook(() => useDetailPrefs(), { wrapper: prefsWith({}) }); + expect(result.current.crossfade).toBe(on); + }); + + it("keeps the reader's own pick over the system", () => { + mockReducedMotion(true); + const on = renderHook(() => useDetailPrefs(), { + wrapper: prefsWith({ plexInfoArtCrossfade: true }), + }); + expect(on.result.current.crossfade).toBe(true); + mockReducedMotion(false); + const off = renderHook(() => useDetailPrefs(), { + wrapper: prefsWith({ plexInfoArtCrossfade: false }), + }); + expect(off.result.current.crossfade).toBe(false); + }); + + it("turns on from the state in force, and stores that as the reader's pick", () => { + mockReducedMotion(true); + const save = vi.spyOn(api, "savePrefs").mockResolvedValue({} as never); + const { result } = renderHook(() => useDetailPrefs(), { wrapper: prefsWith({}) }); + act(() => result.current.toggleCrossfade()); + expect(result.current.crossfade).toBe(true); + expect(save).toHaveBeenCalledWith({ plexInfoArtCrossfade: true }); + save.mockRestore(); + }); +}); diff --git a/frontend/src/lib/plexDetail.ts b/frontend/src/lib/plexDetail.ts index 3834d464..ece8475b 100644 --- a/frontend/src/lib/plexDetail.ts +++ b/frontend/src/lib/plexDetail.ts @@ -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("plexInfoArtBg", true); const [showCast, setShowCast] = usePref("plexInfoCast", true); const [showRelated, setShowRelated] = usePref("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("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("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, {