import { useCallback, useEffect, useRef } from 'react';
import { usePlaybackStore } from '../store/usePlaybackStore';
import { useVibeStore } from '../store/useVibeStore';
import { trackService } from '../services/trackService';
import { advancePastUnplayableVibeTrack, reportVibeEvent } from '../services/vibeSession';
import { PREFETCH_LEAD_SECONDS, PREFETCH_START_SECONDS } from '../lib/playbackPrefs';
import type { Track } from '../types';

// Threshold (seconds) above which a store position change is treated as a user
// scrub and applied to the audio element. Keeps the timeupdate -> setPosition ->
// effect loop from fighting itself.
const SEEK_THRESHOLD = 1;

// If a track reaches this fraction of its duration, treat it as "effectively
// completed" even if the user clicks Next before the very end.
const COMPLETION_THRESHOLD = 0.95;

// Relative seek increment (seconds) for MediaSession seekforward/seekbackward.
const SEEK_INCREMENT = 10;

// Fade granularity. Fine enough to be inaudible, coarse enough to be cheap.
const FADE_TICK_MS = 40;

// With crossfade off there is no overlap to hide Vibe's replan round-trip, so
// the handover still starts this early — the next element just waits, silent,
// until the current one actually ends.
const HANDOFF_LEAD_MS = 1200;

// If the outgoing element never reports `ended` (a stalled or broken stream),
// start the waiting track anyway this long after its lead began.
const JOIN_TIMEOUT_MS = HANDOFF_LEAD_MS + 2000;

/** Build the artwork URLs for MediaSession metadata (OS media controls). */
function buildArtwork(track: Track): MediaImage[] {
  const sizes = [96, 128, 192, 256, 384, 512];
  const artwork = track.artwork_id;
  if (!artwork) return [];
  // artwork_id is either a full URL (external) or a relative path served by us.
  const url = artwork.startsWith('http')
    ? artwork
    : `${window.location.origin}${artwork}`;
  return sizes.map((s) => ({ src: url, sizes: `${s}x${s}`, type: 'image/jpeg' }));
}

/**
 * The id of the track ordinary queue navigation will play next, or null when it
 * cannot be known ahead of time (shuffle) or there is nothing after this one.
 * Vibe's next track comes from the server, so this is only a prediction there —
 * used to warm the stream, never to decide what actually plays.
 */
function predictNextTrackId(): string | null {
  const { queue, currentTrack, currentIndex, shuffle, repeat } = usePlaybackStore.getState();
  if (shuffle || repeat === 'one' || queue.length === 0) return null;
  const idx =
    currentIndex >= 0 && queue[currentIndex]?.id === currentTrack?.id
      ? currentIndex
      : currentTrack
        ? queue.findIndex((t) => t.id === currentTrack.id)
        : -1;
  if (idx < 0) return null;
  if (idx + 1 < queue.length) return queue[idx + 1].id;
  if (repeat === 'all') return queue[0].id;
  return null;
}

// Headless audio engine: two <audio> elements driven by the playback store, so
// the next track can buffer (and fade in) while the current one is still
// playing. State -> DOM via store subscriptions; DOM -> state via media events.
export const AudioEngine = () => {
  const audioARef = useRef<HTMLAudioElement>(null);
  const audioBRef = useRef<HTMLAudioElement>(null);

  // Index of the element the store's currentTrack is playing on; the other one
  // is idle, prefetching, or fading out after being retired.
  const activeIdxRef = useRef(0);
  // Per-element fade multiplier applied on top of the store's master volume.
  const gainRef = useRef([1, 1]);
  const fadeTimerRef = useRef<Array<ReturnType<typeof setInterval> | null>>([null, null]);
  // The idle element holds this track id, loaded but never yet played.
  const preparedRef = useRef<{ id: string; idx: number } | null>(null);
  // Set when the handover window triggered the track change before `ended`, so
  // the ending element's own event does not advance a second time.
  const earlyAdvanceRef = useRef(false);
  // Cancels a pending gapless join (next track loaded, waiting for the tail).
  const cancelJoinRef = useRef<(() => void) | null>(null);

  // Track which id is currently loaded into the active element, and whether it
  // ended naturally (so we record COMPLETED, not skip, on the track change).
  const loadedIdRef = useRef<string | null>(null);
  const endedNaturallyRef = useRef(false);
  // Track whether the current track has crossed the completion threshold.
  const crossedThresholdRef = useRef(false);
  const lastProgressSecondRef = useRef(-1);
  const streamErrorTrackIdRef = useRef<string | null>(null);

  const elements = useCallback((): HTMLAudioElement[] => {
    const a = audioARef.current;
    const b = audioBRef.current;
    return a && b ? [a, b] : [];
  }, []);

  const applyGain = useCallback((idx: number) => {
    const els = elements();
    if (!els[idx]) return;
    const master = usePlaybackStore.getState().volume;
    els[idx].volume = Math.min(1, Math.max(0, master * gainRef.current[idx]));
  }, [elements]);

  const stopFade = useCallback((idx: number) => {
    const timer = fadeTimerRef.current[idx];
    if (timer !== null) {
      clearInterval(timer);
      fadeTimerRef.current[idx] = null;
    }
  }, []);

  const setGain = useCallback((idx: number, gain: number) => {
    stopFade(idx);
    gainRef.current[idx] = Math.min(1, Math.max(0, gain));
    applyGain(idx);
  }, [applyGain, stopFade]);

  /** Ramp one element's gain, then run `onDone`. Instant when ms <= 0. */
  const fadeTo = useCallback((idx: number, target: number, ms: number, onDone?: () => void) => {
    stopFade(idx);
    if (ms <= 0) {
      setGain(idx, target);
      onDone?.();
      return;
    }
    const from = gainRef.current[idx];
    const steps = Math.max(1, Math.round(ms / FADE_TICK_MS));
    let step = 0;
    fadeTimerRef.current[idx] = setInterval(() => {
      step += 1;
      gainRef.current[idx] = from + ((target - from) * step) / steps;
      applyGain(idx);
      if (step >= steps) {
        stopFade(idx);
        setGain(idx, target);
        onDone?.();
      }
    }, FADE_TICK_MS);
  }, [applyGain, setGain, stopFade]);

  /** Take an element out of service: silence it and drop its stream. */
  const retire = useCallback((idx: number) => {
    const els = elements();
    const el = els[idx];
    if (!el) return;
    stopFade(idx);
    el.pause();
    el.removeAttribute('src');
    el.load();
    setGain(idx, 1);
  }, [elements, setGain, stopFade]);

  const cancelJoin = useCallback(() => {
    const cancel = cancelJoinRef.current;
    cancelJoinRef.current = null;
    cancel?.();
  }, []);

  /**
   * Crossfade-off handover: the next track is already loaded and the store has
   * already moved on, but the outgoing element still has its tail to play.
   * Start the new one the moment that tail ends, so the join has no gap and no
   * overlap.
   */
  const scheduleJoin = useCallback((oldIdx: number, nextIdx: number) => {
    const els = elements();
    if (els.length === 0) return;
    const oldEl = els[oldIdx];
    let settled = false;

    const cleanup = () => {
      clearTimeout(timer);
      oldEl.removeEventListener('ended', join);
      oldEl.removeEventListener('error', join);
    };

    function join() {
      if (settled) return;
      settled = true;
      cleanup();
      cancelJoinRef.current = null;
      retire(oldIdx);
      // A newer track change may already own the pipeline.
      if (activeIdxRef.current !== nextIdx) return;
      if (!usePlaybackStore.getState().isPlaying) return;
      setGain(nextIdx, 1);
      void els[nextIdx].play().catch(() => {});
    }

    const timer = setTimeout(join, JOIN_TIMEOUT_MS);
    oldEl.addEventListener('ended', join);
    oldEl.addEventListener('error', join);
    cancelJoinRef.current = () => {
      if (settled) return;
      settled = true;
      cleanup();
      retire(oldIdx);
    };
  }, [elements, retire, setGain]);

  // --- DOM -> store: media events -----------------------------------------
  useEffect(() => {
    const els = elements();
    if (els.length === 0) return;

    const store = usePlaybackStore.getState;

    /** Warm the predicted next track into the idle element. */
    const prefetch = () => {
      const playback = store();
      if (!playback.prefetchNext || playback.audioElsewhere) return;
      const nextId = predictNextTrackId();
      if (!nextId || nextId === loadedIdRef.current) return;
      const idleIdx = 1 - activeIdxRef.current;
      // Buffering starts early enough that a Vibe replan can change the answer
      // underneath us. Re-point the idle element rather than arriving at the
      // handover with the wrong track warmed; leave a tail still playing alone.
      const prepared = preparedRef.current;
      if (prepared && (prepared.id === nextId || prepared.idx !== idleIdx)) return;
      const idle = els[idleIdx];
      if (prepared && !idle.paused) return;
      setGain(idleIdx, 1);
      idle.preload = 'auto';
      idle.src = trackService.getStreamUrl(nextId);
      idle.load();
      preparedRef.current = { id: nextId, idx: idleIdx };
    };

    /**
     * Hand over to the next track before `ended`, so Vibe's feedback/replan
     * round-trip happens while the tail of this one is still playing. With a
     * crossfade the tail fades under the new track; without one the new track
     * waits, silent, for the tail to finish.
     */
    const maybeAdvanceEarly = (audio: HTMLAudioElement) => {
      const playback = store();
      const leadMs = playback.crossfadeMs > 0 ? playback.crossfadeMs : HANDOFF_LEAD_MS;
      if (earlyAdvanceRef.current || !playback.isPlaying) return;
      if (!Number.isFinite(audio.duration) || audio.duration <= 0) return;
      if (audio.duration * 1000 <= leadMs * 2) return;
      if (audio.duration - audio.currentTime > leadMs / 1000) return;
      const vibeOwned = playback.queueOwner === 'vibe' && !!playback.vibeAdvanceHandler;
      // Ordinary playback must have somewhere to go; otherwise let the element
      // finish on its own so end-of-queue still stops cleanly.
      if (!vibeOwned && !predictNextTrackId()) return;
      earlyAdvanceRef.current = true;
      endedNaturallyRef.current = true;
      store().nextWithReason('completed');
    };

    const onTimeUpdate = (idx: number, audio: HTMLAudioElement) => {
      if (idx !== activeIdxRef.current) return;
      store().setPosition(audio.currentTime);
      // Mark as effectively completed if we cross the threshold.
      if (
        !crossedThresholdRef.current &&
        audio.duration &&
        audio.currentTime / audio.duration >= COMPLETION_THRESHOLD
      ) {
        crossedThresholdRef.current = true;
      }
      const vibe = useVibeStore.getState();
      const track = store().currentTrack;
      const elapsed = Math.floor(audio.currentTime);
      if (vibe.activeSessionId && store().queueOwner === 'vibe' && track && elapsed > 0 && elapsed % 30 === 0 && elapsed !== lastProgressSecondRef.current) {
        lastProgressSecondRef.current = elapsed;
        void reportVibeEvent('progress', track.id, Math.round(audio.currentTime * 1000), Math.round((audio.duration || 0) * 1000)).catch(() => undefined);
      }
      if (
        audio.currentTime >= PREFETCH_START_SECONDS ||
        (Number.isFinite(audio.duration) && audio.duration - audio.currentTime <= PREFETCH_LEAD_SECONDS)
      ) {
        prefetch();
      }
      maybeAdvanceEarly(audio);
    };
    const onLoadedMetadata = (idx: number, audio: HTMLAudioElement) => {
      // The idle element's metadata says nothing about what is playing.
      if (idx !== activeIdxRef.current) return;
      if (Number.isFinite(audio.duration)) store().setDuration(audio.duration);
    };
    const onPlay = (idx: number) => {
      if (idx !== activeIdxRef.current) return;
      if (!store().isPlaying) store().play();
    };
    const onPause = (idx: number, audio: HTMLAudioElement) => {
      if (idx !== activeIdxRef.current) return;
      // Ignore the pause that fires as part of ending a track.
      if (audio.ended) return;
      if (store().isPlaying) store().pause();
    };
    const onEnded = (idx: number, audio: HTMLAudioElement) => {
      // A retired element reaching its end is expected during a crossfade.
      if (idx !== activeIdxRef.current) return;
      // The crossfade window already handed over; do not advance twice.
      if (earlyAdvanceRef.current) {
        audio.pause();
        return;
      }
      // Just flag it — applyTrack (below) is the single place that sends
      // feedback, on the resulting track-change, so completion is recorded
      // exactly once per track.
      endedNaturallyRef.current = true;
      // Vibe's durable feedback/replan handshake can take longer than the
      // browser's end transition. Explicitly pause the ended element so it
      // cannot auto-resume from its final buffered samples while that work is
      // in flight. applyTrack will start the next source when ready.
      audio.pause();
      store().nextWithReason('completed');
    };
    const onError = (idx: number) => {
      if (idx !== activeIdxRef.current) return;
      const playback = store();
      const track = playback.currentTrack;
      const vibe = useVibeStore.getState();
      // Metadata can be available while the stream itself is no longer
      // readable. Vibe must advance that exact durable cursor, not fall back
      // to ordinary queue navigation or feedback-driven replanning.
      if (!track || !vibe.activeSessionId || playback.queueOwner !== 'vibe' || streamErrorTrackIdRef.current === track.id) return;
      streamErrorTrackIdRef.current = track.id;
      void advancePastUnplayableVibeTrack(track.id)
        .catch(() => undefined)
        .finally(() => { streamErrorTrackIdRef.current = null; });
    };

    const teardown = els.map((audio, idx) => {
      const listeners: Array<[string, () => void]> = [
        ['timeupdate', () => onTimeUpdate(idx, audio)],
        ['loadedmetadata', () => onLoadedMetadata(idx, audio)],
        ['play', () => onPlay(idx)],
        ['pause', () => onPause(idx, audio)],
        ['ended', () => onEnded(idx, audio)],
        ['error', () => onError(idx)],
      ];
      for (const [event, handler] of listeners) audio.addEventListener(event, handler);
      return () => {
        for (const [event, handler] of listeners) audio.removeEventListener(event, handler);
      };
    });

    return () => { for (const off of teardown) off(); };
  }, [elements, setGain]);

  // --- store -> DOM: react to currentTrack changes ------------------------
  useEffect(() => {
    const els = elements();
    if (els.length === 0) return;

    const applyTrack = (id: string | null) => {
      // Another device is playing and this one is only showing what it plays.
      // Pointing an element at the stream here would download the track — the
      // whole of it, once an earlier prefetch left that element on preload=auto
      // — for audio that is never heard. Drop the sources and follow along.
      if (usePlaybackStore.getState().audioElsewhere) {
        cancelJoin();
        for (const idx of [0, 1]) retire(idx);
        loadedIdRef.current = null;
        preparedRef.current = null;
        endedNaturallyRef.current = false;
        crossedThresholdRef.current = false;
        earlyAdvanceRef.current = false;
        return;
      }
      if (id === loadedIdRef.current) return;
      // A newer track change supersedes any tail still waiting to hand over.
      cancelJoin();

      // The durable Vibe controller owns normal next/ended navigation. It
      // records the outcome, receives a new plan revision, then calls the raw
      // advance method. Do not emit a second event here after that transition.
      const prevId = loadedIdRef.current;
      const completed = endedNaturallyRef.current || crossedThresholdRef.current;
      const playback = usePlaybackStore.getState();
      const inVibePlayback = !!useVibeStore.getState().activeSessionId && playback.queueOwner === 'vibe';
      if (prevId && inVibePlayback && !playback.vibeAdvanceHandler) {
        void reportVibeEvent(completed ? 'completed' : 'skipped', prevId).catch(() => undefined);
      }
      const wasEarlyAdvance = earlyAdvanceRef.current;
      endedNaturallyRef.current = false;
      crossedThresholdRef.current = false;
      earlyAdvanceRef.current = false;
      lastProgressSecondRef.current = -1;
      loadedIdRef.current = id;

      const oldIdx = activeIdxRef.current;
      const oldEl = els[oldIdx];

      if (!id) {
        retire(oldIdx);
        return;
      }

      // Reuse the prefetched element when it holds exactly this track; that
      // stream is already buffered, so playback starts without a fetch. With
      // nothing playing yet there is no tail to preserve, so load in place.
      const prepared = preparedRef.current;
      const reusePrefetched = prepared?.id === id && prepared.idx !== oldIdx;
      const nextIdx = reusePrefetched ? prepared!.idx : prevId ? 1 - oldIdx : oldIdx;
      const nextEl = els[nextIdx];
      preparedRef.current = null;

      if (!reusePrefetched) {
        nextEl.src = trackService.getStreamUrl(id);
        nextEl.load();
      } else if (nextEl.currentTime > 0) {
        nextEl.currentTime = 0;
      }
      activeIdxRef.current = nextIdx;

      // Only an early handover leaves a tail to deal with; an element that
      // already ended has nothing left to play.
      const crossfadeMs = playback.crossfadeMs;
      const hasTail = wasEarlyAdvance && nextIdx !== oldIdx && !oldEl.paused && !oldEl.ended;
      const fadeMs = hasTail ? crossfadeMs : 0;
      // Tail with no crossfade: hold the new track until the tail is done.
      const deferStart = hasTail && crossfadeMs <= 0;
      if (nextIdx !== oldIdx) {
        if (fadeMs > 0) fadeTo(oldIdx, 0, fadeMs, () => retire(oldIdx));
        else if (deferStart) scheduleJoin(oldIdx, nextIdx);
        else retire(oldIdx);
      }

      if (inVibePlayback) void reportVibeEvent('playback_started', id).catch(() => undefined);

      setGain(nextIdx, fadeMs > 0 ? 0 : 1);
      if (deferStart) return;
      if (usePlaybackStore.getState().isPlaying) {
        void nextEl.play().catch(() => {});
        if (fadeMs > 0) fadeTo(nextIdx, 1, fadeMs);
      } else if (fadeMs > 0) {
        setGain(nextIdx, 1);
      }
    };

    // Apply the current value immediately, then subscribe to future changes.
    let lastElsewhere = usePlaybackStore.getState().audioElsewhere;
    applyTrack(usePlaybackStore.getState().currentTrack?.id ?? null);
    const unsub = usePlaybackStore.subscribe((state) => {
      if (state.audioElsewhere !== lastElsewhere) {
        lastElsewhere = state.audioElsewhere;
        // The audio just came back to this device. Nothing is loaded, and the
        // track id has not changed, so say so and let applyTrack load it.
        if (!lastElsewhere) loadedIdRef.current = null;
      }
      applyTrack(state.currentTrack?.id ?? null);
    });
    return unsub;
  }, [cancelJoin, elements, fadeTo, retire, scheduleJoin, setGain]);

  // --- store -> DOM: isPlaying ------------------------------------------------
  useEffect(() => {
    const els = elements();
    if (els.length === 0) return;

    const apply = (isPlaying: boolean) => {
      const audio = els[activeIdxRef.current];
      // Nothing is loaded while another device holds the audio.
      if (usePlaybackStore.getState().audioElsewhere) return;
      if (isPlaying) {
        // Never resume a finished element. Between `ended` and the next source
        // being loaded, isPlaying is still true, and resuming here replays the
        // final buffered milliseconds of the track that just finished.
        if (audio.ended) return;
        if (audio.paused) void audio.play().catch(() => {});
      } else {
        // Pausing ends the handover: the tail is dropped and the waiting track
        // becomes the one that resumes.
        cancelJoin();
        for (const el of els) if (!el.paused) el.pause();
      }
    };

    // Gate on an actual change: the subscription is unselected, so it fires on
    // every store write, including the queue/plan writes that happen while
    // Vibe's advance handshake is in flight.
    let lastIsPlaying = usePlaybackStore.getState().isPlaying;
    apply(lastIsPlaying);
    return usePlaybackStore.subscribe((state) => {
      if (state.isPlaying === lastIsPlaying) return;
      lastIsPlaying = state.isPlaying;
      apply(lastIsPlaying);
    });
  }, [cancelJoin, elements]);

  // --- store -> DOM: volume --------------------------------------------------
  useEffect(() => {
    const els = elements();
    if (els.length === 0) return;

    // Master volume is scaled by each element's fade gain.
    const apply = () => els.forEach((_, idx) => applyGain(idx));

    let lastVolume = usePlaybackStore.getState().volume;
    apply();
    return usePlaybackStore.subscribe((state) => {
      if (state.volume === lastVolume) return;
      lastVolume = state.volume;
      apply();
    });
  }, [applyGain, elements]);

  // --- store -> DOM: external seeks (user scrubbing) -------------------------
  useEffect(() => {
    const els = elements();
    if (els.length === 0) return;

    const apply = (position: number) => {
      const audio = els[activeIdxRef.current];
      // A mirrored position belongs to another device's playhead, and there is
      // no source loaded here to move anyway.
      if (usePlaybackStore.getState().audioElsewhere) return;
      if (Math.abs(audio.currentTime - position) > SEEK_THRESHOLD) {
        audio.currentTime = position;
      }
    };

    // Only real position changes are seeks; other store writes must not move
    // the playhead of a track that is mid-transition.
    let lastPosition = usePlaybackStore.getState().position;
    return usePlaybackStore.subscribe((state) => {
      if (state.position === lastPosition) return;
      lastPosition = state.position;
      apply(lastPosition);
    });
  }, [elements]);

  // --- MediaSession: hardware media keys + OS media controls ------------------
  //
  // Without this, the browser's default media-key handler toggles the <audio>
  // element directly, bypassing the store — causing the UI and audio to
  // desync. By registering action handlers we route all media-key input
  // through the store, so isPlaying stays consistent. We also publish track
  // metadata so the OS "now playing" widget shows title/artist/artwork.
  useEffect(() => {
    if (!('mediaSession' in navigator)) return;

    const store = usePlaybackStore.getState;
    const active = () => elements()[activeIdxRef.current] ?? null;

    const handlers: Partial<Record<MediaSessionAction, (details: MediaSessionActionDetails) => void>> = {
      play: () => store().play(),
      pause: () => store().pause(),
      previoustrack: () => store().prev(),
      nexttrack: () => store().next(),
      seekbackward: (details) => {
        const audio = active();
        if (!audio) return;
        const delta = details.seekOffset ?? SEEK_INCREMENT;
        audio.currentTime = Math.max(0, audio.currentTime - delta);
      },
      seekforward: (details) => {
        const audio = active();
        if (!audio) return;
        const delta = details.seekOffset ?? SEEK_INCREMENT;
        audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + delta);
      },
      seekto: (details) => {
        const audio = active();
        if (!audio || details.seekTime == null) return;
        audio.currentTime = details.seekTime;
      },
      stop: () => {
        store().pause();
        store().setPosition(0);
      },
    };

    for (const [action, handler] of Object.entries(handlers)) {
      try {
        navigator.mediaSession.setActionHandler(
          action as MediaSessionAction,
          handler ?? null,
        );
      } catch {
        // Some actions aren't supported on every browser/OS — ignore.
      }
    }

    // Clean up handlers on unmount so they don't outlive the engine.
    return () => {
      for (const action of Object.keys(handlers)) {
        try {
          navigator.mediaSession.setActionHandler(
            action as MediaSessionAction,
            null,
          );
        } catch {
          /* ignore */
        }
      }
    };
  }, [elements]);

  // --- MediaSession: publish metadata + playback state ------------------------
  useEffect(() => {
    if (!('mediaSession' in navigator)) return;

    const update = (track: Track | null, isPlaying: boolean) => {
      if (track) {
        navigator.mediaSession.metadata = new MediaMetadata({
          title: track.title || 'Unknown',
          artist: track.artist || 'Unknown',
          album: '',
          artwork: buildArtwork(track),
        });
      }
      navigator.mediaSession.playbackState = isPlaying ? 'playing' : 'paused';
    };

    // Publish immediately for the current state.
    update(usePlaybackStore.getState().currentTrack, usePlaybackStore.getState().isPlaying);

    // Subscribe to future changes of currentTrack or isPlaying.
    return usePlaybackStore.subscribe((state) => {
      update(state.currentTrack, state.isPlaying);
    });
  }, []);

  // --- MediaSession: publish position -----------------------------------------
  //
  // Without a position state the Android notification renders a scrubber that is
  // stuck at zero. setPositionState throws if position exceeds duration, which
  // happens transiently at a track handover, so both are clamped.
  useEffect(() => {
    if (!('mediaSession' in navigator) || !navigator.mediaSession.setPositionState) return;

    const publish = (position: number, duration: number) => {
      if (!Number.isFinite(duration) || duration <= 0) return;
      try {
        navigator.mediaSession.setPositionState({
          duration,
          position: Math.min(Math.max(position, 0), duration),
          playbackRate: 1,
        });
      } catch {
        // Stale position against a just-changed duration — the next tick fixes it.
      }
    };

    let lastPosition = -1;
    let lastDuration = -1;

    return usePlaybackStore.subscribe((state) => {
      // timeupdate fires ~4x a second; only republish on a whole-second change.
      const second = Math.floor(state.position);
      if (second === lastPosition && state.duration === lastDuration) return;
      lastPosition = second;
      lastDuration = state.duration;
      publish(state.position, state.duration);
    });
  }, []);

  // Drop in-flight fades and any pending handover when the engine unmounts.
  useEffect(() => () => {
    for (const timer of fadeTimerRef.current) if (timer !== null) clearInterval(timer);
    cancelJoinRef.current?.();
    cancelJoinRef.current = null;
  }, []);

  return (
    <>
      <audio ref={audioARef} hidden />
      <audio ref={audioBRef} hidden />
    </>
  );
};
