services/screensaver.jsxjavascript
/*
  Combines idle detection (useIdle) with the configured timeout/fade values
  (useConfig) into a single ScreensaverContext. Waking from idle also resets
  the kiosk session: the app navigates back to the app-selection screen ("/")
  while the screensaver is still fading out, so the next visitor starts fresh
  instead of inheriting the previous visitor's route, open menus and
  selections. The page unmount is what clears that per-page state - no
  explicit reset needed. The reset deliberately happens on wake-up rather
  than when going idle: the screensaver video is defined by the current
  route's content file (see Screensaver.jsx), and navigating away at idle
  start would unload it mid-show.

  Must be mounted inside the Router (see src/HashRouter.jsx): useConfig (via
  useSearchParams) requires router context, and the session reset needs
  useNavigate/useLocation. Same reason ContentProvider now lives in
  HashRouter.jsx instead of App.jsx - TrackingProvider/TabProvider stay in
  App.jsx since they don't use useConfig.
*/

// allow export of contexts and hooks - shouldn't be flagged as non-component
/* eslint react-refresh/only-export-components: 0 */

import { createContext, useContext, useEffect, useRef } from "react";
import PropTypes from "prop-types";
import { useNavigate, useLocation } from "react-router";
import { useConfig } from "./config/config";
import { useIdle } from "../hooks/idle";

const ScreensaverContext = createContext();

/**
 * Tracks idle state and exposes it via ScreensaverContext.
 */
export const ScreensaverProvider = ({ children }) => {
  const { screensaverTimeout, screensaverFadeDuration } = useConfig();
  // screensaverTimeout is in seconds (see config.jsx), useIdle expects ms.
  const { isIdle, trigger, forceIdle } = useIdle(screensaverTimeout * 1000);
  const navigate = useNavigate();
  const { pathname, search } = useLocation();

  // session reset on the idle -> active transition only (not on mount, not
  // while idle): happens behind the still-opaque fading screensaver, no view
  // transition needed; keeps the kiosk config params (config-schema.js) alive
  const wasIdleRef = useRef(false);
  useEffect(() => {
    if (wasIdleRef.current && !isIdle && pathname !== "/") navigate(`/${search}`);
    wasIdleRef.current = isIdle;
  }, [isIdle, pathname, search, navigate]);

  return (
    <ScreensaverContext.Provider
      value={{ isIdle, trigger, screensaverFadeDuration, showNow: forceIdle }}>
      {children}
    </ScreensaverContext.Provider>
  );
};

ScreensaverProvider.propTypes = {
  children: PropTypes.node.isRequired,
};

/**
 * React hook to access { isIdle, trigger, screensaverFadeDuration, showNow }.
 * Must be used within a `<ScreensaverProvider>`.
 */
export const useScreensaver = () => {
  return useContext(ScreensaverContext);
};