<!-- 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 { get } from "svelte/store";
  import { goto } from "$app/navigation";
  import { commands } from "$lib/api/bindings";
  import type { JRayActor } 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 {
    resolveSubtitleTracks,
    reconcileSelectedSubtitle,
    videoCrossOriginMode,
    nativeSubtitleTracks,
    nativeSubtitleArrayIndex,
    type RenderableSubtitleTrack,
  } from "./subtitleTracks";
  import { sleepTimerActive, sleepTimerExpiredSignal } from "$lib/stores/sleepTimer";
  import { playbackPosition, 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 { isPipSupported, enterPip, setAutoEnterEnabled } from "$lib/utils/pictureInPicture";
  import {
    createTapGestureState,
    registerTap,
    resolveSeekTarget,
    clampSeekTarget,
    isSynthesizedTouchClick,
    isControlSurfaceTouch,
    SEEK_FORWARD_SECONDS,
    SEEK_BACKWARD_SECONDS,
    type TapFeedback,
  } from "./tapGestures";
  import {
    setBackgroundAudioEnabled,
    subscribeAppBackgrounded,
    subscribeAppForegrounded,
  } from "$lib/utils/backgroundAudio";
  import { platform } from "@tauri-apps/plugin-os";
  import {
    computeHandoffPosition,
    initialHandoffState,
    shouldEnterBackgroundAudio,
    shouldExitBackgroundAudio,
    shouldResumeOnForeground,
    type BackgroundAudioState,
  } from "./backgroundAudioHandoff";

  interface Props {
    media: MediaItem | null;
    streamUrl: string;
    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, streamUrl, 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?.();
  }
  let isFullscreen = $state(false);
  let showControls = $state(true);
  let showSleepTimerModal = $state(false);
  let isBuffering = $state(false);
  let controlsTimeout: ReturnType<typeof setTimeout> | null = null;
  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).
  let currentStreamUrl = $state(untrack(() => streamUrl));
  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;
    }
  }

  const adapterBridge: Html5ElementBridge = {
    getElement: () => videoElement,
    getSeekOffset: () => seekOffset,
    setSeekOffset: (o) => { seekOffset = o; },
    setStreamUrl: (u) => { currentStreamUrl = u; },
    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);

  // 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;
  });


  // Get available audio tracks from media streams
  const audioTracks = $derived(() => {
    if (!media || !media.mediaStreams) {
      console.log("[VideoPlayer] No media or mediaStreams available");
      return [];
    }
    const tracks = media.mediaStreams.filter(stream => stream.kind === "audio");
    console.log("[VideoPlayer] 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) {
        console.log("[VideoPlayer] 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) {
        console.log("[VideoPlayer] Matched audio track by language:", match.language);
        return match.index;
      }
    }

    // Fall back to default track
    const defaultTrack = tracks.find(t => t.isDefault) || tracks[0];
    console.log("[VideoPlayer] 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) {
        console.log("[VideoPlayer] Loaded series audio preference:", preference);
        const matchedIndex = findBestAudioTrack(preference);
        if (matchedIndex !== null) {
          selectedAudioTrackIndex = matchedIndex;
          console.log("[VideoPlayer] Applied series audio preference, track index:", matchedIndex);
        }
      }
    } catch (err) {
      console.warn("[VideoPlayer] Failed to load series audio preference:", err);
    }
  }

  // Get available subtitle tracks from media streams
  const subtitleTracks = $derived(() => {
    if (!media || !media.mediaStreams) {
      console.log("[VideoPlayer] No media or mediaStreams available for subtitles");
      return [];
    }
    const tracks = media.mediaStreams.filter(stream => stream.kind === "subtitle");
    console.log("[VideoPlayer] 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 (streamUrl !== lastStreamUrlProp) {
      lastStreamUrlProp = streamUrl;
      currentStreamUrl = streamUrl;
      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;
    }
  });

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

    const isHlsStream = currentStreamUrl.includes('.m3u8');

    if (isHlsStream && Hls.isSupported()) {
      // Clean up existing HLS instance if any - CRITICAL for preventing dual audio
      if (hls) {
        console.log('[VideoPlayer] 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;

        console.log('[VideoPlayer] 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, () => {
          console.log('[VideoPlayer] HLS.js attached to video element');
          // Load the HLS stream
          hls!.loadSource(currentStreamUrl);
        });

        // Listen for manifest parsed event
        hls.on(Hls.Events.MANIFEST_PARSED, () => {
          console.log('[VideoPlayer] 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) {
            console.warn('[VideoPlayer] 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) => {
          console.error('[VideoPlayer] HLS error:', data);
          if (data.fatal) {
            // Check if we're near the end of the video - if so, this is likely
            // end-of-stream rather than a real error. Jellyfin transcoded HLS
            // streams may not always terminate cleanly with #EXT-X-ENDLIST.
            const knownDuration = media?.durationMs ? media.durationMs / 1000 : videoDuration;
            const effectiveTime = currentTime + seekOffset;
            const isNearEnd = knownDuration > 0 && effectiveTime > 0 && effectiveTime / knownDuration > 0.9;

            switch (data.type) {
              case Hls.ErrorTypes.NETWORK_ERROR:
                hlsFatalRecoveryAttempts++;
                if (isNearEnd) {
                  // Near end of stream - treat as natural end, don't restart
                  console.log('[VideoPlayer] Fatal network error near end of stream - treating as ended');
                  notifyEnded();
                } else if (hlsFatalRecoveryAttempts <= 3) {
                  console.error('[VideoPlayer] Fatal network error, trying to recover (attempt', hlsFatalRecoveryAttempts, ')');
                  hls!.startLoad();
                } else {
                  console.error('[VideoPlayer] Fatal network error, max recovery attempts reached');
                  hls!.destroy();
                }
                break;
              case Hls.ErrorTypes.MEDIA_ERROR:
                console.error('[VideoPlayer] Fatal media error, trying to recover');
                hls!.recoverMediaError();
                break;
              default:
                console.error('[VideoPlayer] Unrecoverable HLS error');
                hls!.destroy();
                break;
            }
          }
        });
      }, 50); // 50ms delay to ensure cleanup completes

      // Cleanup on effect re-run
      return () => {
        console.log('[VideoPlayer] Effect cleanup: destroying HLS instance');
        if (hls) {
          hls.detachMedia();
          hls.stopLoad();
          hls.destroy();
          hls = null;
        }
        if (videoElement) {
          videoElement.pause();
        }
      };
    } else if (isHlsStream && videoElement.canPlayType('application/vnd.apple.mpegurl')) {
      // Native HLS support (Safari)
      console.log('[VideoPlayer] Using native HLS support');
      videoElement.src = currentStreamUrl;
    } else {
      // Not an HLS stream, use regular video element
      console.log('[VideoPlayer] Using regular video element for non-HLS stream');
    }
  });

  // 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;
      console.log("[VideoPlayer] Video element configured: muted=", videoElement.muted, "volume=", videoElement.volume);

      // DIAGNOSTIC: Check if video has audio tracks
      if ((videoElement as any).audioTracks) {
        console.log("[VideoPlayer] 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;
          console.log("[VideoPlayer] Selected default audio track:", selectedAudioTrackIndex);
        }
      }
      if ((videoElement as any).mozHasAudio !== undefined) {
        console.log("[VideoPlayer] mozHasAudio:", (videoElement as any).mozHasAudio);
      }
      if ((videoElement as any).webkitAudioDecodedByteCount !== undefined) {
        console.log("[VideoPlayer] 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(() => {
      console.log("[VideoPlayer] Initial position changed, seeking to:", pos);
      lastAppliedInitialPosition = pos;
      if (videoElement) {
        videoElement.currentTime = pos;
        currentTime = pos;
      }
    });
  });

  // 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(enterBackgroundAudioHandoff));
      nativeUnlisteners.push(subscribeAppForegrounded(exitBackgroundAudioHandoff));
    }

    // Initialize player via Rust - Rust will decide which backend to use based on platform
    if (media && currentStreamUrl) {
      try {
        console.log("[VideoPlayer] Initializing player for:", media.name);
        console.log("[VideoPlayer] 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))
          : [];
        console.log(`[VideoPlayer] 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,
          // 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;
        console.log(`[VideoPlayer] 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) {
          console.log("[VideoPlayer] Native backend available but experimentalNativeVideo is off - using HTML5");
          useHtml5Element = true;
          try {
            await commands.playerStop();
            didStopBackendEarly = true;
          } catch (err) {
            console.warn("[VideoPlayer] 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.
          console.log("[VideoPlayer] 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 {
            console.log("[VideoPlayer] 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) {
            console.warn("[VideoPlayer] Failed to stop backend player:", err);
          }
        } else if (useHtml5Element && needsTranscoding) {
          console.log("[VideoPlayer] 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) => { currentStreamUrl = u; },
          });
          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) {
        console.error("[VideoPlayer] 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.
          console.warn("[VideoPlayer] 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();

    // 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);
        }
      }, 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.
        console.log(
          `[VideoPlayer 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();

    // 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) {
      console.log("[VideoPlayer] 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 {
        console.log("[VideoPlayer] Stopping backend player on component unmount");
        await commands.playerStop();
      } catch (err) {
        console.error("[VideoPlayer] 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() {
    console.log("[VideoPlayer] loadedmetadata event");
    console.log("[VideoPlayer] Video element duration:", videoElement?.duration);
    console.log("[VideoPlayer] Media item runTimeTicks:", media?.runTimeTicks);
    console.log("[VideoPlayer] 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;
      console.log("[VideoPlayer] Setting videoDuration to:", newDuration);
      videoDuration = newDuration;
      console.log("[VideoPlayer] 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(() => {
      console.log("[VideoPlayer] Derived duration value:", duration);
      console.log("[VideoPlayer] 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) {
        console.error("[VideoPlayer] Failed to resume after background audio:", err);
      }
    };

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

  function markMediaReady() {
    if (isMediaReady) return;
    console.log("[VideoPlayer] 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)
    console.log("[VideoPlayer] 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;
      console.log("[VideoPlayer] 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) {
      console.log("[VideoPlayer] 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) {
        console.error("[VideoPlayer] 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
    console.error("[VideoPlayer] 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})`;
    console.error("[VideoPlayer] Error interpretation:", msg);

    // Log additional debugging info
    console.error("[VideoPlayer] Stream URL:", currentStreamUrl);
    console.error("[VideoPlayer] 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"];
    console.error("[VideoPlayer] 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"];
    console.error("[VideoPlayer] Ready state:", readyStates[video.readyState] || video.readyState);
  }

  function handleWaiting() {
    console.log("[VideoPlayer] waiting event - buffering");
    isBuffering = true;
  }

  function handlePlaying() {
    console.log("[VideoPlayer] 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() {
    console.log("[VideoPlayer] loadstart event - starting to load:", currentStreamUrl);
    console.log("[VideoPlayer] Video element readyState:", videoElement?.readyState);
    console.log("[VideoPlayer] 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) {
        console.warn("[VideoPlayer] canplay event did not fire within 5 seconds");
        console.log("[VideoPlayer] 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
          console.log("[VideoPlayer] 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) {
      console.warn("[VideoPlayer] 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);
    }
  });

  function handlePlay() {
    isPlaying = true;
    startTimeUpdates(); // Start RAF loop for smooth time updates
    // 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;
    console.log(
      `[VideoPlayer] 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
    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) {
      console.error("[VideoPlayer] 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 {
      console.log("[VideoPlayer] 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();
      }

      console.log("[VideoPlayer] Seek completed at:", currentTime.toFixed(2), "offset:", seekOffset);
    } catch (err) {
      console.error("[VideoPlayer] 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 };

  function toggleBackgroundAudio() {
    backgroundAudioOn = !backgroundAudioOn;
    console.log("[VideoPlayer] 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) {
      console.warn("[VideoPlayer] 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 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;
    console.log("[VideoPlayer] 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) {
      console.error("[VideoPlayer] 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() {
    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();
      console.log("[VideoPlayer] 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;

      pendingForegroundPlay = wasPlaying;

      // Determine the target URL + how the element/offset should be positioned.
      let targetUrl: string;
      if (needsTranscoding && onSeek) {
        // Transcoded HLS can't seek by setting currentTime — the stream must be
        // rebuilt at the new position (StartTimeTicks). onSeek returns that URL.
        // The reloaded segment's timeline starts at 0, so seekOffset carries the
        // absolute base and the element seeks to 0 (handled on canplay).
        targetUrl = await onSeek(pos, selectedAudioTrackIndex ?? undefined);
        seekOffset = pos;
        currentTime = pos;
        pendingForegroundSeek = 0;
      } else {
        // Direct stream: reload the original URL and seek the element to pos.
        targetUrl = streamUrl;
        seekOffset = 0;
        pendingForegroundSeek = pos;
      }

      // 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).
      currentStreamUrl = "";
      await Promise.resolve();
      currentStreamUrl = targetUrl;
    } catch (err) {
      console.error("[VideoPlayer] Background-audio return failed:", err);
    }
  }

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

  function toggleFullscreen() {
    if (!document.fullscreenElement) {
      document.documentElement.requestFullscreen();
      isFullscreen = true;
    } else {
      document.exitFullscreen();
      isFullscreen = false;
    }
  }

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

  function handleMouseMove() {
    showControls = true;
    if (controlsTimeout) {
      clearTimeout(controlsTimeout);
    }
    controlsTimeout = setTimeout(() => {
      if (isPlaying) {
        showControls = false;
      }
    }, 3000);
  }

  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;

    console.log("[VideoPlayer] 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) {
        document.exitFullscreen();
      } 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;
  }

  /**
   * 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) {
    console.log("[VideoPlayer] 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();
      }

      console.log("[VideoPlayer] 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
            );
            console.log("[VideoPlayer] Saved series audio preference:", selectedTrack.displayTitle || selectedTrack.language);
          }
        } catch (err) {
          console.warn("[VideoPlayer] Failed to save series audio preference:", err);
        }
      }
    } catch (err) {
      console.error("[VideoPlayer] Failed to change audio track:", err);
      // Revert to previous track on error
      selectedAudioTrackIndex = previousTrackIndex;
    }
  }

  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";
        console.log("[VideoPlayer] 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) {
    console.log("[VideoPlayer] 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);
        console.log("[VideoPlayer] Native backend subtitle track changed - streamIndex:", streamIndex, "position:", indexToUse);
      } catch (error) {
        console.error("[VideoPlayer] 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 -->
  <div class="flex-1 flex items-center justify-center relative">
    {#if !!useHtml5Element}
      <!-- HTML5 video for desktop/non-Android platforms -->
      <video
          bind:this={videoElement}
          src={currentStreamUrl.includes('.m3u8') && Hls.isSupported() ? '' : currentStreamUrl}
          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 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
        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}
    class:pointer-events-none={!showControls}
  >
    <!-- 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}

        <!-- 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>
