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:
@@ -2,12 +2,17 @@
|
|||||||
//
|
//
|
||||||
// E5 S8: the crossfading art backdrop hook. Covers the pieces that don't need real layout — that a
|
// 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),
|
// 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
|
// creates exactly one overlay layer when it may animate, and stays static (no overlay, first image
|
||||||
// image only) under prefers-reduced-motion, a single image, or when disabled. matchMedia is stubbed
|
// only) for a single image, the crossfade off, or when disabled. Then the crossfade setting itself:
|
||||||
// per-test; the hook reads it at effect time.
|
// 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 { describe, it, expect, afterEach, vi } from "vitest";
|
||||||
import { render, cleanup } from "@testing-library/react";
|
import { render, cleanup, renderHook, act } from "@testing-library/react";
|
||||||
import { useCrossfadeBackdrop } from "./plexDetail";
|
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) {
|
function mockReducedMotion(reduce: boolean) {
|
||||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||||
@@ -64,12 +69,11 @@ describe("useCrossfadeBackdrop", () => {
|
|||||||
expect(overlays().length).toBe(1);
|
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();
|
withRoot();
|
||||||
mockReducedMotion(true);
|
mockReducedMotion(true);
|
||||||
render(<Harness arts={["/a.jpg", "/b.jpg"]} />);
|
render(<Harness arts={["/a.jpg", "/b.jpg"]} />);
|
||||||
expect(root().style.backgroundImage).toContain('url("/a.jpg")'); // first image still painted
|
expect(overlays().length).toBe(1);
|
||||||
expect(overlays().length).toBe(0); // but no crossfade layer
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays static for a single image (no overlay)", () => {
|
it("stays static for a single image (no overlay)", () => {
|
||||||
@@ -101,3 +105,44 @@ describe("useCrossfadeBackdrop", () => {
|
|||||||
expect(root().style.isolation).toBe("");
|
expect(root().style.isolation).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("the crossfade setting", () => {
|
||||||
|
const prefsWith = (preferences: Record<string, unknown>) => {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useLayoutEffect } from "react";
|
import { useLayoutEffect, useSyncExternalStore } from "react";
|
||||||
import { usePref, useSavePref } from "./usePref";
|
import { usePref, useSavePref } from "./usePref";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import type { PlexCard, PlexTmdb } from "./api";
|
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
|
// prefs, the TMDB backdrop URL, the crossfading art backdrop hook and the episode label. Split out so
|
||||||
// plexDetailUi exports only components (Fast Refresh).
|
// 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() {
|
export function useDetailPrefs() {
|
||||||
// Three per-user detail-page toggles, each a server pref adopted on login (usePref). Default ON:
|
// 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
|
// 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 [artBg, setArtBg] = usePref<boolean>("plexInfoArtBg", true);
|
||||||
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
const [showCast, setShowCast] = usePref<boolean>("plexInfoCast", true);
|
||||||
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
const [showRelated, setShowRelated] = usePref<boolean>("plexInfoRelated", true);
|
||||||
// E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Independent
|
// E5 S8: slowly cross-dissolve the art backdrop through the item's backdrops/related art. Untouched,
|
||||||
// of prefers-reduced-motion, which hard-disables the rotation regardless of this toggle.
|
// it follows the system: off where the reader asked for less motion (prefers-reduced-motion — which
|
||||||
const [crossfade, setCrossfade] = usePref<boolean>("plexInfoArtCrossfade", true);
|
// 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();
|
const savePref = useSavePref();
|
||||||
return {
|
return {
|
||||||
artBg,
|
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
|
// 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.
|
// #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
|
// The rotation runs only when `animate` says so — the reader's crossfade setting, which already folds in
|
||||||
// when there are fewer than two images or it's disabled; `attachment:fixed` keeps it viewport-anchored
|
// 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.
|
// while the content scrolls. Cleared on unmount / disable / empty pool.
|
||||||
const BACKDROP_FADE_MS = 1600;
|
const BACKDROP_FADE_MS = 1600;
|
||||||
const BACKDROP_INTERVAL_MS = 7000;
|
const BACKDROP_INTERVAL_MS = 7000;
|
||||||
@@ -148,13 +167,9 @@ export function useCrossfadeBackdrop(
|
|||||||
}
|
}
|
||||||
paint(pool[0]!);
|
paint(pool[0]!);
|
||||||
|
|
||||||
const reduce =
|
// Static case (one image / crossfade off): still MUST clear on unmount — otherwise #root keeps the
|
||||||
typeof window !== "undefined" &&
|
// painted backdrop after leaving the page (e.g. switching to another module).
|
||||||
typeof window.matchMedia === "function" &&
|
if (!animate || pool.length < 2) return clear;
|
||||||
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;
|
|
||||||
|
|
||||||
const layer = document.createElement("div");
|
const layer = document.createElement("div");
|
||||||
Object.assign(layer.style, {
|
Object.assign(layer.style, {
|
||||||
|
|||||||
Reference in New Issue
Block a user