<!-- TRACES: UR-003, UR-005, UR-020, UR-021, UR-026, UR-040, UR-061 | DR-010, DR-023, DR-024, DR-051, DR-052, DR-092, DR-098, DR-099 -->
<script lang="ts">
  import { onMount, onDestroy, tick, untrack } from "svelte";
  import { planFullscreen } from "./fullscreenTarget";
  import { get } from "svelte/store";
  import { goto } from "$app/navigation";
  import { commands } from "$lib/api/bindings";
  import type {
    JRayActor,
    StreamingQuality,
    BackgroundAction,
    StreamSelection,
  } from "$lib/api/bindings";
  import { listen } from "@tauri-apps/api/event";
  import Hls from "hls.js";
  import type { MediaItem } from "$lib/api/types";
  import { auth } from "$lib/stores/auth";
  import VolumeControl from "./VolumeControl.svelte";
  import SleepTimerModal from "./SleepTimerModal.svelte";
  import SleepTimerIndicator from "./SleepTimerIndicator.svelte";
  import CachedImage from "../common/CachedImage.svelte";
  import { videoFitClass } from "./videoFit";
  import { fatalNetworkErrorAction } from "./hlsRecovery";
  import {
    subtitleStreamsOf,
    resolveSubtitleTracks,
    reconcileSelectedSubtitle,
    videoCrossOriginMode,
    nativeSubtitleTracks,
    nativeSubtitleArrayIndex,
    type RenderableSubtitleTrack,
  } from "./subtitleTracks";
  import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
  import { playbackPosition, playbackDuration, playerState } from "$lib/stores/player";
  import * as html5Adapter from "$lib/player/html5Adapter";
  import { playerController } from "$lib/player";
  import {
    createAdapter,
    Html5PlayerAdapter,
    type PlayerAdapter,
    type Html5ElementBridge,
  } from "$lib/player/adapters";
  import { createRustReportHost } from "$lib/player/adapters/rustReportHost";
  import { experimentalNativeVideo } from "$lib/stores/nativeVideo";
  import {
    enableNativeVideoCompositing,
    disableNativeVideoCompositing,
  } from "$lib/utils/videoSurface";
  import { nativeSignalRevealsVideo } from "./mediaReady";
  import { shouldHideControls } from "./controlsVisibility";
  import {
    isPipSupported,
    enterPip,
    setAutoEnterEnabled,
    setHtml5VideoState,
  } from "$lib/utils/pictureInPicture";
  import { enterImmersive, exitImmersive } from "$lib/utils/immersive";
  import {
    createTapGestureState,
    registerTap,
    resolveSeekTarget,
    clampSeekTarget,
    isSynthesizedTouchClick,
    isControlSurfaceTouch,
    SEEK_FORWARD_SECONDS,
    SEEK_BACKWARD_SECONDS,
    type TapFeedback,
  } from "./tapGestures";
  import {
    setBackgroundAudioEnabled,
    subscribeAppBackgrounded,
    type BackgroundSignal,
    subscribeAppForegrounded,
  } from "$lib/utils/backgroundAudio";
  import { platform } from "@tauri-apps/plugin-os";
  import {
    computeHandoffPosition,
    initialHandoffState,
    shouldEnterBackgroundAudio,
    shouldExitBackgroundAudio,
    shouldResumeOnForeground,
    planHandoffReturn,
    type BackgroundAudioState,
  } from "./backgroundAudioHandoff";
  import { createLogger } from "$lib/utils/logger";
  import { elementSrcFor, loaderForTransport } from "$lib/player/streamTransport";

  const log = createLogger("VideoPlayer");

  interface Props {
    media: MediaItem | null;
    /**
     * What to play, as the backend decided it: URL, transport, playback kind and
     * the quality ladder for this source. Replaces the bare `streamUrl` string,
     * which forced this component to re-derive the transport by searching for
     * `.m3u8`.
     *
     * TRACES: UR-079 | DR-225, DR-227
     */
    selection: StreamSelection;
    mediaSourceId?: string; // Media source ID for subtitle URLs
    initialPosition?: number; // Position in seconds to seek to after load (for resume)
    needsTranscoding?: boolean; // Whether content needs transcoding (HEVC/10-bit) - affects seeking behavior
    onClose: () => void;
    onSeek?: (positionSeconds: number, audioStreamIndex?: number) => Promise<string>; // Returns new stream URL for transcoded seeking
    // Reporting callbacks pass the played media's id explicitly so a late
    // reportStop (fired from onDestroy during autoplay navigation) is attributed
    // to the episode this player actually played, not the next episode whose URL
    // is already active on the page.
    onReportProgress?: (positionSeconds: number, isPaused: boolean, reportId?: string) => void;
    onReportStart?: (positionSeconds: number, reportId?: string) => void;
    onReportStop?: (positionSeconds: number, reportId?: string) => void;
    onEnded?: () => void; // Called when video playback ends naturally
    onNext?: () => void; // Called when user clicks next episode button
    hasNext?: boolean; // Whether there is a next episode available
    isLive?: boolean; // Live stream (Live TV) - no seek bar, no resume, no progress reporting
  }

  let {
    media,
    selection,
    mediaSourceId,
    initialPosition,
    needsTranscoding = false,
    onClose,
    onSeek,
    onReportProgress,
    onReportStart,
    onReportStop,
    onEnded,
    onNext,
    hasNext = false,
    isLive = false,
  }: Props = $props();

  // The id this player instance reports progress against. Snapshotted from the
  // media prop so a late reportStop (e.g. from onDestroy during autoplay
  // navigation) is always attributed to the episode this player played.
  // untrack() makes the intent explicit: capture the initial value only.
  const reportMediaId = untrack(() => media?.id);

  let videoElement: HTMLVideoElement | null = $state(null);
  let isPlaying = $state(false);
  let currentTime = $state(0);
  // Guards against onEnded() firing more than once per loaded stream. For
  // transcoded HLS, both the native `ended` event and the "fatal network error
  // near end of stream" recovery path can fire for the same playback, which
  // would otherwise call playerOnPlaybackEnded twice (e.g. decrementing the
  // sleep-timer episode counter twice). Reset when the stream URL changes.
  let endedFired = $state(false);

  function notifyEnded() {
    if (endedFired) return;
    endedFired = true;
    onEnded?.();
  }

  /**
   * Keep native's picture-in-picture state in step with the `<video>` element.
   *
   * PiP is driven by the Activity, and it only ever knew about the native
   * ExoPlayer surface — a path behind `experimentalNativeVideo`, which at the
   * time defaulted to off. So in the then-shipping configuration nothing
   * satisfied its "is a video playing?" check and the PiP button did nothing at
   * all. Reporting the element gives it a surface it can shrink into, and still
   * has to: the flag defaults to on now (DR-161) but a user who turns it off is
   * back on the element. (UR-041, DR-160, DR-161)
   */
  function reportPipVideoState() {
    if (!useHtml5Element || !videoElement) {
      setHtml5VideoState(false, 0, 0, false);
      return;
    }
    setHtml5VideoState(true, videoElement.videoWidth, videoElement.videoHeight, isPlaying);
  }
  let isFullscreen = $state(false);
  let showControls = $state(true);
  /**
   * True while the Activity is in picture-in-picture.
   *
   * On the HTML5 path the WebView *is* what PiP shows, so the page has to strip
   * itself down to the video — controls, header and gradients would otherwise be
   * rendered into a window a couple of inches wide. (UR-041, DR-160)
   */
  let isInPip = $state(false);
  let pipListenerCleanup: (() => void) | null = null;
  let showSleepTimerModal = $state(false);
  let isBuffering = $state(false);
  // Bumped by every reveal so the auto-hide effect restarts its countdown even
  // when no other input to that decision changed (a tap during playback).
  let lastControlsInteraction = $state(0);
  let seekOffset = $state(0); // Track offset when seeking in transcoded streams
  let isSeeking = $state(false);
  // Capture only the initial streamUrl prop; later prop changes are applied via
  // the $effect below (untrack keeps this a one-time snapshot, matching
  // reportMediaId above and silencing state_referenced_locally).
  // The selection currently loaded. Starts from the prop and is replaced
  // wholesale by a reload (quality change, audio-track switch, transcoded seek)
  // so transport and URL can never disagree.
  // TRACES: UR-079 | DR-225
  let currentSelection = $state<StreamSelection>(untrack(() => selection));
  const currentStreamUrl = $derived(currentSelection.url);
  /**
   * The transport as a plain string, so effects can depend on its *value*.
   * A `$derived` primitive only notifies when it actually changes, which is what
   * keeps the HLS teardown from re-running for an unchanged stream.
   */
  const transportKind = $derived(currentSelection.transport.type);
  let hasReportedStart = $state(false);
  let progressInterval: ReturnType<typeof setInterval> | null = null;
  let isMediaReady = $state(false); // Track if media is ready to play (implements Loading state from DR-001)
  let hasPerformedInitialSeek = $state(false); // Track if we've seeked to initialPosition
  let canplayFallbackTimeout: ReturnType<typeof setTimeout> | null = null; // Fallback timeout for canplay event
  let isDraggingSeekBar = $state(false); // Track if user is dragging the seek bar
  let debugLogInterval: ReturnType<typeof setInterval> | null = null; // Debug logging interval
  let rafId: number | null = null; // RequestAnimationFrame ID for smooth time updates

  // Touch gesture state
  let touchStartX = $state(0);
  let touchStartY = $state(0);
  let touchStartTime = $state(0);
  let tapGestures = createTapGestureState();
  // When a touch tap last ran the gesture handler, so the compatibility click
  // the browser synthesizes afterwards can be ignored (see handleVideoClick).
  let lastTouchTapAt = 0;
  let brightness = $state(1); // 0-2, default 1
  let showDoubleTapFeedback = $state<TapFeedback | null>(null);
  let doubleTapFeedbackTimeout: ReturnType<typeof setTimeout> | null = null;
  // Target of a skip already requested but not yet reported back by the player,
  // so back-to-back double taps chain instead of stacking on a stale position.
  let pendingSeekTarget: number | null = null;
  let swipeGestureActive = $state(false);
  // Whether the in-flight touch belongs to the player surface (and so may be
  // read as a tap/swipe gesture) rather than to a control. Set on touchstart,
  // cleared on touchend — see handleTouchMove for why a per-gesture flag and not
  // just a per-event target check.
  let playerGestureActive = false;
  // Raised when the user changes the seek bar's value, cleared by whichever
  // release signal commits the seek. See handleSeekBarRelease.
  let seekCommitArmed = false;

  // Backend info from Rust (Rust decides which backend to use based on platform)
  let useHtml5Element = $state(true); // Default to HTML5, Rust will override if using native backend
  let backendChosen = false; // playerPlayItem succeeded and told us which backend to use
  let nativeUnlisteners: Array<() => void> = []; // raw-channel listeners for native backend mode
  // Position updates captured before a native seek can land after it and snap
  // the bar back; suppress backend position feeds briefly after each seek
  // (same idea as the MPV backend's last_seek_time suppression).
  let lastNativeSeekAt = 0;
  const NATIVE_SEEK_SETTLE_MS = 1500;
  function nativeSeekSettling(): boolean {
    return Date.now() - lastNativeSeekAt < NATIVE_SEEK_SETTLE_MS;
  }
  let didStartNativePlayback = $state(false); // Track if we started playback (to know if we should stop on unmount)
  let didStopBackendEarly = $state(false); // Track if we stopped backend early for non-transcoded content
  let swipeType = $state<"brightness" | null>(null);
  let hls: Hls | null = null; // HLS.js instance for streaming HLS content
  let hlsFatalRecoveryAttempts = 0; // Track recovery attempts to prevent infinite restarts

  // ===== Player adapter (control boundary) =====
  // The adapter owns the high-level control contract (play/pause/seek/track).
  // VideoPlayer supplies a narrow bridge for the element/HLS-coupled parts and
  // registers the adapter with the facade so control intents — from UI OR from a
  // backend control event (lockscreen/remote/sleep) — reach this element.
  // Widened from Html5PlayerAdapter: the native path registers a
  // NativePlayerAdapter here. Element-coupled work is guarded by
  // `useHtml5Element`, not by narrowing this type.
  let playerAdapter: PlayerAdapter | null = null;

  function tearDownHls() {
    if (hls) {
      hls.detachMedia();
      hls.stopLoad();
      hls.destroy();
      hls = null;
    }
  }

  /**
   * A selection identical to the one loaded, but pointing at a different URL.
   *
   * Used by the paths that swap the stream without re-negotiating — the
   * background-audio handoff and its return. Each states the transport it is
   * moving to rather than letting it be inferred, which is the whole point of
   * DR-225: the audio handoff really is a progressive mp3, and the rebuilt
   * video stream really is an HLS transcode, and neither is knowable from the
   * URL text.
   *
   * TRACES: UR-040, UR-079 | DR-225
   */
  function selectionAt(url: string, transport: StreamSelection["transport"]): StreamSelection {
    // A re-opened stream is a new transcode job; the old session id is stale.
    return { ...currentSelection, url, transport, playSessionId: null };
  }

  const adapterBridge: Html5ElementBridge = {
    getElement: () => videoElement,
    getSeekOffset: () => seekOffset,
    setSeekOffset: (o) => {
      seekOffset = o;
    },
    setStreamSelection: (sel) => {
      currentSelection = sel;
    },
    destroyHls: tearDownHls,
    getMediaSourceId: () => mediaSourceId ?? null,
  };

  // Audio track selection
  let showAudioTrackMenu = $state(false);
  let selectedAudioTrackIndex = $state<number | null>(null);

  // Subtitle track selection
  let showSubtitleMenu = $state(false);
  let selectedSubtitleIndex = $state<number | null>(null);

  // Streaming bandwidth ceiling. The ladder and the current value both come from
  // Rust — the frontend never encodes what a step means.
  // TRACES: UR-074 | DR-162
  let showQualityMenu = $state(false);
  let changingQuality = $state(false);
  /**
   * The device's durable default, shown when the stream is a direct play and so
   * has no rendition of its own to report. Read once from Settings.
   */
  let defaultQuality = $state<StreamingQuality>("original");

  /**
   * The rungs to offer for the stream that is playing, straight from the
   * backend (DR-227). Rungs whose ceiling is at or above the source bitrate are
   * dropped: they produce the same bytes as Original, so listing five of them is
   * five ways to spell one choice. Rust decides which those are — this only
   * decides not to draw them.
   *
   * `Original` is always kept; it is the source, never redundant with it.
   *
   * TRACES: UR-070, UR-079 | DR-227, DR-121
   */
  const qualityOptions = $derived(
    currentSelection.available.filter((o) => !o.exceedsSource || o.quality === "original"),
  );

  /**
   * The rung in force. A transcode reports the rendition it was built against;
   * a direct play has none, because it *is* the source — so it reads as
   * Original rather than as whatever ceiling happens to be set.
   */
  const selectedQuality = $derived<StreamingQuality>(
    currentSelection.rendition?.quality ??
      (currentSelection.playbackKind.type === "transcode" ? defaultQuality : "original"),
  );

  /** Human line for what the server is doing with this stream. */
  const playbackKindLabel = $derived(
    currentSelection.playbackKind.type === "directPlay"
      ? "Direct play — the original file"
      : currentSelection.playbackKind.type === "directStream"
        ? "Direct stream — repackaged, not re-encoded"
        : "Transcoding on the server",
  );

  // Track duration from video element (for when media item doesn't have runTimeTicks)
  let videoDuration = $state(0);

  // Use known duration from media item (runTimeTicks is in 10M ticks/second)
  // Fallback to video element duration for direct streams
  const duration = $derived.by(() => {
    // Explicitly check if durationMs exists and is a valid number
    if (media && media.durationMs && media.durationMs > 0) {
      return media.durationMs / 1000;
    }
    // Otherwise use the video element's duration
    return videoDuration;
  });

  // The audio tracks available for this item, as the server described them.
  //
  // Jellyfin has no separate "audio tracks" endpoint: the tracks arrive on the
  // item itself, in `MediaStreams`, which the backend asks for by name in
  // `get_item`'s `Fields=` list and tags with an opaque `kind`. Selecting by
  // that tag rather than by Jellyfin's `Type` string keeps the taxonomy on the
  // Rust side of the boundary.
  //
  // TRACES: UR-021 | IR-016, JA-009 | DR-024
  const audioTracks = $derived(() => {
    if (!media || !media.mediaStreams) {
      log.debug("No media or mediaStreams available");
      return [];
    }
    const tracks = media.mediaStreams.filter((stream) => stream.kind === "audio");
    log.debug("Found audio tracks:", tracks.length, tracks);
    return tracks;
  });

  // Function to find best matching audio track based on preference
  function findBestAudioTrack(preference: {
    audioTrackDisplayTitle?: string | null;
    audioTrackLanguage?: string | null;
  }) {
    const tracks = audioTracks();
    if (tracks.length === 0) return null;

    // Try to match by display title first
    if (preference.audioTrackDisplayTitle) {
      const match = tracks.find((t) => t.displayTitle === preference.audioTrackDisplayTitle);
      if (match) {
        log.debug("Matched audio track by display title:", match.displayTitle);
        return match.index;
      }
    }

    // Try to match by language
    if (preference.audioTrackLanguage) {
      const match = tracks.find((t) => t.language === preference.audioTrackLanguage);
      if (match) {
        log.debug("Matched audio track by language:", match.language);
        return match.index;
      }
    }

    // Fall back to default track
    const defaultTrack = tracks.find((t) => t.isDefault) || tracks[0];
    log.debug(
      "Using default/first audio track:",
      defaultTrack.displayTitle || defaultTrack.language,
    );
    return defaultTrack.index;
  }

  // Load series audio preference on mount
  async function loadSeriesAudioPreference() {
    if (!media || !media.seriesId) return;

    try {
      const userId = auth.getUserId();
      if (!userId) return;

      const preference = await commands.storageGetSeriesAudioPreference(userId, media.seriesId);

      if (preference) {
        log.debug("Loaded series audio preference:", preference);
        const matchedIndex = findBestAudioTrack(preference);
        if (matchedIndex !== null) {
          selectedAudioTrackIndex = matchedIndex;
          log.debug("Applied series audio preference, track index:", matchedIndex);
        }
      }
    } catch (err) {
      log.warn("Failed to load series audio preference:", err);
    }
  }

  // The subtitle streams the menu offers — the same list the <track> children
  // and the native play request are built from, so the menu can never name a
  // track the player was never given. subtitleStreamsOf() also drops the ones
  // the backend says it cannot deliver as a sidecar (image-based PGS/DVD/DVB,
  // which only server burn-in could show and we never ask for — DR-176).
  // TRACES: UR-020 | DR-176 | UT-168
  const subtitleTracks = $derived(() => {
    if (!media || !media.mediaStreams) {
      log.debug("No media or mediaStreams available for subtitles");
      return [];
    }
    const tracks = subtitleStreamsOf(media.mediaStreams);
    log.debug("Found subtitle tracks:", tracks.length, tracks);
    return tracks;
  });

  // ===== Subtitle <track> sources for the HTML5 element (Linux/WebKitGTK) =====
  // Resolved asynchronously into state and only then rendered. The URLs come
  // from an async command, so they must never be bound to `src` directly — the
  // original markup did exactly that and put "[object Promise]" on every track,
  // which is why the whole block ended up commented out (and why selecting a
  // subtitle did nothing: with no <track> children the element has no
  // textTracks for the adapter to switch on).
  // TRACES: UR-020 | DR-023 | UT-143, UT-144
  let renderedSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);

  // The subtitle list actually handed to the native backend at load time
  // (Android/ExoPlayer). Kept because `player_set_subtitle_track` takes a
  // *position in this list*, not a Jellyfin stream index — see
  // nativeSubtitleArrayIndex. It is written once, from onMount, before the
  // play request; it is not derived, because the request is what fixed the
  // backend's idea of the track order.
  // TRACES: UR-020 | IR-016 | UT-147
  let sentSubtitleTracks = $state<RenderableSubtitleTrack[]>([]);

  // Cross-origin <track> fetches use the media element's CORS setting; see
  // videoCrossOriginMode for why this is opt-in and same-origin-only.
  const videoCrossOrigin = $derived(
    videoCrossOriginMode(currentStreamUrl, subtitleTracks().length),
  );

  $effect(() => {
    const streams = media?.mediaStreams ?? null;
    const itemId = media?.id;
    const sourceId = mediaSourceId;
    // Native (ExoPlayer) mode renders subtitles itself; the element has none.
    if (!useHtml5Element || !itemId || !sourceId) {
      renderedSubtitleTracks = [];
      return;
    }

    let cancelled = false;
    void (async () => {
      const tracks = await resolveSubtitleTracks(streams, (index) => getSubtitleUrl(index));
      if (cancelled) return;
      renderedSubtitleTracks = tracks;
      // Keep the menu's checkmark and the element's text tracks in agreement:
      // a selection that no longer resolves collapses to "Off".
      const selected = reconcileSelectedSubtitle(
        tracks,
        untrack(() => selectedSubtitleIndex),
      );
      selectedSubtitleIndex = selected;
      // The <track> children were just (re)created, so re-apply the selection to
      // the new TextTrack objects — otherwise a surviving selection shows nothing.
      await tick();
      if (!cancelled) applySubtitleToElement(selected);
    })();

    return () => {
      cancelled = true;
    };
  });

  // Track the last prop value to detect when parent changes the URL (vs internal seeks)
  let lastStreamUrlProp = $state("");

  // Update stream URL when prop changes (from parent component, not from internal seeks)
  $effect(() => {
    // Only reset when the streamUrl prop actually changes from parent
    if (selection.url !== lastStreamUrlProp) {
      lastStreamUrlProp = selection.url;
      currentSelection = selection;
      seekOffset = 0;
      isMediaReady = false; // Reset to loading state when stream URL changes
      hasPerformedInitialSeek = false; // Reset so new video can seek to initial position
      lastAppliedInitialPosition = undefined; // New stream - forget the previously-applied resume point
      endedFired = false; // New stream loaded - allow onEnded to fire again
      html5Adapter.resetReporting(); // New stream - clear position-report throttle
    }
  });

  // Sleep-timer expiry pause is now driven by the backend through the player
  // adapter: playerEvents.ts routes `sleep_timer_expired` to the active adapter's
  // pause() (see handleControlCommand / the sleep_timer_expired case). This
  // removes the component's direct videoElement.pause() reach-in — the backend
  // has control authority over the webview element via the adapter boundary.

  // Native backend (Android ExoPlayer): drive the seek bar from the player
  // store, which is fed by the backend's PositionUpdate events. The legacy
  // "player://position-update" raw channel was never emitted by the backend,
  // so without this the bar only moves when the user scrubs.
  $effect(() => {
    const position = $playbackPosition;
    if (!useHtml5Element && !isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
      currentTime = position;
    }
  });

  // Auto-hide the control bar.
  //
  // An `$effect` rather than a timer armed by input, because the conditions that
  // *permit* hiding arrive on their own schedule. The first attempt armed a
  // one-shot timer from `revealControls()` on entry; three seconds later
  // playback had not started yet, `shouldHideControls` correctly declined, and
  // nothing re-armed it — so the bar sat over the video for the whole film. The
  // timer has to follow the state, not the input event.
  //
  // Re-runs whenever any input changes: each run cancels the previous timer, so
  // starting playback, closing a menu or finishing a seek re-arms it, and
  // pausing or opening a menu cancels it. `lastControlsInteraction` is read so a
  // tap restarts the countdown even when nothing else changed.
  //
  // TRACES: UR-003, UR-066 | DR-189 | UT-188
  $effect(() => {
    void lastControlsInteraction;
    if (!showControls) return;
    if (
      !shouldHideControls({
        isPlaying,
        isSeeking,
        menuOpen: showAudioTrackMenu || showSubtitleMenu || showQualityMenu,
      })
    ) {
      return;
    }
    const timer = setTimeout(() => {
      showControls = false;
    }, 3000);
    return () => clearTimeout(timer);
  });

  // Reveal the video on the native path.
  //
  // The poster/title card is opaque and covers the whole video area, so on this
  // path it is the only thing between the viewer and the ExoPlayer surface —
  // every other markMediaReady() call site is a `<video>` element event, and
  // there is no `<video>` here.
  //
  // Driven from the same stores as the seek bar above, deliberately: the
  // `player://position-update` and `player://state-changed` channels the native
  // branch subscribes to are **never emitted by the backend** (see the comment
  // on the effect above — the seek bar had to be moved off them for the same
  // reason). Hooking the reveal to those channels looks right, passes a test
  // that fires them by hand, and does nothing on a device.
  //
  // TRACES: UR-003, UR-004 | DR-182 | UT-185
  $effect(() => {
    if (useHtml5Element || isMediaReady) return;
    const state = $playerState.kind;
    const position = $playbackPosition;
    const duration = $playbackDuration;
    if (
      nativeSignalRevealsVideo({ kind: "state", state }) ||
      nativeSignalRevealsVideo({ kind: "position", position, duration })
    ) {
      markMediaReady();
    }
  });

  // Mirror the backend's play/pause into the UI on the native path.
  //
  // `isPlaying` is assigned once from the player_play_item response and then
  // only by the `player://state-changed` listener — a channel the backend never
  // emits, exactly as for the reveal above. So on the native path it was
  // whatever the initial response said, forever: with ExoPlayer playing, the UI
  // still believed it was paused, which raised the `bg-black/30` play overlay
  // over the video surface and left the transport button showing ▶. The video
  // was both dimmed and covered while it played.
  //
  // The player is the authoritative source of playback state and the UI is a
  // consumer of it (see the architecture docs), so this reads the same store
  // `playerEvents.ts` feeds rather than tracking it locally. HTML5 keeps its own
  // element-event wiring, which is authoritative for that path.
  //
  // TRACES: UR-003, UR-005 | DR-186 | UT-187
  $effect(() => {
    if (useHtml5Element) return;
    isPlaying = $playerState.kind === "playing";
  });

  // Set up HLS.js for HLS streams
  $effect(() => {
    if (!useHtml5Element || !videoElement || !currentStreamUrl) {
      return;
    }

    // The loader comes from the backend's tagged transport, never from the URL.
    //
    // Read through the *primitive* `transportKind`, never `currentSelection`
    // itself: this effect tears down and rebuilds hls.js, and a selection object
    // is replaced on every reload — so depending on the object re-ran the whole
    // teardown for an unchanged stream and left the element showing nothing
    // until a seek forced another cycle.
    //
    // TRACES: UR-079 | DR-225 | UT-214
    const loader = loaderForTransport(transportKind, {
      hlsJsSupported: Hls.isSupported(),
      nativeHlsSupported: !!videoElement.canPlayType("application/vnd.apple.mpegurl"),
    });

    if (loader === "hlsjs") {
      // Clean up existing HLS instance if any - CRITICAL for preventing dual audio
      if (hls) {
        log.debug("Cleaning up existing HLS instance");
        // Detach from media element first to stop all audio/video
        hls.detachMedia();
        // Stop loading and flush buffers
        hls.stopLoad();
        // Destroy the instance
        hls.destroy();
        hls = null;
      }

      // Clear video element completely to stop any residual playback
      // This is critical to prevent dual audio streams
      if (videoElement.src) {
        videoElement.pause(); // Ensure playback is stopped
        videoElement.removeAttribute("src");
        videoElement.load(); // Reset the media element and clear all buffers
        videoElement.currentTime = 0;
      }

      // Small delay to ensure cleanup completes before creating new instance
      // This prevents race conditions with dual audio
      setTimeout(() => {
        if (!videoElement) return;

        log.debug("Creating new HLS instance for:", currentStreamUrl);

        // Create new HLS instance
        hls = new Hls({
          debug: true, // Enable debug logging to diagnose loading issues
          enableWorker: true,
          lowLatencyMode: false,
          // Buffer configuration for smooth playback without gaps
          maxBufferLength: 60, // Maximum buffer length in seconds (increased for smoother playback)
          maxMaxBufferLength: 120, // Maximum max buffer length in seconds
          backBufferLength: 60, // Keep 60 seconds of back buffer to prevent gaps
          maxBufferSize: 100 * 1000 * 1000, // 100MB max buffer size
          maxBufferHole: 0.5, // Maximum buffer hole tolerance before seeking over it
          maxFragLookUpTolerance: 0.25, // Fragment lookup tolerance
          // Improve stall recovery
          abrEwmaDefaultEstimate: 500000, // Initial bandwidth estimate
          abrBandWidthFactor: 0.95, // Conservative bandwidth estimation
          abrBandWidthUpFactor: 0.7, // Slower quality upgrades to reduce buffering
          // Prevent aggressive buffer eviction
          liveSyncDurationCount: 3, // Only for live streams
          liveMaxLatencyDurationCount: 10, // Only for live streams
        });

        // Attach media element
        hls.attachMedia(videoElement);

        // Listen for media attached event
        hls.on(Hls.Events.MEDIA_ATTACHED, () => {
          log.debug("HLS.js attached to video element");
          // Load the HLS stream
          hls!.loadSource(currentStreamUrl);
        });

        // Listen for manifest parsed event
        hls.on(Hls.Events.MANIFEST_PARSED, () => {
          log.debug("HLS manifest parsed, ready to play");
        });

        // On the Android WebView the element's own `canplay` may not fire for
        // MSE-fed HLS, so treat the first buffered fragment as "ready" too.
        // This reveals the <video> element (otherwise it stays invisible behind
        // the black poster card while audio plays).
        hls.on(Hls.Events.FRAG_BUFFERED, () => {
          markMediaReady();
        });

        // The canplay-fallback timeout is normally armed from the element's
        // `loadstart` event, but with hls.js the element's `src` is "" and
        // `loadstart` may not fire, so arm a backstop here directly.
        if (canplayFallbackTimeout) clearTimeout(canplayFallbackTimeout);
        canplayFallbackTimeout = setTimeout(() => {
          if (!isMediaReady && videoElement && videoElement.readyState >= 2) {
            log.warn(
              "HLS canplay fallback - revealing video (readyState:",
              videoElement.readyState,
              ")",
            );
            markMediaReady();
          }
        }, 5000);

        // Reset recovery attempts for new HLS instance
        hlsFatalRecoveryAttempts = 0;

        // Handle errors
        hls.on(Hls.Events.ERROR, (event, data) => {
          log.error("HLS error:", data);
          if (data.fatal) {
            // Is this the stream ending or the stream breaking? Jellyfin's
            // transcoded HLS doesn't always emit #EXT-X-ENDLIST, so both arrive
            // here identically and only the position tells them apart.
            // `currentTime` is already absolute — see hlsRecovery.ts.
            const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;

            switch (data.type) {
              case Hls.ErrorTypes.NETWORK_ERROR:
                hlsFatalRecoveryAttempts++;
                switch (
                  fatalNetworkErrorAction({
                    positionSeconds: currentTime,
                    knownDurationSeconds: knownDuration,
                    attempts: hlsFatalRecoveryAttempts,
                  })
                ) {
                  case "ended":
                    log.debug("Fatal network error near end of stream - treating as ended");
                    notifyEnded();
                    break;
                  case "retry":
                    log.error(
                      "Fatal network error, trying to recover (attempt",
                      hlsFatalRecoveryAttempts,
                      ")",
                    );
                    hls!.startLoad();
                    break;
                  case "giveUp":
                    log.error("Fatal network error, max recovery attempts reached");
                    hls!.destroy();
                    break;
                }
                break;
              case Hls.ErrorTypes.MEDIA_ERROR:
                log.error("Fatal media error, trying to recover");
                hls!.recoverMediaError();
                break;
              default:
                log.error("Unrecoverable HLS error");
                hls!.destroy();
                break;
            }
          }
        });
      }, 50); // 50ms delay to ensure cleanup completes

      // Cleanup on effect re-run
      return () => {
        log.debug("Effect cleanup: destroying HLS instance");
        if (hls) {
          hls.detachMedia();
          hls.stopLoad();
          hls.destroy();
          hls = null;
        }
        if (videoElement) {
          videoElement.pause();
        }
      };
    } else if (loader === "nativeHls") {
      // The element parses the playlist itself (Safari/WebKit).
      log.debug("Using native HLS support");
      videoElement.src = currentStreamUrl;
    } else {
      // Progressive or local: the element loads the URL directly.
      log.debug("Using regular video element", currentSelection.transport.type);
    }
  });

  // Ensure video element is unmuted and has max volume when it's bound (critical for Android)
  $effect(() => {
    if (videoElement) {
      videoElement.muted = false;
      videoElement.volume = 1.0;
      log.debug(
        "Video element configured: muted=",
        videoElement.muted,
        "volume=",
        videoElement.volume,
      );

      // DIAGNOSTIC: Check if video has audio tracks
      if ((videoElement as any).audioTracks) {
        log.debug("Audio tracks count:", (videoElement as any).audioTracks.length);

        // Set initial audio track (prefer default track)
        if (selectedAudioTrackIndex === null && audioTracks().length > 0) {
          const defaultTrack = audioTracks().find((t) => t.isDefault);
          selectedAudioTrackIndex = defaultTrack ? defaultTrack.index : audioTracks()[0].index;
          log.debug("Selected default audio track:", selectedAudioTrackIndex);
        }
      }
      if ((videoElement as any).mozHasAudio !== undefined) {
        log.debug("mozHasAudio:", (videoElement as any).mozHasAudio);
      }
      if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
        log.debug(
          "webkitAudioDecodedByteCount:",
          (videoElement as any).webkitAudioDecodedByteCount,
        );
      }
    }
  });

  // Handle initial position changes (for resuming the *same* loaded video from a
  // different position, e.g. the resume point is re-chosen without a reload).
  //
  // This must only react to a genuine change of the `initialPosition` prop to a
  // value we haven't already applied. The previous version re-fired on the very
  // first seek (it also depended on, and wrote, `hasPerformedInitialSeek`), and
  // since seeking a transcoded/HLS stream re-buffers and fires `canplay` again,
  // the two seek paths ping-ponged forever — the player appeared to pause/resume
  // in a loop. We now snapshot the last-applied position and only seek when the
  // prop actually moves to a new value, untracking the writes so this effect
  // can't re-trigger itself.
  let lastAppliedInitialPosition = $state<number | undefined>(undefined);
  $effect(() => {
    const pos = initialPosition;
    if (!pos || pos <= 0 || !isMediaReady) return;
    // Only act on a real change to a not-yet-applied position.
    if (pos === untrack(() => lastAppliedInitialPosition)) return;
    // Skip the very first application; handleCanPlay owns the initial seek.
    if (!untrack(() => hasPerformedInitialSeek)) {
      lastAppliedInitialPosition = pos;
      return;
    }
    untrack(() => {
      log.debug("Initial position changed, seeking to:", pos);
      lastAppliedInitialPosition = pos;
      if (videoElement) {
        videoElement.currentTime = pos;
        currentTime = pos;
      }
    });
  });

  // The quality *ladder* now arrives with the stream selection (DR-227), so all
  // this still needs is the device default, for the case where the stream is a
  // direct play and has no rendition of its own.
  //
  // Deliberately its own *synchronous* onMount that fires the load without
  // awaiting it: an await inside the main onMount below flips the component into
  // HTML5 mode and breaks native seeking, and nothing about playback waits on
  // this value.
  //
  // TRACES: UR-074, UR-079 | DR-162, DR-227
  onMount(() => {
    commands
      .playerGetVideoSettings()
      .then((settings) => {
        // Optional on the wire (serde default) — absent means uncapped.
        defaultQuality = settings.streamingQuality ?? "original";
      })
      .catch((err) => {
        log.warn("Failed to load the default streaming quality:", err);
      });
  });

  // Set up progress reporting interval
  onMount(async () => {
    // Background-audio lifecycle listeners MUST be registered synchronously —
    // before any await below — per the native-mode pitfall (an await here can
    // flip the component into HTML5 mode). Unsubscribers go into nativeUnlisteners
    // so onDestroy tears them down.
    if (backgroundAudioSupported) {
      nativeUnlisteners.push(subscribeAppBackgrounded(onAppBackgrounded));
      nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
    }

    // The video player owns the whole screen, so the system bars go away with it
    // — not only when the fullscreen button is pressed, which was the sole
    // caller of enterImmersive(). The status and navigation bars stayed painted
    // over the player on entry, and on the native path they sit directly on top
    // of the ExoPlayer surface, which fills the content view.
    //
    // Synchronous, before any await, per the native-mode pitfall above. Paired
    // with the unconditional exitImmersive() in onDestroy. (UR-066, DR-187)
    enterImmersive();

    // Arm the control-bar auto-hide on entry. Without this the bar only ever
    // hides after the first pointer/touch event, which on a touchscreen meant
    // "after the user happens to tap" — and before DR-189 wired touch up, never.
    revealControls();

    // Initialize player via Rust - Rust will decide which backend to use based on platform
    if (media && currentStreamUrl) {
      try {
        log.debug("Initializing player for:", media.name);
        log.debug("Stream URL:", currentStreamUrl);

        // Resolve subtitle URLs for the native (ExoPlayer) path. These must be
        // in hand *before* the play request: ExoPlayer sideloads subtitles as
        // MediaItem.SubtitleConfigurations, which have to exist before
        // prepare() — there is no way to add one to a loaded item afterwards.
        //
        // Awaiting here is safe despite the native-mode pitfall: that rule is
        // about Svelte *lifecycle* calls (onMount/onDestroy) after an await,
        // which throw lifecycle_outside_component and used to be misread as an
        // init failure. Nothing is registered here, and the background-audio
        // subscriptions above already ran synchronously. resolveSubtitleTracks
        // fans the requests out in parallel, so this costs one round trip, not
        // one per subtitle stream as the old serial loop did.
        // TRACES: UR-020 | IR-016, JA-008 | UT-147
        sentSubtitleTracks = mediaSourceId
          ? await resolveSubtitleTracks(media.mediaStreams, (index) => getSubtitleUrl(index))
          : [];
        log.debug(`Sending ${sentSubtitleTracks.length} subtitle tracks to the backend`);

        // Call Rust backend to start playback
        // Rust will choose ExoPlayer (Android), libmpv (Linux), or tell us to use HTML5
        // Send minimal video data - no complex serialization to avoid Tauri Android issues
        const response: any = await commands.playerPlayItem({
          streamUrl: currentStreamUrl,
          title: media.name,
          id: media.id,
          videoCodec: needsTranscoding ? "hevc" : "h264",
          needsTranscoding: needsTranscoding,
          // Carry the negotiated transport onto the queue item so a later seek
          // reads it instead of falling back. TRACES: UR-079 | DR-230
          transport: currentSelection.transport,
          // Order matters: player_set_subtitle_track(n) is a position in this
          // array. Previously this array was built and then dropped, so
          // ExoPlayer got a MediaItem with no subtitles at all.
          subtitles: nativeSubtitleTracks(sentSubtitleTracks),
        });

        // Rust tells us which backend it's using
        useHtml5Element = response.useHtml5Element;
        backendChosen = true;
        log.debug(`Backend: ${response.backend}, useHtml5Element: ${useHtml5Element}`);

        // Rust reported a native backend (Android/ExoPlayer). Honour it only if
        // the user opted into the experimental native path; otherwise fall back
        // to the webview element, which is what shipped by default.
        //
        // The flag is a suppressor, never a promoter — see createAdapter(). When
        // it is off we must also stop the native backend that player_play_item
        // just started, or ExoPlayer and the <video> element both decode the
        // same stream and the audio doubles.
        if (!useHtml5Element && !$experimentalNativeVideo) {
          log.debug("Native backend available but experimentalNativeVideo is off - using HTML5");
          useHtml5Element = true;
          try {
            await commands.playerStop();
            didStopBackendEarly = true;
          } catch (err) {
            log.warn("Failed to stop native backend:", err);
          }
        } else if (!useHtml5Element) {
          // Native path: clear the opaque layers between the viewport and the
          // ExoPlayer SurfaceView (webview widget background + page background).
          // Paired with disableNativeVideoCompositing() in the teardown path —
          // leaving this on renders the rest of the app over a transparent
          // window.
          log.debug("Using native ExoPlayer video surface");
          enableNativeVideoCompositing();
        }

        // If using HTML5 element for non-transcoded content, stop the backend player
        // For transcoded content, we need to keep the backend running to handle seeking/audio track switching
        if (useHtml5Element && !needsTranscoding && !didStopBackendEarly) {
          try {
            log.debug(
              "Using HTML5 for direct stream - stopping backend player to prevent dual audio",
            );
            await commands.playerStop();
            didStopBackendEarly = true; // Track that we stopped the backend
          } catch (err) {
            log.warn("Failed to stop backend player:", err);
          }
        } else if (useHtml5Element && needsTranscoding) {
          log.debug(
            "Using HTML5 for transcoded stream - keeping backend for seeking/transcoding decisions",
          );
          // Backend is kept running but should not play audio since HTML5 element handles playback
          didStartNativePlayback = true; // Track that we need to stop backend on unmount
        }

        // Register the adapter with the facade so control intents (UI, or a
        // backend lockscreen/remote/sleep event) route to whatever is actually
        // rendering. Both paths need one: the native adapter forwards control
        // intents to ExoPlayer over IPC.
        {
          const host = createRustReportHost(media.id, {
            onEnded: () => notifyEnded(),
            onStreamUrlChanged: (u) => {
              // Rust re-opened the same stream (a transcoded seek): the
              // transport is unchanged, only the job behind it.
              currentSelection = selectionAt(u, currentSelection.transport);
            },
          });
          playerAdapter = createAdapter({
            backendKind: useHtml5Element ? "html5" : "native",
            host,
            bridge: adapterBridge,
            // useHtml5Element is already the resolved decision above, so the
            // flag has had its say; pass it through for the invariant check.
            experimentalNativeVideo: $experimentalNativeVideo,
          });
          // No-op for the native adapter, which owns no DOM element.
          playerAdapter.attach(videoElement);
          playerController.setActiveAdapter(playerAdapter);

          // The native (ExoPlayer) path has no <video> element, so `canplay`
          // never fires and the handleCanPlay initial-seek below never runs —
          // resume-at-position played from the beginning on Android. Hand the
          // resume point to the adapter, which issues the backend seek.
          //
          // HTML5 keeps its existing element-driven seek: seeking before the
          // element has metadata is clamped back to 0, which is precisely what
          // handleCanPlay waits for.
          // TRACES: UR-005 | DR-004, DR-028
          if (!useHtml5Element) {
            hasPerformedInitialSeek = true; // native path owns the initial seek
            lastAppliedInitialPosition = initialPosition;
            await playerAdapter.load(currentStreamUrl, {
              mediaId: media.id,
              mediaSourceId: mediaSourceId ?? null,
              needsTranscoding,
              initialPosition: initialPosition ?? 0,
              isLive,
              audioTrackIndex: null,
              knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
              // ExoPlayer already received these as SubtitleConfigurations via
              // player_play_item; mapped to the adapter shape for the contract.
              subtitleTracks: sentSubtitleTracks.map((t) => ({
                index: t.streamIndex,
                url: t.url,
                language: t.srclang,
                label: t.label,
                mimeType: "text/vtt",
              })),
            });
            if (initialPosition && initialPosition > 0 && !isLive) {
              currentTime = initialPosition;
            }
          }
        }

        if (!useHtml5Element) {
          // Using native backend, subscribe to player events
          didStartNativePlayback = true; // Track that we started native playback
          isPlaying = (response.state?.kind ?? response.state) === "playing";
          // Cleanup happens in the component's top-level onDestroy. Calling
          // onDestroy() here — after an await — throws lifecycle_outside_component,
          // which the catch below used to misread as an init failure: it flipped
          // useHtml5Element to true, so every seek went down the HTML5 path and
          // never reached ExoPlayer (the video "seeked" then snapped back).
          nativeUnlisteners.push(
            await listen("player://position-update", (event: any) => {
              if (!isDraggingSeekBar && !isSeeking && !nativeSeekSettling()) {
                currentTime = event.payload.position;
              }
            }),
          );
          nativeUnlisteners.push(
            await listen("player://state-changed", (event: any) => {
              isPlaying = event.payload.state === "playing";
            }),
          );
        }
      } catch (err) {
        log.error("Failed to initialize player:", err);
        if (backendChosen) {
          // The backend already accepted the item; a later error (e.g. event
          // subscription) must not silently switch the seek/controls path to
          // HTML5 while the native backend keeps playing.
          log.warn("Backend already initialized - keeping native mode despite error");
        } else {
          // Fallback to HTML5 on error
          useHtml5Element = true;

          // For non-transcoded content, try to stop any backend player that might have started
          if (!needsTranscoding) {
            try {
              await commands.playerStop();
              didStopBackendEarly = true;
            } catch (stopErr) {
              // Ignore errors when stopping
            }
          } else {
            // For transcoded content, keep backend for seeking
            didStartNativePlayback = true;
          }
        }
      }
    }

    // Load series audio preference (for TV shows)
    await loadSeriesAudioPreference();

    // PiP: keep native's view of the `<video>` current, and react to the window
    // shrinking. The listeners are torn down in onDestroy. (DR-160)
    reportPipVideoState();
    const onPipEntered = () => (isInPip = true);
    const onPipExited = () => (isInPip = false);
    const onPipPlay = () => void videoElement?.play().catch(() => {});
    const onPipPause = () => videoElement?.pause();
    window.addEventListener("jellytau-pip-entered", onPipEntered);
    window.addEventListener("jellytau-pip-exited", onPipExited);
    window.addEventListener("jellytau-pip-play", onPipPlay);
    window.addEventListener("jellytau-pip-pause", onPipPause);
    pipListenerCleanup = () => {
      window.removeEventListener("jellytau-pip-entered", onPipEntered);
      window.removeEventListener("jellytau-pip-exited", onPipExited);
      window.removeEventListener("jellytau-pip-play", onPipPlay);
      window.removeEventListener("jellytau-pip-pause", onPipPause);
    };

    // Report progress every 10 seconds while playing. Live streams have no
    // meaningful position to report, so skip progress reporting entirely.
    if (!isLive) {
      progressInterval = setInterval(() => {
        if (isPlaying && !isSeeking && onReportProgress) {
          onReportProgress(currentTime, false, reportMediaId);
          mirrorElementStateToRust(false);
        }
      }, 10000);
    }

    // Debug logging every second
    debugLogInterval = setInterval(() => {
      if (videoElement && isPlaying && !isSeeking) {
        const buffered = videoElement.buffered;
        const bufferedRanges = [];
        for (let i = 0; i < buffered.length; i++) {
          bufferedRanges.push(`[${buffered.start(i).toFixed(1)} - ${buffered.end(i).toFixed(1)}]`);
        }

        // Flattened to a single string on purpose: the Android WebView console
        // bridge stringifies objects as "[object Object]" in logcat, which made
        // this whole payload useless when diagnosing over adb.
        log.debug(
          `Debug t=${videoElement.currentTime.toFixed(2)}` +
            ` display=${currentTime.toFixed(2)}` +
            ` readyState=${videoElement.readyState}` +
            ` networkState=${videoElement.networkState}` +
            ` paused=${videoElement.paused}` +
            ` seeking=${videoElement.seeking}` +
            ` rate=${videoElement.playbackRate}` +
            ` buffered=${bufferedRanges.join(", ")}`,
        );
      }
    }, 1000);
  });

  onDestroy(async () => {
    // FIRST, and synchronously: restore the opaque webview/page backgrounds.
    //
    // This callback is async, so anything after an `await` may run a frame or
    // more later. Leaving the window transparent for even that long shows the
    // launcher/wallpaper through the app as the player unwinds. Unconditional
    // and idempotent — a no-op when compositing was never enabled.
    disableNativeVideoCompositing();

    // Same reasoning for the system bars: they belong to the Activity, not to
    // this component, so a player torn down while immersive would leave every
    // screen behind it without a status or navigation bar. Idempotent. (UR-066)
    exitImmersive();

    // The `<video>` is going away, so PiP must stop being offered over it.
    setHtml5VideoState(false, 0, 0, false);
    pipListenerCleanup?.();
    pipListenerCleanup = null;

    // Stop RAF loop
    stopTimeUpdates();

    // Unregister the adapter from the facade (guarded so we only clear our own).
    if (playerAdapter) {
      playerController.clearActiveAdapter(playerAdapter);
      playerAdapter = null;
    }

    if (progressInterval) {
      clearInterval(progressInterval);
    }
    if (debugLogInterval) {
      clearInterval(debugLogInterval);
    }
    tapGestures.cancel();
    if (doubleTapFeedbackTimeout) {
      clearTimeout(doubleTapFeedbackTimeout);
      doubleTapFeedbackTimeout = null;
    }

    // Remove native backend event listeners (incl. background-audio lifecycle subs)
    for (const unlisten of nativeUnlisteners) {
      unlisten();
    }
    nativeUnlisteners = [];

    // Re-assert defaults so this player's background-audio choice can't leak into
    // the next one: disarm background audio and restore auto-PiP.
    if (backgroundAudioSupported) {
      setBackgroundAudioEnabled(false);
      setAutoEnterEnabled(true);
    }

    // Clean up HLS.js instance - prevent dual audio on unmount
    if (hls) {
      log.debug("Destroying HLS.js instance on unmount");
      hls.detachMedia(); // Detach from video element first
      hls.stopLoad(); // Stop loading and flush buffers
      hls.destroy();
      hls = null;
    }

    // Stop video element playback
    if (videoElement) {
      videoElement.pause();
      videoElement.src = "";
      videoElement.load();
    }

    // Stop the player when component is destroyed
    // Skip if we already stopped the backend early (non-transcoded + HTML5)
    if (didStartNativePlayback && !didStopBackendEarly) {
      try {
        log.debug("Stopping backend player on component unmount");
        await commands.playerStop();
      } catch (err) {
        log.error("Failed to stop backend player:", err);
      }
    }

    // Report stop when component is destroyed (skip for live - no resume tracking)
    if (!isLive && onReportStop && currentTime > 0) {
      onReportStop(currentTime, reportMediaId);
    }
  });

  // Smooth time updates using requestAnimationFrame (60fps)
  function updateTimeLoop() {
    if (videoElement && !isSeeking && !isDraggingSeekBar && isPlaying) {
      const newCurrentTime = seekOffset + videoElement.currentTime;
      if (videoElement.readyState >= 2) {
        currentTime = newCurrentTime;
        // Feed the Rust controller a throttled position tick (~250ms) so it
        // stays the source of truth for HTML5 video without flooding IPC.
        html5Adapter.reportPosition(currentTime, duration);
      }
    }

    // Keep the loop alive while playing — stopped by handlePause/handleEnded
    if (isPlaying) {
      rafId = requestAnimationFrame(updateTimeLoop);
    } else {
      rafId = null;
    }
  }

  // Start/stop RAF loop based on playback state
  function startTimeUpdates() {
    if (rafId === null) {
      rafId = requestAnimationFrame(updateTimeLoop);
    }
  }

  function stopTimeUpdates() {
    if (rafId !== null) {
      cancelAnimationFrame(rafId);
      rafId = null;
    }
  }

  // Fallback: Update time on timeupdate event (for when RAF isn't running)
  function handleTimeUpdate() {
    if (videoElement && !isSeeking && !isDraggingSeekBar && !isPlaying) {
      const newCurrentTime = seekOffset + videoElement.currentTime;
      if (videoElement.readyState >= 2) {
        currentTime = newCurrentTime;
      }
    }
  }

  function handleLoadedMetadata() {
    log.debug("loadedmetadata event");
    // Intrinsic dimensions are known now, which is what PiP sizes its window
    // from — before this they are 0 and the ratio would be rejected. (DR-160)
    reportPipVideoState();
    log.debug("Video element duration:", videoElement?.duration);
    log.debug("Media item runTimeTicks:", media?.runTimeTicks);
    log.debug("Needs transcoding:", needsTranscoding);

    // For direct streams without runTimeTicks, use video element's duration
    if (
      videoElement &&
      videoElement.duration &&
      !isNaN(videoElement.duration) &&
      videoElement.duration !== Infinity
    ) {
      const newDuration = videoElement.duration;
      log.debug("Setting videoDuration to:", newDuration);
      videoDuration = newDuration;
      log.debug("videoDuration state is now:", videoDuration);
    }

    // Tell the Rust controller the media is loaded and its duration (mirrors the
    // native MediaLoaded event so the backend has a duration for HTML5 video).
    html5Adapter.reportMediaLoaded(duration);

    // Use setTimeout to log the derived value after reactive updates
    setTimeout(() => {
      log.debug("Derived duration value:", duration);
      log.debug("Duration source:", media?.runTimeTicks ? "runTimeTicks" : "video element");
    }, 0);
  }

  // Flip out of the Loading state and reveal the <video> element (which is
  // `invisible` and covered by the black poster card until then). Multiple
  // signals can legitimately mean "ready": the native `canplay` event, hls.js
  // buffering its first fragment, or the element actually reaching `playing`.
  // On the Android system WebView the HLS path feeds the element through MSE
  // with `src=""`, so `loadstart`/`canplay` don't fire reliably and the
  // canplay-fallback timeout was never armed — audio played while the video
  // stayed invisible. Any of these callers now reveals it.
  // Apply the pending background-audio foreground seek, if any. This MUST run
  // no matter which readiness signal fired — on the Android WebView HLS/MSE path
  // `canplay` is unreliable and the video is revealed via markMediaReady()
  // instead, so gating this on handleCanPlay alone meant the seek was silently
  // dropped and the reloaded stream played from its start (resume "started from
  // the beginning"). Returns true if a pending seek was consumed.
  async function applyPendingForegroundSeek(): Promise<boolean> {
    if (pendingForegroundSeek === null || !videoElement) return false;
    const seekTo = pendingForegroundSeek;
    const shouldPlay = pendingForegroundPlay;
    pendingForegroundSeek = null;
    pendingForegroundPlay = false;
    hasPerformedInitialSeek = true;

    const el = videoElement;
    // currentTime is only honored once the element has metadata (duration/seekable).
    // If it isn't there yet, defer to loadedmetadata rather than seeking into a
    // still-empty timeline (which the element clamps back to 0).
    const doSeek = async () => {
      try {
        el.currentTime = seekTo;
        // Displayed position is absolute: element time + transcode seekOffset.
        // (Direct stream: seekOffset=0, seekTo=pos. Transcoded: seekOffset=pos,
        // seekTo=0.) Both yield the correct absolute position.
        currentTime = seekOffset + seekTo;
        el.muted = false;
        el.volume = 1.0;
        if (shouldPlay) await el.play();
      } catch (err) {
        log.error("Failed to resume after background audio:", err);
      }
    };

    if (el.readyState >= 1 /* HAVE_METADATA */) {
      log.debug("Applying foreground seek to:", (seekOffset + seekTo).toFixed(1));
      await doSeek();
    } else {
      log.debug(
        "Deferring foreground seek until loadedmetadata:",
        (seekOffset + seekTo).toFixed(1),
      );
      el.addEventListener(
        "loadedmetadata",
        () => {
          void doSeek();
        },
        { once: true },
      );
    }
    return true;
  }

  function markMediaReady() {
    if (isMediaReady) return;
    log.debug("Marking media ready");
    isMediaReady = true;
    // A handoff return can be revealed here (not via canplay) — apply its seek.
    void applyPendingForegroundSeek();
  }

  async function handleCanPlay() {
    // Media is ready to play - transition from Loading to Playing state (DR-001)
    log.debug("canplay event fired - media is ready");
    isMediaReady = true;

    // Ensure video is unmuted and at max volume (critical for Android)
    if (videoElement) {
      videoElement.muted = false;
      videoElement.volume = 1.0;
      log.debug("Video unmuted on canplay, volume: 1.0");
    }

    // Returning from background audio: resume the <video> at the position native
    // audio reached, restoring the prior play/pause state. Takes precedence over
    // the resume-point seek below (which is for a fresh load, not a handoff).
    if (await applyPendingForegroundSeek()) {
      return;
    }

    // Seek to initial position if resuming playback
    if (initialPosition && initialPosition > 0 && !hasPerformedInitialSeek && videoElement) {
      log.debug("Seeking to initial position:", initialPosition);
      hasPerformedInitialSeek = true;
      lastAppliedInitialPosition = initialPosition; // mark this value as applied so the change-effect ignores it

      // Pause video to prevent autoplay from starting at position 0
      const wasPlaying = !videoElement.paused;
      videoElement.pause();

      try {
        videoElement.currentTime = initialPosition;
        currentTime = initialPosition;

        // Wait for the seek to complete before resuming playback
        await new Promise<void>((resolve) => {
          const onSeeked = () => {
            videoElement?.removeEventListener("seeked", onSeeked);
            resolve();
          };
          videoElement!.addEventListener("seeked", onSeeked);
          // Fallback timeout in case seeked event doesn't fire
          setTimeout(() => {
            videoElement?.removeEventListener("seeked", onSeeked);
            resolve();
          }, 2000);
        });

        // Resume playback after seek completes
        if (wasPlaying || videoElement.autoplay) {
          await videoElement.play();
        }
      } catch (err) {
        log.error("Failed to seek to initial position:", err);
      }
    }
  }

  function handleError(e: Event) {
    const video = e.target as HTMLVideoElement;
    const error = video.error;

    // Log comprehensive error details
    log.error("Video error event:", {
      code: error?.code,
      message: error?.message,
      networkState: video.networkState,
      readyState: video.readyState,
      currentSrc: video.currentSrc,
      src: video.src,
    });

    // MediaError codes: 1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED
    const errorMessages: Record<number, string> = {
      1: "Playback aborted",
      2: "Network error while loading video - check server connectivity and CORS headers",
      3: "Video decoding failed - codec may not be supported by browser",
      4: "Video format not supported - may need transcoding",
    };

    const errorCode = error?.code || 0;
    const msg = errorMessages[errorCode] || `Unknown error (code ${errorCode})`;
    log.error("Error interpretation:", msg);

    // Log additional debugging info
    log.error("Stream URL:", currentStreamUrl);
    log.error("Needs transcoding:", needsTranscoding);

    // Network state meanings: 0=EMPTY, 1=IDLE, 2=LOADING, 3=NO_SOURCE
    const networkStates = ["NETWORK_EMPTY", "NETWORK_IDLE", "NETWORK_LOADING", "NETWORK_NO_SOURCE"];
    log.error("Network state:", networkStates[video.networkState] || video.networkState);

    // Ready state meanings: 0=NOTHING, 1=METADATA, 2=CURRENT_DATA, 3=FUTURE_DATA, 4=ENOUGH_DATA
    const readyStates = [
      "HAVE_NOTHING",
      "HAVE_METADATA",
      "HAVE_CURRENT_DATA",
      "HAVE_FUTURE_DATA",
      "HAVE_ENOUGH_DATA",
    ];
    log.error("Ready state:", readyStates[video.readyState] || video.readyState);
  }

  function handleWaiting() {
    log.debug("waiting event - buffering");
    isBuffering = true;
  }

  function handlePlaying() {
    log.debug("playing event - playback resumed");
    isBuffering = false;
    // Safety net: if we reached `playing` we are definitely renderable, even if
    // `canplay`/hls FRAG_BUFFERED were missed on this WebView.
    markMediaReady();
  }

  function handleLoadStart() {
    log.debug("loadstart event - starting to load:", currentStreamUrl);
    log.debug("Video element readyState:", videoElement?.readyState);
    log.debug("Video element networkState:", videoElement?.networkState);

    // Clear any existing fallback timeout
    if (canplayFallbackTimeout) {
      clearTimeout(canplayFallbackTimeout);
    }

    // Set up a fallback timeout in case canplay event never fires
    canplayFallbackTimeout = setTimeout(() => {
      if (!isMediaReady && videoElement) {
        log.warn("canplay event did not fire within 5 seconds");
        log.debug(
          "Fallback check - readyState:",
          videoElement.readyState,
          "networkState:",
          videoElement.networkState,
        );

        // Check if video is actually ready despite event not firing
        if (videoElement.readyState >= 3) {
          // HAVE_FUTURE_DATA or HAVE_ENOUGH_DATA
          log.debug("Video appears ready (readyState >= 3), forcing media ready state");
          markMediaReady();
        }
      }
    }, 5000);
  }

  // ===== JRay "who's on screen" pause overlay (optional plugin) =====
  // On pause, ask the JRay Jellyfin plugin which actors are on screen at the
  // current timestamp and show them as a tappable overlay. If the plugin isn't
  // installed (or has no data for this item), the call resolves to [] and the
  // overlay simply doesn't render. Tapping an actor with a resolved Jellyfin
  // Person id navigates to that person's library page.
  let jrayActors = $state<JRayActor[]>([]);
  // Monotonic token so a slow in-flight request can't overwrite a newer pause
  // (or a resume that cleared the list).
  let jrayRequestId = 0;

  async function fetchJrayActors() {
    const itemId = media?.id;
    if (!itemId) return;
    const token = ++jrayRequestId;
    const t = currentTime;
    try {
      const actors = await auth.getRepository().jrayActorsAt(itemId, t);
      // Discard if a newer pause/resume happened while we were waiting.
      if (token === jrayRequestId) {
        jrayActors = actors;
      }
    } catch (err) {
      log.warn("JRay lookup failed:", err);
      if (token === jrayRequestId) jrayActors = [];
    }
  }

  function clearJrayActors() {
    jrayRequestId++; // invalidate any in-flight request
    jrayActors = [];
  }

  function openJrayActor(actor: JRayActor) {
    if (actor.jellyfin_id) {
      goto(`/library/${actor.jellyfin_id}`);
    }
  }

  // Drive the JRay overlay off the single `isPlaying` flag so it works for both
  // the HTML5 <video> (desktop) and the native ExoPlayer path (Android, which
  // updates isPlaying via the player://state-changed event). Fetch when we go
  // paused, clear when we resume. untrack() keeps this from re-running on every
  // currentTime tick — only isPlaying transitions matter.
  $effect(() => {
    if (isPlaying) {
      untrack(clearJrayActors);
    } else if (isMediaReady) {
      untrack(fetchJrayActors);
    }
  });

  /**
   * Mirror the **webview element's** play/pause and position into Rust.
   *
   * Only ever when the element is what renders. `html5_playing` is Rust's record
   * of "a webview element is active and in this state", and `toggle_playback`,
   * `play` and `pause` all route transport to that element when it is set. So
   * reporting it from the native path is not a harmless extra: it hands
   * transport authority to an element that does not exist, and every play/pause
   * intent is then emitted into the void. That is exactly what made the pause
   * button dead on the native path — from the on-screen tap, the control bar,
   * and even a direct `player_toggle` invocation — while seek and skip kept
   * working, because they decide elsewhere.
   *
   * This lived in the player route's reporting callbacks, which cannot tell the
   * two rendering paths apart and so mirrored unconditionally — including from
   * the 10-second progress interval, which is why the flag came back after
   * DR-193 cleared it at load. It belongs here, where `useHtml5Element` is
   * known.
   *
   * TRACES: UR-005, UR-003 | DR-195 | UT-189
   */
  function mirrorElementStateToRust(paused: boolean) {
    if (!useHtml5Element) return;
    html5Adapter.reportState(paused ? "paused" : "playing", reportMediaId ?? null);
    html5Adapter.reportPosition(currentTime, duration, { force: true });
  }

  function handlePlay() {
    isPlaying = true;
    startTimeUpdates(); // Start RAF loop for smooth time updates
    // PiP's play/pause action reflects this. (DR-160)
    reportPipVideoState();
    // Mirror the DOM state into the Rust PlayerController so it is the single
    // source of truth for HTML5 video (the <video> lives in the webview, which
    // Rust cannot observe directly). See html5Adapter.ts.
    html5Adapter.reportState("playing", reportMediaId ?? null);
    // Report playback start on first play (skip for live - no resume tracking)
    if (!isLive && !hasReportedStart && onReportStart) {
      onReportStart(currentTime, reportMediaId);
      hasReportedStart = true;
    }
  }

  function handlePause() {
    // The element pausing is normally user intent, but a stall, a source change,
    // or a competing controller can also do it — and the pause itself carries no
    // reason. Log the element state so an unexplained pause/resume loop can be
    // attributed from an adb capture instead of guessed at.
    const el = videoElement;
    log.debug(
      `pause event — t=${el ? el.currentTime.toFixed(2) : "?"}` +
        ` readyState=${el?.readyState}` +
        ` networkState=${el?.networkState}` +
        ` seeking=${el?.seeking}` +
        ` ended=${el?.ended}` +
        ` isSeeking=${isSeeking}` +
        ` isBuffering=${isBuffering}` +
        ` handoff=${handoffState.active}`,
    );
    isPlaying = false;
    stopTimeUpdates(); // Stop RAF loop when paused
    reportPipVideoState(); // PiP's play/pause action reflects this. (DR-160)
    html5Adapter.reportState("paused", reportMediaId ?? null);
    html5Adapter.reportPosition(currentTime, duration, { force: true });
    // Report progress when paused
    if (onReportProgress) {
      onReportProgress(currentTime, true, reportMediaId);
    }
  }

  function handleEnded() {
    isPlaying = false;
    stopTimeUpdates(); // Stop RAF loop when ended
    // NOTE: do NOT report a "stopped" player state here. Natural end-of-video is
    // an autoplay handoff, not a stop: the backend's on_video_playback_ended
    // decides whether to advance to the next episode (incl. sleep-timer episode
    // counting). Emitting StateChanged{stopped} would flip the player/mode to
    // idle mid-handoff and suppress the next-episode auto-advance (pauses at the
    // end of an episode instead of continuing). onReportStop below still reports
    // progress to Jellyfin; notifyEnded() drives the autoplay decision.
    if (!isLive && onReportStop) {
      onReportStop(currentTime, reportMediaId);
    }
    // Notify parent that video has ended (for next episode popup)
    notifyEnded();
  }

  async function togglePlayPause() {
    // Route through the facade → active adapter so the toggle goes through the
    // one control boundary (and the adapter reports the resulting element state
    // back into Rust). The element's own play/pause handlers update isPlaying.
    try {
      await playerController.toggle();
    } catch (err) {
      log.error("Failed to toggle playback:", err);
    }
  }

  function handleSeekBarInput(e: Event) {
    const input = e.target as HTMLInputElement;
    const targetTime = parseFloat(input.value);
    // Update the displayed time immediately for smooth visual feedback
    currentTime = targetTime;
    // The user has moved the value; the next release must commit it.
    seekCommitArmed = true;
  }

  /**
   * Seek-bar released — commit the value the user landed on, at most once.
   *
   * Wired to `touchend`/`mouseup` AND `change`, because `change` alone is not
   * dependable: Android's WebView does not reliably fire it for a touch
   * interaction on a range input, so the thumb moved to the tapped position but
   * the seek never ran ("the bar moves, playback doesn't"). Engines that DO fire
   * `change` deliver both signals, hence the arm/disarm — whichever arrives
   * first commits and the other is a no-op.
   */
  function handleSeekBarRelease(e: Event) {
    isDraggingSeekBar = false;
    if (!seekCommitArmed) return;
    seekCommitArmed = false;
    const input = (e.currentTarget ?? e.target) as HTMLInputElement;
    void commitSeek(parseFloat(input.value));
  }

  async function commitSeek(rawTarget: number) {
    // Clamp strictly inside the media: the range input's max IS the duration, so
    // dragging fully right would otherwise request a segment past the media end,
    // which the server never produces (see END_SEEK_MARGIN_SECONDS).
    const targetTime = clampSeekTarget(rawTarget, duration);

    // Set isSeeking immediately to prevent timeupdate from interfering
    isSeeking = true;
    isDraggingSeekBar = false;

    try {
      log.debug("Seeking to:", targetTime.toFixed(2));

      // Optimistic display; the primitive updates currentTime/seekOffset as it
      // completes (reloadSource drives the stream URL via the adapter bridge).
      currentTime = targetTime;
      stopTimeUpdates(); // pause RAF while the seek settles

      // The BACKEND decides the strategy (in-place vs transcode reload); the
      // facade dispatches the matching adapter PRIMITIVE. This is the shared
      // decision-in-Rust design — no strategy branch lives here anymore.
      lastNativeSeekAt = Date.now();
      await playerController.seekVideo(
        targetTime,
        mediaSourceId ?? null,
        selectedAudioTrackIndex ?? null,
      );

      // Resume smooth updates if still playing after the seek settled.
      if (videoElement && !videoElement.paused) {
        startTimeUpdates();
      }

      log.debug("Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
    } catch (err) {
      log.error("Seek failed:", err);
    } finally {
      isSeeking = false;
      isDraggingSeekBar = false;
    }
  }

  // Resolved once at component setup: the PiP bridge is installed by
  // MainActivity before the page loads and never changes for the session.
  // Synchronous by design - no await in onMount (see VideoPlayer native-mode
  // pitfalls: awaiting there flips the component into HTML5 mode).
  const pipSupported = isPipSupported();

  function handlePictureInPicture() {
    enterPip();
  }

  // ===== Background audio (UR-040, Android) =====
  // Keep the video's audio playing when the app is backgrounded/locked by handing
  // playback off to the native ExoPlayer audio service; the WebView <video> is
  // torn down so no video is decoded. Mutually exclusive with auto-PiP.
  //
  // Gate on the platform, NOT on the AndroidBackgroundAudio JS-bridge probe.
  // The native isSupported() is unconditionally true on Android, but the bridge
  // is injected into the WebView asynchronously and races component mount — a
  // one-shot bridge probe here comes out false on some loads and, being a const,
  // never recovers, so the button vanished on "some videos". platform() is
  // available synchronously and is stable. toggleBackgroundAudio() no-ops safely
  // if the bridge is momentarily absent.
  const backgroundAudioSupported = platform() === "android";
  let backgroundAudioOn = $state(false); // v1: default OFF each session
  let handoffState: BackgroundAudioState = { ...initialHandoffState };
  // Set when backgrounding paused playback, so foregrounding resumes only
  // what we stopped -- never something the user paused themselves.
  let pausedByBackgrounding = false;

  function toggleBackgroundAudio() {
    backgroundAudioOn = !backgroundAudioOn;
    log.debug("Background-audio toggle ->", backgroundAudioOn);
    // Arm/disarm native background-audio mode AND flip auto-PiP the other way,
    // so exactly one background behavior is active.
    const armed = setBackgroundAudioEnabled(backgroundAudioOn);
    if (!armed) {
      log.warn("Background audio NOT armed natively (no bridge)");
    }
    setAutoEnterEnabled(!backgroundAudioOn);
  }

  // App went to background/locked while background-audio is armed: hand off to
  // native audio and stop the WebView video decode.
  async function onAppBackgrounded(signal: BackgroundSignal) {
    // What to do is a domain decision, not a presentation one: it depends on
    // whether the item has a picture to lose, which is Rust's to know. This used
    // to be decided implicitly by Kotlin gating the event on the toggle, which
    // is why the native path -- whose media service keeps playing regardless --
    // ignored the toggle entirely (DR-225).
    let action: BackgroundAction;
    try {
      action = await commands.playerBackgroundAction(
        signal.backgroundAudioArmed,
        signal.inPictureInPicture,
      );
    } catch (e) {
      // Never leave playback in an undefined state because a decision call
      // failed. Continuing is the old behaviour and the safer default: it
      // cannot silently stop something the user is listening to.
      log.warn("Background action lookup failed; leaving playback alone:", e);
      return;
    }

    log.debug("Background action:", action);
    switch (action) {
      case "keepPlaying":
        return;
      case "pause":
        // The user did not ask for background playback. Remember that WE paused
        // it, so returning to the foreground can resume rather than leaving a
        // video mysteriously stopped.
        pausedByBackgrounding = isPlaying;
        if (isPlaying) await playerController.pause();
        return;
      case "handOffToAudio":
        await enterBackgroundAudioHandoff();
        return;
    }
  }

  async function enterBackgroundAudioHandoff() {
    if (!shouldEnterBackgroundAudio(backgroundAudioOn, handoffState)) return;
    // `currentTime` is the component's authoritative ABSOLUTE position (the RAF
    // loop keeps it at seekOffset + element.currentTime, and it survives HLS
    // transcode segment resets). Reading videoElement.currentTime directly is
    // wrong for transcoded streams (it's the in-segment offset) and can read 0
    // if the element is mid-teardown — which shipped audio starting from 0:00.
    const pos = computeHandoffPosition(currentTime, 0);
    const wasPlaying = isPlaying;
    log.debug("Background-audio handoff at position:", pos.toFixed(1));
    handoffState = { active: true, wasPlaying };
    try {
      if (!media) return;
      // Ask the server for an audio-only stream of this video item (no video
      // decode), carrying the selected audio track and resume position.
      const audioUrl = await auth
        .getRepository()
        .getAudioOnlyStreamUrlForVideo(
          media.id,
          mediaSourceId ?? undefined,
          pos,
          selectedAudioTrackIndex ?? undefined,
        );
      await commands.playerEnterBackgroundAudio(
        {
          id: media.id,
          title: media.name,
          streamUrl: audioUrl,
          videoCodec: "aac",
          needsTranscoding: false,
          // Now-playing metadata so the lockscreen/miniplayer show the item.
          artist: media.seriesName ?? null,
          primaryImageTag: media.imageId ?? null,
          serverId: media.serverId ?? null,
          // Real duration so the lockscreen scrubber has a range to draw.
          durationSeconds: duration > 0 ? duration : null,
          // Episode identity so the backend can auto-advance to the next episode
          // when this audio-only stream ends while backgrounded (UR-040).
          itemType: media.type ?? null,
          seriesId: media.seriesId ?? null,
        },
        pos,
      );
      // Tear down the WebView <video>/HLS decode AFTER native audio has started,
      // so there is never a gap — and exactly one audio source is ever live.
      tearDownHls();
      if (videoElement) {
        videoElement.pause();
        videoElement.removeAttribute("src");
        videoElement.load();
      }
    } catch (err) {
      log.error("Background-audio handoff failed:", err);
      handoffState = { ...initialHandoffState };
    }
  }

  // App returned to foreground: stop native audio, reload the WebView <video> at
  // the position native reached, and restore play/pause.
  async function exitBackgroundAudioHandoff() {
    // Resume what backgrounding paused, before the handoff check: the pause path
    // and the handoff path are mutually exclusive, and this one leaves no
    // handoff state to unwind. Only resumes if WE paused it -- a user who
    // paused before locking the screen stays paused.
    if (pausedByBackgrounding) {
      pausedByBackgrounding = false;
      try {
        await playerController.play();
      } catch (e) {
        log.warn("Failed to resume after backgrounding:", e);
      }
      return;
    }

    if (!shouldExitBackgroundAudio(handoffState)) return;
    // Read the native player's state BEFORE exiting — the exit stops it. If the
    // user hit pause on the lockscreen while backgrounded, that pause must
    // survive the return to video rather than being overwritten by whatever the
    // <video> was doing when we handed off.
    const wasPlaying = shouldResumeOnForeground(handoffState.wasPlaying, get(playerState).kind);
    handoffState = { ...initialHandoffState };
    try {
      // Absolute position the native audio reached (base offset applied in Rust).
      const pos = await commands.playerExitBackgroundAudio();
      log.debug("Returning from background audio at:", pos.toFixed(1));

      isMediaReady = false;
      // The foreground seek below (pendingForegroundSeek/handleCanPlay) OWNS the
      // post-handoff position. Keep the initial-position change-effect quiescent:
      // leaving hasPerformedInitialSeek=true and pinning lastAppliedInitialPosition
      // to the current prop means the effect sees no "change" and won't fire a
      // stale seek back to the original resume point (clobbering the handoff pos).
      hasPerformedInitialSeek = true;
      lastAppliedInitialPosition = initialPosition;

      // How to come back depends on which renderer is actually on screen. See
      // planHandoffReturn: the webview element resumes off its stream URL, the
      // native backend only ever resumes off an explicit load.
      const plan = planHandoffReturn({
        useHtml5Element,
        position: pos,
        wasPlaying,
        nativeStateKind: get(playerState).kind,
      });

      pendingForegroundPlay = plan.shouldPlay;

      // Determine the target stream + how the element/offset should be
      // positioned.
      let targetSelection: StreamSelection;
      if (needsTranscoding && onSeek) {
        // Transcoded HLS is rebuilt rather than seeked in place, but the rebuilt
        // stream starts at the BEGINNING of the item, not at `pos`: a start
        // position on an HLS playlist is copied onto every segment URI and
        // rejected with 400 (DR-181). So there is no base to carry — the element
        // is seeked to the absolute position on canplay, exactly like a direct
        // stream. This previously set seekOffset = pos, which paired with a URL
        // that really did start there; leaving it would now display `pos` while
        // playing the opening titles.
        // TRACES: UR-040, UR-004 | DR-181
        // Every transcode this app requests is HLS (DR-140).
        targetSelection = selectionAt(await onSeek(pos, selectedAudioTrackIndex ?? undefined), {
          type: "hls",
        });
        seekOffset = 0;
        currentTime = pos;
        pendingForegroundSeek = pos;
      } else {
        // Direct stream: reload the original selection and seek to pos.
        targetSelection = selection;
        seekOffset = 0;
        pendingForegroundSeek = pos;
      }

      if (plan.target === "native-backend" && media) {
        // ExoPlayer has no element and nothing watches the stream URL for it, so
        // the URL dance below would restart precisely nothing — which is exactly
        // what shipped: the backend came back from the handoff holding no item,
        // leaving a black screen with a play overlay stuck at 0:00 and a play
        // button that did nothing (there was nothing loaded to play).
        //
        // Re-issue the same pair the initial load does, in the same order:
        // player_play_item hands ExoPlayer the item and its sideloaded subtitle
        // configurations (which cannot be added after prepare()), then the
        // adapter load carries the resume position. `sentSubtitleTracks` was
        // resolved during onMount for this same item, so it is reused rather
        // than re-fetched.
        //
        // TRACES: UR-040, UR-003 | DR-196
        currentSelection = targetSelection;
        await commands.playerPlayItem({
          streamUrl: targetSelection.url,
          title: media.name,
          id: media.id,
          videoCodec: needsTranscoding ? "hevc" : "h264",
          needsTranscoding,
          // TRACES: UR-079 | DR-230
          transport: targetSelection.transport,
          subtitles: nativeSubtitleTracks(sentSubtitleTracks),
        });
        didStartNativePlayback = true;
        await playerAdapter?.load(targetSelection.url, {
          mediaId: media.id,
          selection: targetSelection,
          mediaSourceId: mediaSourceId ?? null,
          needsTranscoding,
          initialPosition: plan.position,
          isLive,
          audioTrackIndex: selectedAudioTrackIndex ?? null,
          knownDuration: media.durationMs ? media.durationMs / 1000 : 0,
          subtitleTracks: sentSubtitleTracks.map((t) => ({
            index: t.streamIndex,
            url: t.url,
            language: t.srclang,
            label: t.label,
            mimeType: "text/vtt",
          })),
        });
        currentTime = plan.position;
        // The load starts playing; honour a pause taken on the lockscreen.
        if (!plan.shouldPlay) {
          await playerController.pause();
        }
        return;
      }

      // Force the HLS-init $effect to re-run even if the URL string is unchanged:
      // blank it first, then set it on the next microtask so Svelte sees a real
      // transition. Without this, assigning the same value is a no-op and the
      // player stays stuck on the loading spinner (HLS never re-initialises).
      currentSelection = selectionAt("", targetSelection.transport);
      await Promise.resolve();
      currentSelection = targetSelection;
    } catch (err) {
      log.error("Background-audio return failed:", err);
    }
  }

  // Consumed by handleCanPlay after the <video> reloads on foreground.
  let pendingForegroundSeek: number | null = null;
  let pendingForegroundPlay = false;

  // On Android the Activity owns the system bars, and requestFullscreen() cannot
  // reach them — the WebView already spans the window under an edge-to-edge
  // Activity, so on its own it left the status and navigation bars painted over
  // the video. The native bridge is what actually makes fullscreen full screen;
  // requestFullscreen() still does the work everywhere else. (UR-066, DR-157)
  async function toggleFullscreen() {
    // A native surface draws the picture *behind* the webview at window size, so
    // fullscreening the document alone leaves the video at its old size while
    // the page around it expands. See fullscreenTarget.ts. (DR-240)
    const plan = planFullscreen(!useHtml5Element);

    if (!document.fullscreenElement) {
      if (plan.document) {
        document.documentElement.requestFullscreen().catch((err) => {
          // WebKitGTK rejects when the gesture isn't recognised as user-activated;
          // the immersive call below is what matters on Android, so don't let a
          // rejection here abort it.
          log.warn("requestFullscreen rejected:", err);
        });
      }
      if (plan.osWindow) {
        await setOsWindowFullscreen(true);
      }
      enterImmersive();
      isFullscreen = true;
    } else {
      document.exitFullscreen();
      if (plan.osWindow) {
        await setOsWindowFullscreen(false);
      }
      exitImmersive();
      isFullscreen = false;
    }
  }

  /// Resize the OS window itself. Best-effort: a platform without a window to
  /// resize (Android) must not break the rest of the toggle.
  async function setOsWindowFullscreen(on: boolean) {
    try {
      const { getCurrentWindow } = await import("@tauri-apps/api/window");
      await getCurrentWindow().setFullscreen(on);
    } catch (err) {
      log.warn("setFullscreen on the OS window failed:", err);
    }
  }

  function formatTime(seconds: number): string {
    const mins = Math.floor(seconds / 60);
    const secs = Math.floor(seconds % 60);
    return `${mins}:${secs.toString().padStart(2, "0")}`;
  }

  /**
   * Show the control bar and arm its auto-hide.
   *
   * This used to be `handleMouseMove` and was wired *only* to the container's
   * `onmousemove`. A touchscreen never fires `mousemove`, so on Android the
   * timer was never armed and the bar stayed up for the whole film — hidden in
   * plain sight while the native video surface was itself invisible. It is now
   * armed on entry and on every touch interaction as well.
   *
   * TRACES: UR-003, UR-066 | DR-189 | UT-188
   */
  function revealControls() {
    showControls = true;
    lastControlsInteraction = Date.now();
  }

  // Kept as the mouse entry point; desktop still drives it from pointer motion.
  const handleMouseMove = revealControls;

  async function seekRelative(seconds: number) {
    isSeeking = true;

    // The facade seeks by absolute position, so resolve the delta here —
    // chaining off a still-in-flight target so rapid double taps accumulate
    // instead of all resolving against the same not-yet-updated position.
    const newTime = resolveSeekTarget({
      delta: seconds,
      reportedPosition: currentTime,
      duration,
      pendingTarget: pendingSeekTarget,
    });
    pendingSeekTarget = newTime;

    log.debug("Relative seek:", {
      offset: `${seconds > 0 ? "+" : ""}${seconds}s`,
      from: currentTime.toFixed(2),
      to: newTime.toFixed(2),
    });

    // Same commit path as the seek bar — one place decides how a seek is issued.
    try {
      await commitSeek(newTime);
    } finally {
      // The player is authoritative again from here on.
      if (pendingSeekTarget === newTime) pendingSeekTarget = null;
    }
  }

  function handleKeydown(e: KeyboardEvent) {
    if (e.key === " " || e.key === "k") {
      e.preventDefault();
      togglePlayPause();
    } else if (e.key === "f") {
      toggleFullscreen();
    } else if (e.key === "Escape") {
      if (isFullscreen) {
        // Through the toggle, not document.exitFullscreen() directly: leaving
        // fullscreen also has to restore the system bars and clear the flag.
        toggleFullscreen();
      } else {
        onClose();
      }
    } else if (e.key === "ArrowLeft") {
      e.preventDefault();
      seekRelative(SEEK_BACKWARD_SECONDS);
    } else if (e.key === "ArrowRight") {
      e.preventDefault();
      seekRelative(SEEK_FORWARD_SECONDS);
    }
  }

  /**
   * Walk up from the touch target collecting the tag/attribute pairs
   * `isControlSurfaceTouch` needs, so the rule itself stays DOM-free and testable.
   */
  function ancestorChain(target: EventTarget | null) {
    const chain: Array<{
      tag: string;
      isPlayerControls?: boolean;
      isPlayerSurface?: boolean;
    }> = [];
    let node = target as HTMLElement | null;
    // Bounded walk: controls live a few levels below the player root, and
    // stopping at <body> keeps this cheap and avoids depending on a bound ref.
    while (node && node.tagName !== "BODY") {
      chain.push({
        tag: node.tagName ?? "",
        isPlayerControls: node.dataset?.playerControls !== undefined,
        isPlayerSurface: node.dataset?.playerSurface !== undefined,
      });
      node = node.parentElement;
    }
    return chain;
  }

  // Touch gesture handlers
  function handleTouchStart(e: TouchEvent) {
    // Taps on the controls belong to those controls. This listener is on the
    // container and touch events bubble, so without this a tap on the bottom
    // play button would toggle here AND again via the button's own click — the
    // two cancelling out and leaving the control apparently dead (DR-098).
    if (isControlSurfaceTouch(ancestorChain(e.target))) {
      // The move handler must stay out of it too. It reads touchStartX/Y, which
      // this early return leaves at the PREVIOUS gesture's values, so a seek-bar
      // drag came out as a huge vertical delta: it was mis-read as a brightness
      // swipe, which dimmed the screen and fired a spurious play/pause
      // "correction" mid-drag (DR-098).
      playerGestureActive = false;
      return;
    }
    playerGestureActive = true;

    const touch = e.touches[0];
    touchStartX = touch.clientX;
    touchStartY = touch.clientY;
    touchStartTime = Date.now();

    const outcome = registerTap(tapGestures, {
      x: touch.clientX,
      screenWidth: window.innerWidth,
      now: Date.now(),
    });

    // Suppress the compatibility click this touch will synthesize.
    lastTouchTapAt = Date.now();

    if (outcome.action === "seek") {
      e.preventDefault();
      handleDoubleTap(outcome.seekSeconds, outcome.feedback);
      // Re-toggle so the first tap's toggle is undone: a double tap seeks and
      // leaves the play state as it was (playing keeps playing, paused stays
      // paused).
      if (outcome.togglePlayPause) togglePlayPause();
      return;
    }

    // First tap: act now. Nothing is deferred, so there is no timer to race the
    // compatibility click Android synthesizes after a touch tap (see DR-098).
    togglePlayPause();
  }

  function handleTouchMove(e: TouchEvent) {
    // Only a gesture that began on the bare video surface is ours. Re-checking
    // the target here would not be enough: the touch that started on a control
    // never recorded a start point, so any delta computed here is meaningless.
    if (!playerGestureActive) return;
    if (!e.touches[0]) return;

    const touch = e.touches[0];
    const deltaX = touch.clientX - touchStartX;
    const deltaY = touch.clientY - touchStartY;
    const timeDelta = Date.now() - touchStartTime;

    // Minimum movement to register as swipe (50px)
    if (Math.abs(deltaY) > 50 && timeDelta > 50) {
      // Only on the frame the gesture is first recognised as a swipe — this runs
      // on every touchmove, and the correction below must happen exactly once.
      if (!swipeGestureActive) {
        // The touchstart already toggled play/pause (taps act immediately now),
        // so undo it: a swipe must not change the play state. Forget the tap too,
        // so it cannot pair with a later tap into a spurious seek.
        togglePlayPause();
        tapGestures.cancel();
      }
      swipeGestureActive = true;

      // Brightness control on vertical swipe
      swipeType = "brightness";
      // Map vertical swipe to brightness (0.3 to 1.7 range for better visibility)
      const brightnessChange = -deltaY / 300; // Swipe up = brighter
      brightness = Math.max(0.3, Math.min(1.7, 1 + brightnessChange));

      // Reset touch start for continuous adjustment
      touchStartY = touch.clientY;
    }
  }

  function handleTouchEnd(e: TouchEvent) {
    playerGestureActive = false;
    swipeGestureActive = false;
    swipeType = null;
    // Touch is the only input on the platform this player mostly runs on, and
    // it is what `mousemove` never covers: show the bar and re-arm its hide.
    // (DR-189)
    revealControls();
  }

  /**
   * Mouse clicks toggle play/pause immediately. Touch taps are handled fully by
   * `handleTouchStart`, so the compatibility click the browser synthesizes after
   * a tap must be ignored or every tap toggles twice.
   *
   * Used by EVERY click target layered over the video, not just the <video>:
   * pausing renders the full-screen play overlay, so the synthesized click lands
   * on that button instead and would re-toggle straight back to playing.
   */
  function handleSurfaceClick(e: MouseEvent) {
    if (isSynthesizedTouchClick(e.detail, Date.now(), lastTouchTapAt)) return;
    togglePlayPause();
  }

  function handleDoubleTap(seekSeconds: number, feedback: TapFeedback) {
    seekRelative(seekSeconds);
    showDoubleTapFeedback = feedback;

    // Hide feedback after animation
    if (doubleTapFeedbackTimeout) {
      clearTimeout(doubleTapFeedbackTimeout);
    }
    doubleTapFeedbackTimeout = setTimeout(() => {
      showDoubleTapFeedback = null;
    }, 800);
  }

  function toggleAudioTrackMenu() {
    showAudioTrackMenu = !showAudioTrackMenu;
  }

  async function selectAudioTrack(streamIndex: number, arrayIndex: number) {
    log.debug("Selecting audio track - streamIndex:", streamIndex, "arrayIndex:", arrayIndex);
    const previousTrackIndex = selectedAudioTrackIndex;
    selectedAudioTrackIndex = streamIndex;
    showAudioTrackMenu = false;

    try {
      // The BACKEND decides whether the audio-track switch needs a transcode
      // reload; the facade dispatches the resulting adapter PRIMITIVE
      // (reloadSource) which runs the invariant dual-audio teardown sequence.
      // No strategy branch lives here anymore.
      stopTimeUpdates();
      await playerController.switchAudioTrack(
        streamIndex,
        arrayIndex,
        videoElement ? videoElement.currentTime + seekOffset : null,
        mediaSourceId ?? null,
      );
      if (videoElement && !videoElement.paused) {
        startTimeUpdates();
      }

      log.debug("Successfully changed audio track");

      // Save series audio preference for future episodes
      if (media && media.seriesId) {
        try {
          const userId = auth.getUserId();
          if (!userId) return;

          // Find the selected track info
          const selectedTrack = audioTracks().find((t) => t.index === streamIndex);
          if (selectedTrack) {
            await commands.storageSaveSeriesAudioPreference(
              userId,
              media.seriesId,
              media.serverId ?? "",
              selectedTrack.displayTitle || null,
              selectedTrack.language || null,
              streamIndex,
            );
            log.debug(
              "Saved series audio preference:",
              selectedTrack.displayTitle || selectedTrack.language,
            );
          }
        } catch (err) {
          log.warn("Failed to save series audio preference:", err);
        }
      }
    } catch (err) {
      log.error("Failed to change audio track:", err);
      // Revert to previous track on error
      selectedAudioTrackIndex = previousTrackIndex;
    }
  }

  function toggleQualityMenu() {
    showQualityMenu = !showQualityMenu;
  }

  /**
   * Re-open the current stream at a different bandwidth ceiling.
   *
   * The backend owns everything about how that happens — it decides whether the
   * caller reloads (HTML5) or it reloads the native backend itself — so this
   * only supplies the position to resume at.
   *
   * The change applies to this playback alone; the durable Settings default is
   * untouched (DR-226). Nothing is optimistically assigned here: what the picker
   * shows comes from the selection the backend hands back, because what you get
   * is not always what you asked for — a ceiling above the source bitrate is the
   * source, and claiming otherwise is the kind of lie the old picker told.
   *
   * TRACES: UR-074, UR-079 | DR-162, DR-226, DR-227
   */
  async function selectQuality(quality: StreamingQuality) {
    showQualityMenu = false;
    if (quality === selectedQuality || changingQuality) return;

    changingQuality = true;
    try {
      stopTimeUpdates();
      const negotiated = await playerController.setStreamQuality(
        quality,
        videoElement ? videoElement.currentTime + seekOffset : null,
        mediaSourceId ?? null,
        selectedAudioTrackIndex,
      );
      // Adopt whatever the backend says it opened. The HTML5 path has already
      // set this via the adapter bridge, so this is a no-op there; the native
      // path reloads inside Rust and this is the only thing that updates the UI.
      //
      // Assigning it is what keeps the picker honest: `selectedQuality` reads
      // the selection's rendition, and a transcode always has one — so without
      // this the menu stayed on the first stream's rung while the stream itself
      // changed underneath.
      //
      // TRACES: UR-074, UR-079 | DR-226, DR-227
      if (negotiated) {
        currentSelection = negotiated;
      }
      if (videoElement && !videoElement.paused) {
        startTimeUpdates();
      }
      log.debug("Streaming quality changed:", quality);
    } catch (err) {
      log.error("Failed to change streaming quality:", err);
    } finally {
      changingQuality = false;
    }
  }

  function toggleSubtitleMenu() {
    showSubtitleMenu = !showSubtitleMenu;
  }

  /**
   * Show exactly one (or no) text track on the HTML5 element. `null` disables
   * every track, which is what the menu's "Off" entry means.
   *
   * TRACES: UR-020 | DR-023
   */
  function applySubtitleToElement(streamIndex: number | null) {
    if (!useHtml5Element || !videoElement || !videoElement.textTracks) return;

    // Disable all text tracks first, so "Off" genuinely turns subtitles off.
    for (let i = 0; i < videoElement.textTracks.length; i++) {
      videoElement.textTracks[i].mode = "disabled";
    }
    if (streamIndex === null) return;

    // Find the corresponding track element by stream index.
    videoElement.querySelectorAll("track").forEach((track) => {
      const trackStreamIndex = parseInt(track.getAttribute("data-stream-index") || "-1");
      if (trackStreamIndex === streamIndex && track.track) {
        track.track.mode = "showing";
        log.debug("Enabled subtitle track:", streamIndex);
      }
    });
  }

  /**
   * Apply the menu's choice. `streamIndex` is always the Jellyfin media-stream
   * index (or `null` for "Off") — the UI speaks stream indices throughout.
   *
   * The native backend does not: `player_set_subtitle_track(n)` reaches
   * `JellyTauPlayer.setSubtitleTrack(n)`, which indexes ExoPlayer's text track
   * groups, i.e. the position of the sideloaded subtitle configuration. That
   * position is derived from `sentSubtitleTracks` — the exact array sent with
   * the play request — and not from the menu's row number, which counts every
   * subtitle *stream* including ones whose URL never resolved and so were never
   * sideloaded.
   *
   * TRACES: UR-020 | DR-023, IR-016 | UT-147
   */
  async function selectSubtitle(streamIndex: number | null) {
    log.debug("Selecting subtitle - streamIndex:", streamIndex);
    selectedSubtitleIndex = streamIndex;
    showSubtitleMenu = false;

    // For HTML5 video element, update the text tracks
    if (useHtml5Element) {
      applySubtitleToElement(streamIndex);
    } else {
      // For native backend (Android), send command to change subtitle track
      try {
        const indexToUse = nativeSubtitleArrayIndex(sentSubtitleTracks, streamIndex);
        await commands.playerSetSubtitleTrack(indexToUse);
        log.debug(
          "Native backend subtitle track changed - streamIndex:",
          streamIndex,
          "position:",
          indexToUse,
        );
      } catch (error) {
        log.error("Failed to set subtitle track:", error);
      }
    }
  }

  // Get subtitle URL for a given stream index
  async function getSubtitleUrl(streamIndex: number): Promise<string> {
    if (!media || !mediaSourceId) return "";
    try {
      const repo = auth.getRepository();
      return await repo.getSubtitleUrl(media.id, mediaSourceId ?? "", streamIndex, "vtt");
    } catch {
      return "";
    }
  }
</script>

<svelte:window onkeydown={handleKeydown} />

<div
  class="fixed inset-0 flex flex-col z-50"
  class:bg-black={useHtml5Element}
  style:background-color={!useHtml5Element ? "transparent" : ""}
  onmousemove={handleMouseMove}
  ontouchstart={handleTouchStart}
  ontouchmove={handleTouchMove}
  ontouchend={handleTouchEnd}
  role="application"
  aria-label="Video player"
>
  <!-- Video -->
  <!--
    `min-h-0` / `min-w-0` are load-bearing, not defensive. A flex item defaults
    to `min-height: auto`, which refuses to shrink below its content's intrinsic
    size — and the <video> inside reports the *media's* natural dimensions. So
    without them this wrapper grows past the viewport whenever the picture is
    larger than the window: the overflow goes off the bottom, which reads as the
    image being cropped and aligned to the top rather than letterboxed and
    centred. `object-contain` was never the problem; it was doing its job inside
    a box that was itself the wrong size.

    Reproduces by resizing the window during playback, and by entering
    fullscreen — where the same overflow put the picture at the bottom.

    TRACES: UR-005 | DR-024
  -->
  <div class="flex-1 min-h-0 min-w-0 flex items-center justify-center relative">
    {#if !!useHtml5Element}
      <!-- HTML5 video for desktop/non-Android platforms -->
      <video
        bind:this={videoElement}
        src={elementSrcFor(currentSelection, {
          hlsJsSupported: Hls.isSupported(),
          nativeHlsSupported: true,
        })}
        crossorigin={videoCrossOrigin}
        class={videoFitClass()}
        class:invisible={!isMediaReady}
        style="filter: brightness({brightness})"
        playsinline
        autoplay
        muted={false}
        ontimeupdate={handleTimeUpdate}
        onloadedmetadata={handleLoadedMetadata}
        oncanplay={handleCanPlay}
        onplay={handlePlay}
        onpause={handlePause}
        onended={handleEnded}
        onerror={handleError}
        onwaiting={handleWaiting}
        onplaying={handlePlaying}
        onloadstart={handleLoadStart}
        onclick={handleSurfaceClick}
      >
        <!--
            Subtitles for the HTML5 path. `src` is a resolved string (see
            renderedSubtitleTracks); `data-stream-index` is what
            Html5PlayerAdapter.selectSubtitle() matches on. No `default`
            attribute: a default track auto-shows, which would contradict the
            menu opening on "Off".
          -->
        {#each renderedSubtitleTracks as track (track.streamIndex)}
          <track
            kind="subtitles"
            src={track.url}
            srclang={track.srclang}
            label={track.label}
            data-stream-index={track.streamIndex}
          />
        {/each}
      </video>
    {:else}
      <!-- Android ExoPlayer - video rendered natively in SurfaceView behind WebView -->
      <!-- Leave this area transparent so video shows through -->
      <div class="flex-1"></div>
    {/if}

    <!-- Title card with loading spinner (Loading state from DR-001) -->
    {#if !isMediaReady}
      <div
        data-testid="video-poster"
        class="absolute inset-0 flex items-center justify-center bg-black"
      >
        <!-- Poster/Title Card -->
        {#if media?.imageId}
          <CachedImage
            itemId={media.id}
            imageType="Primary"
            tag={media.imageId}
            maxHeight={1080}
            alt={media?.name || "Video"}
            class="max-w-full max-h-full object-contain"
          />
        {:else}
          <div class="text-white text-2xl font-semibold px-8 text-center">
            {media?.name || "Loading..."}
          </div>
        {/if}

        <!-- Loading spinner overlay -->
        <div class="absolute inset-0 flex items-center justify-center bg-black/50">
          <div
            class="w-16 h-16 border-4 border-white border-t-transparent rounded-full animate-spin"
          ></div>
        </div>
      </div>
    {/if}

    <!-- Double-tap feedback overlays -->
    {#if showDoubleTapFeedback === "left"}
      <div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
        <div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
          <svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
            <path
              d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z"
            />
            <text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold"
              >{SEEK_BACKWARD_SECONDS}</text
            >
          </svg>
        </div>
      </div>
    {/if}

    {#if showDoubleTapFeedback === "right"}
      <div class="absolute right-8 top-1/2 -translate-y-1/2 pointer-events-none animate-fade-out">
        <div class="bg-white/20 rounded-full p-6 backdrop-blur-sm">
          <svg class="w-12 h-12 text-white" fill="currentColor" viewBox="0 0 24 24">
            <path
              d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm1-13H11v6l5.25 3.15.75-1.23-4-2.42z"
            />
            <text x="12" y="14" text-anchor="middle" font-size="6" fill="white" font-weight="bold"
              >+{SEEK_FORWARD_SECONDS}</text
            >
          </svg>
        </div>
      </div>
    {/if}

    <!-- Swipe gesture feedback -->
    {#if swipeGestureActive && swipeType === "brightness"}
      <div class="absolute left-8 top-1/2 -translate-y-1/2 pointer-events-none">
        <div class="bg-black/60 rounded-lg px-4 py-3 backdrop-blur-sm flex items-center gap-3">
          <svg class="w-6 h-6 text-white" fill="currentColor" viewBox="0 0 24 24">
            <path
              d="M20 8.69V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12 20 8.69zM12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm0-10c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4-1.79-4-4-4z"
            />
          </svg>
          <div class="flex flex-col">
            <span class="text-white text-xs font-medium">Brightness</span>
            <div class="w-24 h-1 bg-white/30 rounded-full mt-1">
              <div
                class="h-full bg-white rounded-full"
                style="width: {((brightness - 0.3) / 1.4) * 100}%"
              ></div>
            </div>
          </div>
        </div>
      </div>
    {/if}

    <!-- Loading overlay for seeking -->
    {#if isSeeking}
      <div class="absolute inset-0 flex items-center justify-center bg-black/50">
        <div
          class="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"
        ></div>
      </div>
    {:else if !isPlaying}
      <!-- Play overlay. Visually this IS the video surface, so it is marked
           `data-player-surface`: it must keep participating in tap gestures even
           though it is a <button>, or the second tap of a double tap (which lands
           here, because the first tap paused and raised this overlay) is
           discarded as "a tap on a control" and seeking dies. It still shares the
           synthesized-click guard, since it appears exactly when a tap pauses.
           See DR-098. -->
      <button
        data-player-surface
        data-testid="play-overlay"
        class="absolute inset-0 flex items-center justify-center bg-black/30"
        onclick={handleSurfaceClick}
        aria-label="Play"
      >
        <svg class="w-20 h-20 text-white" fill="currentColor" viewBox="0 0 24 24">
          <path d="M8 5v14l11-7z" />
        </svg>
      </button>
    {/if}

    <!-- JRay "who's on screen" overlay: shown while paused when the JRay plugin
         returned actors for the current timestamp. Tapping an actor with a
         resolved Jellyfin Person id opens their library page. -->
    {#if !isPlaying && !isSeeking && jrayActors.length > 0}
      <!-- Offset by the safe-area insets so the card clears the status bar and,
           in landscape, the display cutout. (UR-066) -->
      <div
        class="absolute top-4 right-4 max-w-xs bg-black/70 rounded-lg p-3 backdrop-blur-sm pointer-events-auto"
        style:top="calc(1rem + var(--safe-top))"
        style:right="calc(1rem + var(--safe-right))"
      >
        <div class="text-white/60 text-xs font-medium uppercase tracking-wide mb-2">On screen</div>
        <div class="flex flex-col gap-2">
          {#each jrayActors as actor (actor.name + actor.jellyfin_id)}
            {#if actor.jellyfin_id}
              <button
                class="flex items-center gap-2 text-left text-white text-sm hover:text-blue-300 transition-colors group"
                onclick={() => openJrayActor(actor)}
              >
                <!-- Headshot from the actor's Jellyfin Person item. Falls back to
                     a placeholder inside CachedImage when no image exists. -->
                <CachedImage
                  itemId={actor.jellyfin_id}
                  imageType="Primary"
                  maxWidth={80}
                  alt={actor.name}
                  class="w-8 h-8 rounded-full object-cover flex-shrink-0 ring-1 ring-white/20 group-hover:ring-blue-300/60"
                />
                <span>{actor.name}</span>
              </button>
            {:else}
              <span class="flex items-center gap-2 text-white/80 text-sm">
                <!-- No resolved Jellyfin id → no headshot available. -->
                <span
                  class="w-8 h-8 rounded-full bg-gray-700 flex-shrink-0 flex items-center justify-center text-xs text-gray-400"
                >
                  {actor.name.slice(0, 1)}
                </span>
                <span>{actor.name}</span>
              </span>
            {/if}
          {/each}
        </div>
      </div>
    {/if}
  </div>

  <!-- Controls. `data-player-controls` marks this subtree as interactive so
       container-level tap gestures ignore touches here (see DR-098).

       The video itself deliberately fills the whole screen (edge-to-edge, under
       the cutout), but every interactive control lives in here — so this box,
       not the video, carries the safe-area insets. Without them the scrub bar
       and the close/fullscreen buttons sit under the Android gesture bar, and
       in landscape under the display cutout. (UR-066) -->
  <div
    data-player-controls
    class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300"
    style:padding-bottom="calc(1rem + var(--safe-bottom))"
    style:padding-left="calc(1rem + var(--safe-left))"
    style:padding-right="calc(1rem + var(--safe-right))"
    class:opacity-0={!showControls || isInPip}
    class:pointer-events-none={!showControls || isInPip}
  >
    <!-- Title -->
    <div class="mb-2">
      <h2 class="text-white text-lg font-semibold">{media?.name || "Video"}</h2>
    </div>

    <!-- Progress bar (hidden for live streams - no fixed timeline) -->
    {#if isLive}
      <div class="flex items-center gap-2 mb-2">
        <span class="flex items-center gap-1.5 text-white text-sm font-semibold">
          <span class="inline-block w-2 h-2 rounded-full bg-red-500"></span>
          LIVE
        </span>
      </div>
    {:else}
      <div class="flex items-center gap-2 mb-2">
        <span class="text-white text-sm w-12">{formatTime(currentTime)}</span>
        <input
          type="range"
          min="0"
          max={duration || 100}
          value={currentTime}
          oninput={handleSeekBarInput}
          onchange={handleSeekBarRelease}
          onmousedown={() => (isDraggingSeekBar = true)}
          onmouseup={handleSeekBarRelease}
          ontouchstart={() => (isDraggingSeekBar = true)}
          ontouchend={handleSeekBarRelease}
          class="flex-1 h-1 bg-white/30 rounded-full appearance-none cursor-pointer
                 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
                 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
        />
        <span class="text-white text-sm w-12 text-right">{formatTime(duration)}</span>
      </div>
    {/if}

    <!-- Control buttons -->
    <div class="flex items-center justify-between">
      <div class="flex items-center gap-4">
        <!-- Play/Pause -->
        <button
          onclick={togglePlayPause}
          class="text-white hover:text-gray-300"
          aria-label={isPlaying ? "Pause" : "Play"}
        >
          {#if isPlaying}
            <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
              <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
            </svg>
          {:else}
            <svg class="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
              <path d="M8 5v14l11-7z" />
            </svg>
          {/if}
        </button>

        <!-- Next Episode -->
        {#if hasNext}
          <button onclick={onNext} class="text-white hover:text-gray-300" aria-label="Next episode">
            <svg class="w-7 h-7" fill="currentColor" viewBox="0 0 24 24">
              <path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z" />
            </svg>
          </button>
        {/if}
      </div>

      <div class="flex items-center gap-4">
        <!-- Audio Track Selection -->
        {#if audioTracks().length > 1}
          <div class="relative">
            <button
              onclick={toggleAudioTrackMenu}
              class="text-white hover:text-gray-300"
              aria-label="Select audio track"
            >
              <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
                <path
                  d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z"
                />
              </svg>
            </button>

            <!-- Audio Track Menu -->
            {#if showAudioTrackMenu}
              <div
                class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
              >
                <div class="p-2">
                  <div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
                    Audio Track
                  </div>
                  {#each audioTracks() as track, i}
                    <button
                      onclick={() => selectAudioTrack(track.index, i)}
                      class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedAudioTrackIndex ===
                      track.index
                        ? 'bg-white/20'
                        : ''}"
                    >
                      <span class="text-sm">
                        {track.displayTitle || track.language || `Track ${i + 1}`}
                        {#if track.isDefault}
                          <span class="text-xs text-gray-400 ml-1">(Default)</span>
                        {/if}
                      </span>
                      {#if selectedAudioTrackIndex === track.index}
                        <svg
                          class="w-4 h-4 text-[var(--color-jellyfin)]"
                          fill="currentColor"
                          viewBox="0 0 24 24"
                        >
                          <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
                        </svg>
                      {/if}
                    </button>
                  {/each}
                </div>
              </div>
            {/if}
          </div>
        {/if}

        <!--
          Streaming quality (bandwidth ceiling), populated from what this media
          source can actually offer. TRACES: UR-070, UR-074 | DR-162, DR-227
        -->
        {#if qualityOptions.length > 1}
          <div class="relative">
            <button
              onclick={toggleQualityMenu}
              class="text-white hover:text-gray-300 disabled:opacity-50"
              disabled={changingQuality}
              aria-label="Select streaming quality"
            >
              <!-- Speedometer: bitrate ceiling -->
              <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
                <path
                  d="M20.38 8.57l-1.23 1.85a8 8 0 0 1-.22 7.58H5.07A8 8 0 0 1 15.58 6.85l1.85-1.23A10 10 0 0 0 3.35 19a2 2 0 0 0 1.72 1h13.85a2 2 0 0 0 1.74-1 10 10 0 0 0-.27-10.44zm-9.79 6.84a2 2 0 0 0 2.83 0l5.66-8.49-8.49 5.66a2 2 0 0 0 0 2.83z"
                />
              </svg>
            </button>

            {#if showQualityMenu}
              <div
                class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[220px] max-h-[300px] overflow-y-auto"
              >
                <div class="p-2">
                  <div class="px-3 py-2 border-b border-white/20">
                    <div class="text-white text-sm font-semibold">Quality</div>
                    <!--
                      What the server is actually doing. Only knowable now that
                      the backend reports it. TRACES: UR-079 | DR-228
                    -->
                    <div class="text-xs text-gray-400 mt-0.5">{playbackKindLabel}</div>
                  </div>
                  {#each qualityOptions as option (option.quality)}
                    <button
                      onclick={() => selectQuality(option.quality)}
                      class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedQuality ===
                      option.quality
                        ? 'bg-white/20'
                        : ''}"
                    >
                      <div class="flex flex-col">
                        <span class="text-sm">{option.label}</span>
                        <span class="text-xs text-gray-400">
                          {option.detail}{#if option.quality === "original" && option.sourceBitrate}
                            &middot; {(option.sourceBitrate / 1_000_000).toFixed(1)} Mbps{/if}
                        </span>
                      </div>
                      {#if selectedQuality === option.quality}
                        <svg
                          class="w-4 h-4 text-[var(--color-jellyfin)]"
                          fill="currentColor"
                          viewBox="0 0 24 24"
                        >
                          <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
                        </svg>
                      {/if}
                    </button>
                  {/each}
                </div>
              </div>
            {/if}
          </div>
        {/if}

        <!-- Subtitle Selection -->
        {#if subtitleTracks().length > 0}
          <div class="relative">
            <button
              onclick={toggleSubtitleMenu}
              class="text-white hover:text-gray-300"
              aria-label="Select subtitles"
            >
              <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
                <path
                  d="M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zM4 12h4v2H4v-2zm10 6H4v-2h10v2zm6 0h-4v-2h4v2zm0-4H10v-2h10v2z"
                />
              </svg>
            </button>

            <!-- Subtitle Menu -->
            {#if showSubtitleMenu}
              <div
                class="absolute bottom-full right-0 mb-2 bg-black/90 backdrop-blur-sm rounded-lg shadow-xl min-w-[200px] max-h-[300px] overflow-y-auto"
              >
                <div class="p-2">
                  <div class="text-white text-sm font-semibold px-3 py-2 border-b border-white/20">
                    Subtitles
                  </div>
                  <!-- Off option -->
                  <button
                    onclick={() => selectSubtitle(null)}
                    class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
                    null
                      ? 'bg-white/20'
                      : ''}"
                  >
                    <span class="text-sm">Off</span>
                    {#if selectedSubtitleIndex === null}
                      <svg
                        class="w-4 h-4 text-[var(--color-jellyfin)]"
                        fill="currentColor"
                        viewBox="0 0 24 24"
                      >
                        <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
                      </svg>
                    {/if}
                  </button>
                  <!-- Subtitle tracks -->
                  {#each subtitleTracks() as track}
                    <button
                      onclick={() => selectSubtitle(track.index)}
                      class="w-full text-left px-3 py-2 text-white hover:bg-white/10 rounded transition-colors flex items-center justify-between {selectedSubtitleIndex ===
                      track.index
                        ? 'bg-white/20'
                        : ''}"
                    >
                      <div class="flex flex-col">
                        <span class="text-sm">
                          {track.displayTitle || track.language || `Track ${track.index}`}
                          {#if track.isDefault}
                            <span class="text-xs text-gray-400 ml-1">(Default)</span>
                          {/if}
                          {#if track.isForced}
                            <span class="text-xs text-gray-400 ml-1">(Forced)</span>
                          {/if}
                        </span>
                        {#if track.codec}
                          <span class="text-xs text-gray-500">{track.codec.toUpperCase()}</span>
                        {/if}
                      </div>
                      {#if selectedSubtitleIndex === track.index}
                        <svg
                          class="w-4 h-4 text-[var(--color-jellyfin)]"
                          fill="currentColor"
                          viewBox="0 0 24 24"
                        >
                          <path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
                        </svg>
                      {/if}
                    </button>
                  {/each}
                </div>
              </div>
            {/if}
          </div>
        {/if}

        <!-- Sleep Timer -->
        {#if $sleepTimerActive}
          <SleepTimerIndicator
            onClick={() => {
              showSleepTimerModal = true;
            }}
          />
        {:else}
          <button
            onclick={() => {
              showSleepTimerModal = true;
            }}
            class="text-white hover:text-gray-300"
            aria-label="Sleep timer"
          >
            <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
              <path
                d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"
              />
            </svg>
          </button>
        {/if}

        <!-- Volume Control -->
        <VolumeControl size="md" />

        <!-- Picture-in-picture (Android only) -->
        {#if pipSupported}
          <button
            onclick={handlePictureInPicture}
            class="text-white hover:text-gray-300"
            aria-label="Picture in picture"
          >
            <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
              <path
                d="M19 11h-8v6h8v-6zm4 8V4.98C23 3.88 22.1 3 21 3H3c-1.1 0-2 .88-2 1.98V19c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2zm-2 .02H3V4.97h18v14.05z"
              />
            </svg>
          </button>
        {/if}

        <!-- Background audio (Android only) — keep audio playing when the app is
             backgrounded/locked; video decode stops. Suppresses auto-PiP while on. -->
        {#if backgroundAudioSupported}
          <button
            onclick={toggleBackgroundAudio}
            class={backgroundAudioOn
              ? "text-blue-400 hover:text-blue-300"
              : "text-white hover:text-gray-300"}
            aria-label="Background audio"
            aria-pressed={backgroundAudioOn}
          >
            <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
              <path
                d="M12 1c-4.97 0-9 4.03-9 9v7c0 1.66 1.34 3 3 3h3v-8H5v-2c0-3.87 3.13-7 7-7s7 3.13 7 7v2h-4v8h3c1.66 0 3-1.34 3-3v-7c0-4.97-4.03-9-9-9z"
              />
            </svg>
          </button>
        {/if}

        <!-- Fullscreen -->
        <button
          onclick={toggleFullscreen}
          class="text-white hover:text-gray-300"
          aria-label="Toggle fullscreen"
        >
          <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 24 24">
            {#if isFullscreen}
              <path
                d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"
              />
            {:else}
              <path
                d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"
              />
            {/if}
          </svg>
        </button>

        <!-- Close -->
        <button onclick={onClose} class="text-white hover:text-gray-300" aria-label="Close">
          <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path
              stroke-linecap="round"
              stroke-linejoin="round"
              stroke-width="2"
              d="M6 18L18 6M6 6l12 12"
            />
          </svg>
        </button>
      </div>
    </div>
  </div>
</div>

<SleepTimerModal
  isOpen={showSleepTimerModal}
  onClose={() => {
    showSleepTimerModal = false;
  }}
  mediaType={media?.type}
/>

<style>
  @keyframes fade-out {
    0% {
      opacity: 1;
      transform: translateY(-50%) scale(1);
    }
    50% {
      opacity: 1;
      transform: translateY(-50%) scale(1.1);
    }
    100% {
      opacity: 0;
      transform: translateY(-50%) scale(0.9);
    }
  }

  .animate-fade-out {
    animation: fade-out 0.8s ease-out forwards;
  }
</style>
