import { useEffect, useMemo, useRef, useState, useCallback } from 'react'
import { useQueryClient } from '@tanstack/react-query'
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
import {
  MediaPlayer,
  MediaProvider,
  Track,
  isHLSProvider,
  type MediaPlayerInstance,
  type MediaProviderAdapter,
} from '@vidstack/react'
import HLS from 'hls.js'
import '@vidstack/react/player/styles/base.css'
import { motion, AnimatePresence } from 'framer-motion'
import {
  Play,
  RotateCcw,
  RotateCw,
} from '../lib/icons'
import { useItemDetails, useMediaSegments, usePlaybackInfo } from '../hooks/use-jellyfin'
import { usePlayerNavigation } from '../hooks/use-player-navigation'
import { usePlayerChrome } from '../hooks/use-player-chrome'
import { getSubtitleUrl, jellyfinClient } from '../api/jellyfin'
import { usePreferencesStore } from '../stores/preferences-store'
import { ticksToSeconds } from '../lib/format'
import { getAudioStreams, getSubtitleStreams } from '../lib/jellyfin-meta'
import { pickSubtitle } from '../lib/subtitle-match'
import StreamInfo from '../components/player/StreamInfo'
import SubtitleOverlay from '../components/player/SubtitleOverlay'
import LibAssRenderer from '../components/player/LibAssRenderer'
import PlayerOverlays from '../components/player/PlayerOverlays'
import { computeRecapTrigger } from '../lib/recap-trigger'
import { usePersonalData } from '../stores/personal-data-store'
import { usePlayerRuntimeStore } from '../stores/player-runtime-store'
import { useQueueStore } from '../stores/queue-store'
import { usePlayerShortcuts } from '../hooks/use-player-shortcuts'
import { detachAudioGraph } from '../lib/audio-graph'
import type { ShortcutContext } from '../lib/player-shortcuts'
import EpisodesPanel from '../components/player/EpisodesPanel'
import { type QualityOption } from '../components/player/QualityMenu'
import PlayerTopBar from '../components/player/PlayerTopBar'
import PlayerBottomBar from '../components/player/PlayerBottomBar'
import { usePlayerPictureFilter } from '../hooks/use-player-picture-filter'
import { usePlayerAudioGraph } from '../hooks/use-player-audio-graph'
import { usePlaybackReporting } from '../hooks/use-playback-reporting'
import { usePlayerPanels } from '../hooks/use-player-panels'
import { addBookmark as bookmarksAdd } from '../lib/bookmarks'
import { recordManualSkip, dismissSkipPrompt } from '../lib/skip-tracker'
import { recordSkippedSeconds } from '../lib/time-saved'
import { useReducedMotion } from '../hooks/use-reduced-motion'
import { useSyncPlaySocketBridge } from '../hooks/use-syncplay'
import { useSyncPlay } from '../stores/syncplay-store'
import { sendPause as spPause, sendUnpause as spUnpause, sendSeek as spSeek } from '../lib/syncplay'
import { useTraktScrobble } from '../hooks/use-trakt-scrobble'
import { useDownloads } from '../stores/downloads-store'
import { startDownload } from '../lib/downloads'
import { getBestImage } from '../api/jellyfin'

export default function PlayerPage() {
  const { id } = useParams<{ id: string }>()
  const navigate = useNavigate()
  const [searchParams] = useSearchParams()
  const { data: item } = useItemDetails(id)
  const playerRef = useRef<MediaPlayerInstance>(null)
  const qc = useQueryClient()
  const progressRef = useRef(0)
  // Seek the in-player resume prompt asks for, but only applied on the
  // next canPlay so the seek survives when the source isn't ready yet.
  const pendingSeekRef = useRef<number | null>(null)

  // Start paused so the play icon shows until the player actually begins
  // playback - if autoplay succeeds, the onPlay handler flips this to false.
  const [isPaused, setIsPaused] = useState(true)
  const [isMuted, setIsMuted] = useState(false)
  // Volume lives in the preferences store so it persists across episodes,
  // shows, and sessions. Mute is intentionally per-session (autoplay
  // fallback can mute briefly without that polluting the saved volume).
  const volume = usePreferencesStore(s => s.playerVolume)
  const setVolumePref = usePreferencesStore(s => s.setPreference)
  const setVolume = useCallback(
    (v: number) => setVolumePref('playerVolume', Math.max(0, Math.min(1, v))),
    [setVolumePref],
  )
  const [isFullscreen, setIsFullscreen] = useState(false)
  // High-frequency playback state. currentTime and buffered update on every
  // vidstack tick (4-60 Hz) - we store the raw value in a ref for imperative
  // code (progress reporting, A-B loop, seek calculations) and only push to
  // React state at ~4 Hz so the UI doesn't re-render on every video frame.
  const currentTimeRef = useRef(0)
  const bufferedRef = useRef(0)
  const [currentTime, setCurrentTime] = useState(0)
  const [duration, setDuration] = useState(0)
  const [buffered, setBuffered] = useState(0)
  const [scrubPercent, setScrubPercent] = useState<number | null>(null)
  const [seeking, setSeeking] = useState(false)
  const lastUiUpdateRef = useRef(0)

  /* Track selection (audio / subtitle).
   * `audioIndex` is the UI selection; `streamAudioIndex` is what we send
   * to PlaybackInfo. They diverge when the active source is direct-play
   * multi-audio: we switch instantly via HTMLMediaElement.audioTracks
   * without triggering a stream reload, but the menu still highlights the
   * picked track. For transcoded sources the two stay in sync. */
  const [audioIndex, setAudioIndex] = useState<number | null>(null)
  const [streamAudioIndex, setStreamAudioIndex] = useState<number | null>(null)
  const [subtitleIndex, setSubtitleIndex] = useState<number | null>(null)
  const panels = usePlayerPanels()
  const setPanel = panels.set
  const streamInfoOpen = panels.state.streamInfo
  const episodesOpen = panels.state.episodes
  const hintsOpen = panels.state.hints
  const bookmarksOpen = panels.state.bookmarks
  const chaptersOpen = panels.state.chapters
  const subSearchOpen = panels.state.subSearch
  const syncPlayOpen = panels.state.syncPlay
  const setStreamInfoOpen = (v: boolean | ((p: boolean) => boolean)) =>
    panels.set('streamInfo', typeof v === 'function' ? v(streamInfoOpen) : v)
  const setEpisodesOpen = (v: boolean | ((p: boolean) => boolean)) =>
    panels.set('episodes', typeof v === 'function' ? v(episodesOpen) : v)
  const setHintsOpen = (v: boolean | ((p: boolean) => boolean)) =>
    panels.set('hints', typeof v === 'function' ? v(hintsOpen) : v)
  const setBookmarksOpen = (v: boolean | ((p: boolean) => boolean)) =>
    panels.set('bookmarks', typeof v === 'function' ? v(bookmarksOpen) : v)
  const setChaptersOpen = (v: boolean | ((p: boolean) => boolean)) =>
    panels.set('chapters', typeof v === 'function' ? v(chaptersOpen) : v)
  const setSubSearchOpen = (v: boolean | ((p: boolean) => boolean)) =>
    panels.set('subSearch', typeof v === 'function' ? v(subSearchOpen) : v)
  const setSyncPlayOpen = (v: boolean | ((p: boolean) => boolean)) =>
    panels.set('syncPlay', typeof v === 'function' ? v(syncPlayOpen) : v)

  // Trakt scrobble - no-op when the user hasn't connected Trakt.
  useTraktScrobble({ item, isPaused, currentTime, duration })

  // Mirror local pause/play state through a ref so the vidstack
  // subscribe callback (which captures isPaused at registration time)
  // can compare against the latest value without us re-binding on every
  // render.
  const isPausedRef = useRef(isPaused)
  isPausedRef.current = isPaused

  // SyncPlay socket + remote-command bridge. The hook handles its own
  // socket lifecycle; we only need to read the latest remote command
  // here and apply it to the local player.
  useSyncPlaySocketBridge()
  const syncPlayActive = useSyncPlay(s => !!s.active)
  const lastRemoteSeq = useSyncPlay(s => s.lastRemoteSeq)
  const lastRemoteCommand = useSyncPlay(s => s.lastRemoteCommand)
  const remoteQueueItem = useSyncPlay(s => s.remoteQueueItem)
  const remoteSuppressRef = useRef(0)
  useEffect(() => {
    if (!lastRemoteCommand || lastRemoteSeq === 0) return
    const p = playerRef.current
    if (!p) return
    // Mark the next local pause/play/seek as remote-originated so we
    // don't echo it back to the server (which would feedback-loop).
    remoteSuppressRef.current = Date.now() + 800
    if (lastRemoteCommand.type === 'pause') {
      p.pause()
    } else if (lastRemoteCommand.type === 'play') {
      p.play().catch(() => {})
    } else if (lastRemoteCommand.type === 'seek' && typeof lastRemoteCommand.positionTicks === 'number') {
      p.currentTime = lastRemoteCommand.positionTicks / 10_000_000
    }
  }, [lastRemoteSeq, lastRemoteCommand])
  // Catch the joiner up to whatever the party is currently watching.
  // If the host is on a different item, navigate to it; otherwise seek
  // into the current playback. We track the last-applied queue item in
  // a ref so we only navigate / seek when the value actually changes.
  const appliedQueueRef = useRef<string | null>(null)
  useEffect(() => {
    if (!remoteQueueItem) return
    const key = `${remoteQueueItem.itemId}:${remoteQueueItem.positionTicks}`
    if (appliedQueueRef.current === key) return
    appliedQueueRef.current = key
    if (remoteQueueItem.itemId !== id) {
      navigate(`/play/${remoteQueueItem.itemId}`, { replace: true })
      return
    }
    const p = playerRef.current
    if (!p) return
    remoteSuppressRef.current = Date.now() + 800
    p.currentTime = remoteQueueItem.positionTicks / 10_000_000
    if (remoteQueueItem.isPlaying) {
      p.play().catch(() => {})
    } else {
      p.pause()
    }
  }, [remoteQueueItem, id, navigate])
  function shouldSuppressRemoteEcho() {
    return Date.now() < remoteSuppressRef.current
  }
  const [bookmarksRefreshKey, setBookmarksRefreshKey] = useState(0)
  const [resumePromptOpen, setResumePromptOpen] = useState(false)
  const [recapCardOpen, setRecapCardOpen] = useState(false)
  const [endCardOpen, setEndCardOpen] = useState(false)
  const [qualityKey, setQualityKey] = useState<string>('auto')
  const [maxBitrate, setMaxBitrate] = useState<number | undefined>(undefined)
  const [skipPromptSeriesId, setSkipPromptSeriesId] = useState<string | null>(null)
  const areYouStillWatching = usePreferencesStore(s => s.areYouStillWatching)
  const autoAdvanceCountRef = useRef(0)
  const [stillWatchingOpen, setStillWatchingOpen] = useState(false)
  const stillWatchingTargetRef = useRef<string | null>(null)

  /* Preferences */
  const sleepTimerMinutes = usePreferencesStore(s => s.sleepTimerMinutes)
  const autoplayNext = usePreferencesStore(s => s.autoplayNext)
  const subtitleMode = usePreferencesStore(s => s.subtitleMode)
  const subtitleLanguage = usePreferencesStore(s => s.subtitleLanguage)
  const skipIntros = usePreferencesStore(s => s.skipIntros)
  const skipCredits = usePreferencesStore(s => s.skipCredits)
  const defaultPlaybackRate = usePreferencesStore(s => s.defaultPlaybackRate)
  const preserveAudioPitch = usePreferencesStore(s => s.preserveAudioPitch)
  const pauseOnBlur = usePreferencesStore(s => s.pauseOnBlur)
  const showResumePromptPref = usePreferencesStore(s => s.showResumePrompt)
  const showRecapCardPref = usePreferencesStore(s => s.episode.recap.card)
  const recapGapDays = usePreferencesStore(s => s.episode.recap.gapDays)
  const setPreference = usePreferencesStore(s => s.setPreference)

  /* Per-session player runtime state - subtitle offset is read directly
   * inside SubtitleOverlay's tick; audio offset is read by the audio-graph
   * hook so it doesn't need to live here. */
  const theaterModeOn = usePlayerRuntimeStore(s => s.theaterMode)
  const loopA = usePlayerRuntimeStore(s => s.loopA)
  const loopB = usePlayerRuntimeStore(s => s.loopB)

  /* Playback speed - session local, can persist to defaultPlaybackRate */
  const [playbackRate, setPlaybackRate] = useState(defaultPlaybackRate || 1)

  /* Sleep timer - counts down only while playing */
  const sleepRemainingRef = useRef(sleepTimerMinutes * 60)
  const [sleepRemainingSec, setSleepRemainingSec] = useState(sleepTimerMinutes * 60)
  useEffect(() => {
    const sec = sleepTimerMinutes * 60
    sleepRemainingRef.current = sec
    setSleepRemainingSec(sec)
  }, [sleepTimerMinutes, id])
  useEffect(() => {
    if (sleepTimerMinutes <= 0) return
    if (!isPaused) {
      const id = window.setInterval(() => {
        const next = sleepRemainingRef.current - 1
        sleepRemainingRef.current = next
        setSleepRemainingSec(next)
        if (next <= 0) {
          playerRef.current?.pause()
          window.clearInterval(id)
        }
      }, 1000)
      return () => window.clearInterval(id)
    }
  }, [isPaused, sleepTimerMinutes])

  const reducedMotion = useReducedMotion()
  const {
    controlsVisible,
    seekIndicator,
    transientToast,
    showControls,
    showSeekIndicator,
    showToast,
  } = usePlayerChrome(isPaused)

  const subtitleStreams = item ? getSubtitleStreams(item) : []
  const mediaSourceId = item?.MediaSources?.[0]?.Id || undefined

  const {
    seriesId,
    seasonEpisodes,
    nextUpItem,
    queueActive,
    queueNext,
    previousItem,
    nextItem,
  } = usePlayerNavigation(item, id)
  const showUpNextWindow = duration > 0 && currentTime > 0 && duration - currentTime <= 30
  const [upNextDismissed, setUpNextDismissed] = useState(false)
  // Up Next card uses the SAME `nextItem` resolution that drives the
  // chrome's next button and the auto-advance on `onEnded`. When the
  // user is on a shuffled queue, queueNext (next in shuffle order) wins
  // over the series's natural next-up so the card actually previews
  // what will play next.
  const upNextCard = nextItem || nextUpItem || null
  const nextUpVisible =
    !!upNextCard &&
    upNextCard.Id !== item?.Id &&
    (item?.Type === 'Episode' || queueActive) &&
    showUpNextWindow &&
    !upNextDismissed
  const upNextCountdown = Math.max(0, Math.ceil(duration - currentTime))

  const auth = jellyfinClient.getAuthState()
  const token = auth?.token || ''
  const serverUrl = auth?.serverUrl || ''

  const resume = searchParams.get('resume') === 'true'
  const positionTicks = item?.UserData?.PlaybackPositionTicks
  const startTimeTicks = resume && positionTicks ? Number(positionTicks) : undefined
  // If the user clicked Resume from a deep link, the item hasn't
  // returned yet on the first render and startTimeTicks is undefined.
  // Block the first PlaybackInfo call until the saved position is
  // known - otherwise the server returns a stream that starts at 0
  // and the video buffers before the second query refires.
  const playbackInfoReady = !resume || startTimeTicks !== undefined
  if (typeof window !== 'undefined') {
    console.log('[player] resume state', {
      id,
      resume,
      positionTicks,
      startTimeTicks,
      playbackInfoReady,
    })
  }

  /* Resolve the proper stream URL from Jellyfin's PlaybackInfo endpoint.
   * The server picks direct-play / direct-stream / transcoded HLS based on
   * our DeviceProfile and returns a URL with a valid PlaySessionId so HLS
   * segment fetches don't 400. */
  // streamAudioIndex re-runs PlaybackInfo so the server returns a fresh
  // TranscodingUrl muxing the chosen audio track. We only set it when a
  // native audioTracks switch isn't possible (transcoded streams, or
  // single-audio sources where the alternate track isn't in the file).
  // maxBitrate threads the user-picked quality cap into the same call.
  // Offline fallback: if this item was downloaded, use the Blob URL
  // instead of hitting the server. PlaybackInfo and reporting gracefully
  // no-op when there's no connection.
  const downloaded = useDownloads(s => s.getByItemId(id || ''))
  const offlineUrl = downloaded?.status === 'done' ? downloaded.localPath : undefined

  const { data: playbackInfo } = usePlaybackInfo(
    id,
    startTimeTicks,
    streamAudioIndex ?? undefined,
    maxBitrate,
    playbackInfoReady,
    startTimeTicks !== undefined,
  )
  const resolvedSource = playbackInfo?.MediaSources?.[0]
  const streamUrl = (() => {
    if (offlineUrl) return offlineUrl
    if (!resolvedSource || !serverUrl) return ''
    // Direct play: server-confirmed the browser can decode the source as-is
    if (resolvedSource.SupportsDirectPlay) {
      const sep = `${serverUrl}/Videos/${id}/stream?static=true&MediaSourceId=${resolvedSource.Id}&api_key=${token}`
      return startTimeTicks ? `${sep}&StartTimeTicks=${startTimeTicks}` : sep
    }
    // HLS transcoded - the URL the server prepared for us, which already has
    // PlaySessionId, runtimeTicks, and api_key baked in.
    if (resolvedSource.TranscodingUrl) {
      return `${serverUrl}${resolvedSource.TranscodingUrl}`
    }
    return ''
  })()
  if (typeof window !== 'undefined' && playbackInfo) {
    const transcodingUrl = resolvedSource?.TranscodingUrl
    const supportsDirectPlay = resolvedSource?.SupportsDirectPlay
    const hasRuntimeTicks = transcodingUrl ? transcodingUrl.includes('runtimeTicks=') : null
    console.log('[player] stream resolved', {
      id,
      startTimeTicks,
      supportsDirectPlay,
      transcodingUrlPresent: !!transcodingUrl,
      transcodingUrlHasRuntimeTicks: hasRuntimeTicks,
      streamUrl: streamUrl.replace(/api_key=[^&]+/, 'api_key=***'),
    })
  }

  /* Resume behaviour: when the saved position is past the threshold,
   * queue the seek so onCanPlay can apply it (the underlying
   * HTMLMediaElement.currentTime is the only path that works for both
   * direct-play MP4 and HLS without restarting from 0). Decide whether
   * to also show the prompt:
   *   - ?resume=true  : the caller already chose Resume, don't ask again
   *   - playlist queue: auto-advance through a playlist shouldn't ask
   *     before every episode
   *   - otherwise     : show the prompt so the user can pick Resume /
   *     Start over (if the show-resume-prompt pref is on) */
  const resumePromptShownRef = useRef<string | null>(null)
  const resumeItemId = item?.Id
  const resumePositionTicks = item?.UserData?.PlaybackPositionTicks

  /* Reset transient flags on item change */
  useEffect(() => {
    setUpNextDismissed(false)
    setPanel('streamInfo', false)
    setAudioIndex(null)
    setStreamAudioIndex(null)
    setEndCardOpen(false)
    pendingSeekRef.current = null
    resumePromptShownRef.current = null
    usePlayerRuntimeStore.getState().resetForNewItem()
  }, [id, setPanel])

  /* Resume behaviour: when the saved position is past the threshold,
   * queue the seek so onCanPlay can apply it (the underlying
   * HTMLMediaElement.currentTime is the only path that works for both
   * direct-play MP4 and HLS without restarting from 0). Decide whether
   * to also show the prompt:
   *   - ?resume=true  : the caller already chose Resume, don't ask again
   *   - playlist queue: auto-advance through a playlist shouldn't ask
   *     before every episode
   *   - otherwise     : show the prompt so the user can pick Resume /
   *     Start over (if the show-resume-prompt pref is on) */
  const queueSource = useQueueStore(s => s.source)
  useEffect(() => {
    if (!resumeItemId) return
    const pos = Number(resumePositionTicks ?? 0)
    const thresholdSec = usePreferencesStore.getState().resumeThresholdSec ?? 5
    const threshold = thresholdSec * 10_000_000
    if (pos <= threshold) return
    const target = pos / 10_000_000
    if (pendingSeekRef.current == null) {
      pendingSeekRef.current = target
    }
    if (resumePromptShownRef.current === resumeItemId) return
    const inPlaylist = queueActive && queueSource?.type === 'playlist'
    const skipPrompt = resume || inPlaylist
    if (!skipPrompt && showResumePromptPref) {
      setResumePromptOpen(true)
      resumePromptShownRef.current = resumeItemId
    }
  }, [resumeItemId, resumePositionTicks, showResumePromptPref, resume, queueActive, queueSource])

  /* Auto-rewatch counter: when an already-played item starts playing
   * again, record the rewatch. We trip it at most once per item-mount
   * so a mid-session pause/resume doesn't double-count, but each fresh
   * mount of the same item (close + reopen, navigate away + back)
   * counts as a new rewatch. */
  const rewatchedItemIdRef = useRef<string | null>(null)
  useEffect(() => {
    if (!item || !item.Id || String(item.Id).startsWith('tmdb-')) return
    if (item.Type !== 'Movie' && item.Type !== 'Series' && item.Type !== 'Episode') return
    if (!item.UserData?.Played) return
    if (rewatchedItemIdRef.current === item.Id) return
    rewatchedItemIdRef.current = item.Id
    usePersonalData.getState().incrementRewatch(item.Id)
  }, [item])

  /* Auto-recap trigger: decide once per item. Recap card waits for the
   * resume prompt (if any) to resolve before appearing. */
  const recapTrigger = useMemo(
    () => computeRecapTrigger(item, seasonEpisodes, recapGapDays),
    [item, seasonEpisodes, recapGapDays],
  )
  const [recapPending, setRecapPending] = useState(false)
  useEffect(() => {
    if (!item) return
    const dismissed = usePlayerRuntimeStore.getState().recapDismissed
    if (showRecapCardPref && recapTrigger.shouldShow && !dismissed) {
      setRecapPending(true)
    } else {
      setRecapPending(false)
    }
  }, [item?.Id, recapTrigger.shouldShow, showRecapCardPref, item])
  useEffect(() => {
    if (recapPending && !resumePromptOpen) {
      setRecapCardOpen(true)
      setRecapPending(false)
    }
  }, [recapPending, resumePromptOpen])

  /* Auto-pause on window blur. Only if the user opted in. Tracks whether
   * we're the one who paused so re-focus doesn't resume a user-paused
   * video. */
  useEffect(() => {
    if (!pauseOnBlur) return
    const pausedByUs = { current: false }
    function onBlur() {
      const p = playerRef.current
      if (!p || p.paused) return
      pausedByUs.current = true
      p.pause()
    }
    function onFocus() {
      if (!pausedByUs.current) return
      pausedByUs.current = false
      playerRef.current?.play().catch(() => {})
    }
    window.addEventListener('blur', onBlur)
    window.addEventListener('focus', onFocus)
    return () => {
      window.removeEventListener('blur', onBlur)
      window.removeEventListener('focus', onFocus)
    }
  }, [pauseOnBlur])

  /* Apply audio graph state when offsets / boost / night mode change */
  usePlayerAudioGraph(playerRef, streamUrl)

  /* Active query eviction for the previous episode. Drops cached
   * PlaybackInfo + the played-item details + chapters / markers so the
   * cache doesn't accumulate during a long binge. The next episode
   * fetches fresh - cost is one round-trip per episode change, gain
   * is bounded memory. */
  useEffect(() => {
    const evictId = id
    return () => {
      if (!evictId) return
      try {
        qc.removeQueries({ queryKey: ['jellyfin', 'playback-info', evictId], exact: false })
        qc.removeQueries({ queryKey: ['jellyfin', 'item', evictId], exact: false })
        qc.removeQueries({ queryKey: ['jellyfin', 'media-segments', evictId], exact: false })
        qc.removeQueries({ queryKey: ['jellyfin', 'episodes', evictId], exact: false })
      } catch { /* ignore */ }
    }
  }, [id, qc])

  /* Aggressive teardown on player unmount.
   *
   * The official Jellyfin web client (and basically every player) gets
   * away with the default vidstack/hls.js cleanup because they don't
   * remount per-episode - they swap the source on a single long-lived
   * media element. We DO remount per-episode (Routes is keyed on
   * pathname), so each shuffle step creates a new MediaSource +
   * SourceBuffers and the browser holds the old buffered video data
   * until GC catches up. After 13+ episodes the WebView accumulates
   * enough buffer pressure to crash.
   *
   * We can't change the routing without a bigger rewrite, but we can
   * help the browser release the previous episode's media path
   * immediately:
   *   1. Pause the underlying <video> so the decoder stops.
   *   2. Detach src so the MediaSource gets disconnected.
   *   3. Call load() so the element resets and any pending buffer
   *      operations abort.
   *   4. Detach the audio graph (closes AudioContext).
   *
   * Wrapped in try/catch since vidstack may have already cleaned the
   * element by the time this runs. */
  // Capture the underlying <video> element in a ref so the unmount
  // teardown can clean up THIS component's video without accidentally
  // grabbing the next episode's element (which React may have already
  // committed to the DOM before our cleanup runs).
  const videoElRef = useRef<HTMLVideoElement | null>(null)
  useEffect(() => {
    const el = (playerRef.current as { el?: HTMLElement } | null)?.el
    videoElRef.current = el?.querySelector('video') as HTMLVideoElement | null ?? null
  }, [streamUrl])
  useEffect(() => {
    return () => {
      const video = videoElRef.current
      if (video) {
        try {
          video.pause()
          video.removeAttribute('src')
          video.load()
        } catch { /* element already gone */ }
      }
      detachAudioGraph()
    }
  }, [])

  /* Picture filters - apply CSS `filter` to the underlying <video> element.
   * Drives off prefs directly so a refresh restores the user's tuning. */
  const { videoBrightness, videoContrast, videoSaturation } = usePlayerPictureFilter(playerRef, streamUrl)

  /* Subscribe to vidstack's reactive state. Going through .subscribe() means
   * we get duration, currentTime, paused, muted, volume, buffered, and seeking
   * as a single coherent snapshot - safer than mixing event handlers where
   * each event's detail shape varies between vidstack versions. */
  useEffect(() => {
    const player = playerRef.current
    if (!player || !streamUrl) return
    // Volume restore - vidstack's high-level player.volume setter sometimes
    // no-ops during early init / source-swap, so we also write directly to
    // the underlying <video> element. Both paths are idempotent. We retry
    // on every meaningful state tick until the native volume actually
    // matches the pref - that's the only reliable signal.
    function getNativeVideo(): HTMLVideoElement | null {
      const el = (player as { el?: HTMLElement } | null)?.el
      return (el?.querySelector('video') as HTMLVideoElement | null) || null
    }
    function applyVolume() {
      const target = usePreferencesStore.getState().playerVolume
      if (!Number.isFinite(target)) return
      const clamped = Math.max(0, Math.min(1, target))
      try { player!.volume = clamped } catch { /* not ready */ }
      const video = getNativeVideo()
      if (video) {
        try { video.volume = clamped } catch { /* readonly during init */ }
      }
    }
    applyVolume()
    const unsub = player.subscribe(state => {
      // Volume restore - only retry if the native volume hasn't converged
      // yet AND the player is actually ready. Avoids writing on every tick.
      const target = usePreferencesStore.getState().playerVolume
      const video = getNativeVideo()
      if (video && Math.abs(video.volume - target) > 0.001) {
        if (state.canPlay || state.duration > 0) applyVolume()
      }
      if (Number.isFinite(state.duration) && state.duration > 0) {
        setDuration(state.duration)
      }
      if (Number.isFinite(state.currentTime)) {
        // Always update the ref at full rate for imperative code
        currentTimeRef.current = state.currentTime
        progressRef.current = state.currentTime * 10000000
        // Throttle React state updates to ~4 Hz to avoid re-rendering
        // the entire player tree on every video frame
        const now = performance.now()
        if (now - lastUiUpdateRef.current >= 250) {
          lastUiUpdateRef.current = now
          setCurrentTime(state.currentTime)
        }
        // A-B loop check runs on every tick (needs frame accuracy)
        const rt = usePlayerRuntimeStore.getState()
        if (
          rt.loopA != null &&
          rt.loopB != null &&
          rt.loopB > rt.loopA &&
          !state.seeking &&
          state.currentTime >= rt.loopB
        ) {
          const player = playerRef.current
          if (player) player.currentTime = rt.loopA
        }
      }
      if (state.paused !== isPausedRef.current) {
        if (useSyncPlay.getState().active && !shouldSuppressRemoteEcho()) {
          if (state.paused) spPause().catch(() => {})
          else spUnpause().catch(() => {})
        }
      }
      setIsPaused(state.paused)
      setIsMuted(state.muted)
      const ranges: any = state.buffered
      if (ranges && ranges.length > 0) {
        try {
          bufferedRef.current = ranges.end(ranges.length - 1)
          // Buffered changes rarely - update state alongside currentTime throttle
          const now = performance.now()
          if (now - lastUiUpdateRef.current >= 250) {
            setBuffered(bufferedRef.current)
          }
        } catch {
          /* TimeRanges can throw if the index moved */
        }
      }
      setSeeking(Boolean(state.seeking || state.waiting))
    })
    // Final safety net - if the player stalls before firing another state
    // event after canPlay, retry once at 250ms. Idempotent.
    const safetyTimer = setTimeout(applyVolume, 250)
    return () => {
      clearTimeout(safetyTimer)
      unsub()
    }
  }, [streamUrl])

  /* Vidstack handles autoplay via the autoPlay prop - we don't kick play()
   * ourselves because doing so before the can-play event throws "media not
   * ready". The autoplay-blocked case is handled by onAutoPlayFail below. */

  /* Detect intro / credits ranges. Two sources:
   *   1. Jellyfin MediaSegments API (the modern source the official client
   *      uses - populated by the segment-detection backend or a plugin).
   *   2. Chapters with names matching "Intro" / "Opening" / "Credits" /
   *      "Outro" - older fallback for items the segments backend never
   *      processed.
   * MediaSegments take precedence when available.
   */
  type Marker = { type: 'intro' | 'credits'; startSec: number; endSec: number }
  const { data: mediaSegments } = useMediaSegments(id)
  const markers: Marker[] = (() => {
    const out: Marker[] = []

    // Primary: MediaSegments
    for (const seg of mediaSegments || []) {
      const t = seg.Type
      let type: 'intro' | 'credits' | null = null
      if (t === 'Intro') type = 'intro'
      else if (t === 'Outro') type = 'credits'
      if (!type) continue
      const startSec = ticksToSeconds(Number(seg.StartTicks ?? 0))
      const endSec = ticksToSeconds(Number(seg.EndTicks ?? 0))
      if (endSec > startSec) out.push({ type, startSec, endSec })
    }

    // Fallback: chapters with intro/credits names
    if (out.length === 0) {
      const chapters = (item?.Chapters || []) as { Name?: string; StartPositionTicks?: number }[]
      if (chapters.length && duration) {
        for (let i = 0; i < chapters.length; i++) {
          const c = chapters[i]
          const name = (c.Name || '').toLowerCase()
          if (/intro end|credits end|outro end/.test(name)) continue
          let type: 'intro' | 'credits' | null = null
          if (/^intro\b|opening/.test(name)) type = 'intro'
          else if (/credits|outro/.test(name)) type = 'credits'
          if (!type) continue
          const startSec = ticksToSeconds(c.StartPositionTicks)
          const endNamed = chapters
            .slice(i + 1)
            .find(x => (type === 'intro' ? /intro end/ : /credits end|outro end/).test((x.Name || '').toLowerCase()))
          let endSec: number
          if (endNamed) endSec = ticksToSeconds(endNamed.StartPositionTicks)
          else if (i + 1 < chapters.length) endSec = ticksToSeconds(chapters[i + 1].StartPositionTicks)
          else endSec = duration
          if (endSec > startSec) out.push({ type, startSec, endSec })
        }
      }
    }
    return out
  })()

  const currentMarker: Marker | undefined = markers.find(
    m => currentTime >= m.startSec && currentTime < m.endSec - 0.5,
  )
  const currentMarkerType = currentMarker?.type
  const currentMarkerStartSec = currentMarker?.startSec
  const currentMarkerEndSec = currentMarker?.endSec

  /* Auto-skip when entering a marker AND the corresponding pref is on.
   * Also accumulate the skipped seconds into the per-series tally so the
   * detail page can surface a "you saved Xh Ym" badge. */
  useEffect(() => {
    if (!currentMarkerType || currentMarkerEndSec == null || !playerRef.current) return
    const p = playerRef.current
    const from = p.currentTime
    if (currentMarkerType === 'intro' && skipIntros) {
      p.currentTime = currentMarkerEndSec
      if (seriesId) recordSkippedSeconds(seriesId, 'intro', currentMarkerEndSec - from)
    } else if (currentMarkerType === 'credits' && skipCredits && duration > 0) {
      const target = Math.max(currentMarkerEndSec, duration - 0.5)
      p.currentTime = target
      if (seriesId) recordSkippedSeconds(seriesId, 'credits', target - from)
    }
  }, [currentMarkerType, currentMarkerStartSec, currentMarkerEndSec, skipIntros, skipCredits, duration, seriesId])

  /* Imperatively switch the active subtitle track. We use 'hidden' rather
   * than 'showing' so the browser doesn't paint its own caption UI over our
   * custom overlay - 'hidden' still loads cues and populates activeCues.
   *
   * We set mode on BOTH lists: vidstack's wrapped TextTrackList (drives the
   * vidstack renderer state) AND the browser's HTMLMediaElement.textTracks
   * (which actually loads and parses the VTT file). They aren't always in
   * sync, and setting only vidstack's list leaves the browser track disabled
   * with empty activeCues. */
  useEffect(() => {
    const player = playerRef.current
    if (!player) return
    const apply = () => {
      const targetId = subtitleIndex != null ? `sub-${subtitleIndex}` : null

      // 1. Vidstack's list
      const vsTracks: any = player.textTracks
      if (vsTracks) {
        const vsList: any[] =
          typeof vsTracks.toArray === 'function' ? vsTracks.toArray() : Array.from(vsTracks)
        for (const tr of vsList) {
          if (tr.kind !== 'subtitles' && tr.kind !== 'captions') continue
          const want = tr.id === targetId ? 'hidden' : 'disabled'
          if (typeof tr.setMode === 'function') tr.setMode(want)
          else tr.mode = want
        }
      }

      // 2. Browser's native list on the underlying <video>
      const el = (player as { el?: HTMLElement } | null)?.el
      const video = el?.querySelector?.('video') as HTMLVideoElement | null
      if (video?.textTracks) {
        for (let i = 0; i < video.textTracks.length; i++) {
          const tr = video.textTracks[i]
          if (tr.kind !== 'subtitles' && tr.kind !== 'captions') continue
          const want: TextTrackMode = tr.id === targetId ? 'hidden' : 'disabled'
          tr.mode = want
        }
      }
    }
    apply()
    const tt: any = player.textTracks
    if (tt?.addEventListener) {
      tt.addEventListener('add', apply)
      tt.addEventListener('change', apply)
      return () => {
        tt.removeEventListener('add', apply)
        tt.removeEventListener('change', apply)
      }
    }
  }, [subtitleIndex, subtitleStreams.length, id])

  /* Honour the subtitle preference whenever the item or pref changes:
   *   none    -> off
   *   always  -> best track matching subtitleLanguage (text > image,
   *              non-forced > forced); falls back to the server's
   *              default track if no language match exists
   *   default -> a track matching subtitleLanguage if present, else
   *              the server-marked default, else off
   *
   * Depends on the source id so it re-runs when a new episode loads -
   * the previous deps array missed `item` and bailed out early while
   * data was still streaming in, leaving subtitles off.
   */
  useEffect(() => {
    if (!item) return
    const subs = getSubtitleStreams(item)
    if (subs.length === 0 || subtitleMode === 'none') {
      setSubtitleIndex(null)
      return
    }
    if (subtitleMode === 'always') {
      const match = pickSubtitle(subs, subtitleLanguage) || subs[0]
      setSubtitleIndex(match.Index ?? null)
      return
    }
    // default: prefer a language match, then default-flagged, then off
    const match = pickSubtitle(subs, subtitleLanguage)
    setSubtitleIndex(match?.Index ?? null)
  }, [id, mediaSourceId, subtitleStreams.length, subtitleMode, subtitleLanguage, item])

  /* ── Playback reporting ──────────────────────────────────── */
  const playSessionId = playbackInfo?.PlaySessionId || undefined
  const playMethod: 'DirectPlay' | 'DirectStream' | 'Transcode' | undefined =
    resolvedSource?.SupportsDirectPlay
      ? 'DirectPlay'
      : resolvedSource?.SupportsDirectStream
        ? 'DirectStream'
        : resolvedSource?.TranscodingUrl
          ? 'Transcode'
          : undefined
  usePlaybackReporting({
    itemId: id,
    mediaSourceId,
    startTimeTicks,
    playSessionId,
    playMethod,
    audioIndex,
    subtitleIndex,
    isPaused,
    isMuted,
    volume,
    progressRef,
  })

  /* ── Helpers: PiP, screenshot, theater, frame step, etc. ─── */
  function togglePictureInPicture() {
    const el = (playerRef.current as { el?: HTMLElement } | null)?.el
    const video = el?.querySelector('video') as HTMLVideoElement | null
    if (!video) return
    if (document.pictureInPictureElement === video) {
      ;document.exitPictureInPicture?.().catch(() => {})
    } else {
      ;video.requestPictureInPicture?.().catch(() => {})
    }
  }

  function toggleTheaterMode() {
    usePlayerRuntimeStore.getState().setTheaterMode(!usePlayerRuntimeStore.getState().theaterMode)
  }

  function takeScreenshot() {
    const el = (playerRef.current as { el?: HTMLElement } | null)?.el
    const video = el?.querySelector('video') as HTMLVideoElement | null
    if (!video) return
    try {
      const canvas = document.createElement('canvas')
      canvas.width = video.videoWidth
      canvas.height = video.videoHeight
      const ctx = canvas.getContext('2d')
      if (!ctx) return
      ctx.drawImage(video, 0, 0, canvas.width, canvas.height)
      canvas.toBlob(async blob => {
        if (!blob) return
        try {
          await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })])
          showToast('Frame copied')
        } catch {
          showToast('Clipboard write failed')
        }
      }, 'image/png')
    } catch {
      showToast('Screenshot failed')
    }
  }

  function getFps(): number {
    const streams = item?.MediaSources?.[0]?.MediaStreams || []
    const v = streams.find(s => s.Type === 'Video')
    const fps = Number(v?.RealFrameRate ?? v?.AverageFrameRate ?? 0)
    return Number.isFinite(fps) && fps > 1 ? fps : 24
  }

  function stepFrame(direction: 1 | -1) {
    const p = playerRef.current
    if (!p) return
    if (!p.paused) p.pause()
    const step = 1 / getFps()
    p.currentTime = Math.max(0, (p.currentTime ?? 0) + direction * step)
  }

  function cycleSpeed(direction: 1 | -1) {
    const opts = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]
    const i = opts.findIndex(o => Math.abs(o - playbackRate) < 0.001)
    const next = opts[Math.max(0, Math.min(opts.length - 1, (i < 0 ? 2 : i) + direction))]
    applyPlaybackRate(next)
  }
  function resetSpeed() {
    applyPlaybackRate(1)
  }
  function applyPlaybackRate(rate: number) {
    setPlaybackRate(rate)
    const p = playerRef.current
    if (!p) return
    try {
      p.playbackRate = rate
    } catch { /* not ready */ }
    const el = (p as { el?: HTMLElement } | null)?.el
    const video = el?.querySelector('video') as HTMLVideoElement | null
    if (video) {
      video.playbackRate = rate
      ;video.preservesPitch = preserveAudioPitch
      ;video.mozPreservesPitch = preserveAudioPitch
      ;video.webkitPreservesPitch = preserveAudioPitch
    }
  }

  function bumpVolume(delta: number) {
    const p = playerRef.current
    if (!p) return
    const v = Math.max(0, Math.min(1, volume + delta))
    setVolume(v)
    p.volume = v
  }

  /* ── Keyboard shortcuts via the registry ──────────────────── */
  const shortcutContext: ShortcutContext = {
    navigate,
    showSeekIndicator,
    showControls,
    setStreamInfoOpen: (fn) => setStreamInfoOpen(fn),
    setIsMuted: (m) => setIsMuted(m),
    toggleFullscreen,
    togglePictureInPicture,
    toggleTheaterMode,
    takeScreenshot,
    toggleHints: () => setHintsOpen(o => !o),
    bumpVolume,
    cycleSpeed,
    resetSpeed,
    bumpSubtitleDelay: (deltaMs) => usePlayerRuntimeStore.getState().bumpSubtitleOffsetMs(deltaMs),
    resetSubtitleDelay: () => usePlayerRuntimeStore.getState().setSubtitleOffsetMs(0),
    bumpAudioDelay: (deltaMs) => usePlayerRuntimeStore.getState().bumpAudioOffsetMs(deltaMs),
    resetAudioDelay: () => usePlayerRuntimeStore.getState().setAudioOffsetMs(0),
    setLoopA: () => {
      const p = playerRef.current
      if (p) usePlayerRuntimeStore.getState().setLoopA(p.currentTime || 0)
    },
    setLoopB: () => {
      const p = playerRef.current
      if (p) usePlayerRuntimeStore.getState().setLoopB(p.currentTime || 0)
    },
    clearLoop: () => usePlayerRuntimeStore.getState().clearLoop(),
    addBookmark: () => {
      const p = playerRef.current
      if (!p || !id) return
      const t = p.currentTime ?? 0
      bookmarksAdd(id, t)
      setBookmarksRefreshKey(k => k + 1)
      showToast('Bookmark saved')
    },
    toggleBookmarksPanel: () => setBookmarksOpen(o => !o),
    toggleSubSearch: () => setSubSearchOpen(o => !o),
    stepFrame,
    prevChapter: () => {
      const p = playerRef.current
      if (!p || chapters.length === 0) return
      const t = p.currentTime ?? 0
      const ticks = t * 10_000_000
      // Find the chapter whose start is immediately before current time
      let prev = chapters[chapters.length - 1]
      for (let i = chapters.length - 1; i >= 0; i--) {
        const pos = Number(chapters[i].StartPositionTicks ?? 0)
        if (pos < ticks - 1_000_000) {
          prev = chapters[i]
          break
        }
      }
      if (prev) p.currentTime = Number(prev.StartPositionTicks ?? 0) / 10_000_000
    },
    nextChapter: () => {
      const p = playerRef.current
      if (!p || chapters.length === 0) return
      const t = p.currentTime ?? 0
      const ticks = t * 10_000_000
      const next = chapters.find(c => Number(c.StartPositionTicks ?? 0) > ticks + 1_000_000)
      if (next) p.currentTime = Number(next.StartPositionTicks ?? 0) / 10_000_000
    },
  }
  usePlayerShortcuts(playerRef, shortcutContext)

  async function toggleFullscreen() {
    try {
      if (!document.fullscreenElement) {
        await document.documentElement.requestFullscreen()
        setIsFullscreen(true)
      } else {
        await document.exitFullscreen()
        setIsFullscreen(false)
      }
    } catch {
      setIsFullscreen(prev => !prev)
    }
  }

  /* ── Scrubber ──────────────────────────────────────────────── */
  // Drag-preview seek: pointer-down starts a "scrubbing" state where the
  // hover preview tracks the cursor without committing the seek. The seek
  // commits on pointer-up. Hover-only (no pointer pressed) still shows
  // the thumbnail preview without affecting playback.
  const scrubDraggingRef = useRef(false)

  function pctFromEvent(e: React.PointerEvent<HTMLDivElement>): number {
    const rect = e.currentTarget.getBoundingClientRect()
    return Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
  }

  function handleScrubPointer(e: React.PointerEvent<HTMLDivElement>) {
    setScrubPercent(pctFromEvent(e))
  }
  function handleScrubLeave() {
    if (!scrubDraggingRef.current) setScrubPercent(null)
  }
  function handleScrubDown(e: React.PointerEvent<HTMLDivElement>) {
    if (e.button !== 0) return
    scrubDraggingRef.current = true
    e.currentTarget.setPointerCapture?.(e.pointerId)
    setScrubPercent(pctFromEvent(e))
  }
  function handleScrubUp(e: React.PointerEvent<HTMLDivElement>) {
    if (!scrubDraggingRef.current) return
    scrubDraggingRef.current = false
    try { e.currentTarget.releasePointerCapture?.(e.pointerId) } catch { /* noop */ }
    const pct = pctFromEvent(e)
    const time = pct * duration
    if (!Number.isFinite(time)) return
    if (playerRef.current) playerRef.current.currentTime = time
    currentTimeRef.current = time
    setCurrentTime(time)
    progressRef.current = time * 10000000
    setScrubPercent(null)
  }

  const playedPercent = duration > 0 ? (currentTime / duration) * 100 : 0
  const bufferedPercent = duration > 0 ? (buffered / duration) * 100 : 0

  function seekToSeconds(t: number) {
    if (!Number.isFinite(t)) return
    if (playerRef.current) playerRef.current.currentTime = t
    currentTimeRef.current = t
    setCurrentTime(t)
    progressRef.current = t * 10000000
    // Mirror to watch party if active (and not echoing a remote command).
    if (useSyncPlay.getState().active && !shouldSuppressRemoteEcho()) {
      spSeek(Math.round(t * 10_000_000)).catch(() => {})
    }
  }

  /* Track lists from Jellyfin metadata */
  const audioTracks = item ? getAudioStreams(item) : []
  const videoStream = (item?.MediaSources?.[0]?.MediaStreams || []).find((s: { Type?: string }) => s.Type === 'Video')
  const videoWidth = videoStream?.Width || 1920
  const videoHeight = videoStream?.Height || 1080

  /**
   * Pick an audio track. For direct-play sources whose underlying <video>
   * exposes more than one AudioTrack, we toggle them via the native API -
   * instant switch, no source reload, no audible glitch. For everything
   * else we trip a PlaybackInfo re-request which makes the server mux the
   * chosen track into a new TranscodingUrl.
   */
  function pickAudio(jfIndex: number | null) {
    setAudioIndex(jfIndex)
    if (jfIndex == null) return
    if (resolvedSource?.SupportsDirectPlay) {
      const el = (playerRef.current as { el?: HTMLElement } | null)?.el
      const video = el?.querySelector('video') as HTMLVideoElement | null
      if (!video) return
      const native = video.audioTracks as { length: number; [i: number]: { enabled: boolean; language?: string } } | undefined
      if (native && native.length > 1) {
        const target = audioTracks.find(t => t.Index === jfIndex)
        const targetLang = (target?.Language || '').toLowerCase()
        let switched = false
        for (let i = 0; i < native.length; i++) {
          const nt = native[i]
          const matches = !!targetLang && (nt.language || '').toLowerCase() === targetLang
          nt.enabled = matches
          if (matches) switched = true
        }
        if (switched) return
      }
    }
    // Fallback: server-side audio swap via PlaybackInfo reload
    setStreamAudioIndex(jfIndex)
  }
  const subtitleTracks = item ? getSubtitleStreams(item) : []
  const chapters = item?.Chapters || []



  return (
    <div
      className="absolute inset-0 z-fullscreen bg-black"
      data-theater={theaterModeOn ? 'true' : undefined}
      onMouseMove={showControls}
      style={{ cursor: controlsVisible || isPaused ? 'default' : 'none' }}
    >
      <MediaPlayer
        ref={playerRef}
        src={streamUrl}
        autoPlay
        crossOrigin="anonymous"
        playsInline
        onProviderChange={(provider: MediaProviderAdapter | null) => {
          // Vidstack picks VideoProvider for native HLS browsers (Safari /
          // iOS / tvOS) and HLSProvider for MSE-only ones. The block below
          // only runs in the MSE case - native HLS users skip hls.js
          // entirely and get the OS hardware HEVC path.
          if (provider && isHLSProvider(provider)) {
            // Bundled hls.js (no CDN load = no tracking-prevention quirks)
            ;(provider as any).library = HLS
            // hls.js configuration tuned for Jellyfin transcoded streams.
            // Default fragLoadingTimeOut of 10s is too aggressive when the
            // server is doing HEVC -> h264 transcoding from a cold start
            // (ffmpeg needs time to spin up before the first segment).
            ;(provider as any).config = {
              startLevel: -1,
              maxBufferLength: 30,
              maxMaxBufferLength: 120,
              maxBufferSize: 30 * 1024 * 1024,
              lowLatencyMode: false,
              backBufferLength: 15,
              fragLoadingTimeOut: 60_000,
              fragLoadingMaxRetry: 6,
              manifestLoadingTimeOut: 30_000,
              levelLoadingTimeOut: 30_000,
            }
          }
        }}
        onProviderSetup={(provider: MediaProviderAdapter) => {
          if (isHLSProvider(provider)) {
            const hls = (provider as any).instance as any
            if (hls?.on && HLS?.Events) {
              hls.on(HLS.Events.ERROR, (_event: unknown, data: any) => {
                console.warn('[HLS]', data?.type, data?.details, {
                  fatal: data?.fatal,
                  reason: data?.reason,
                  url: data?.url || data?.frag?.url,
                  response: data?.response,
                })
                // Non-fatal errors (buffer stalls, fragment load failures)
                // can often be recovered without user intervention.
                if (data?.fatal) {
                  switch (data.type) {
                    case HLS.ErrorTypes.NETWORK_ERROR:
                      hls.startLoad()
                      break
                    case HLS.ErrorTypes.MEDIA_ERROR:
                      // fragParsingError ("Found no media") means the
                      // transcoder returned an empty segment - usually a
                      // cold-start race. Give it a second and retry from
                      // the current position before doing the heavy
                      // recoverMediaError reset (which causes a visible skip).
                      if (data.details === 'fragParsingError') {
                        const pos = hls.media?.currentTime
                        setTimeout(() => {
                          if (hls.destroyed) return
                          try {
                            hls.loadSource(hls.url)
                            if (pos != null && hls.media) {
                              hls.media.currentTime = pos
                            }
                            hls.startLoad()
                          } catch { hls.recoverMediaError() }
                        }, 1000)
                      } else {
                        hls.recoverMediaError()
                      }
                      break
                    default:
                      hls.destroy()
                      break
                  }
                }
              })
            }
          }
        }}
        className="w-full h-full player-fill"
        onCanPlay={() => {
          // Apply the persisted volume the moment the player is actually
          // ready to accept commands. The subscribe-time restore can be too
          // early - vidstack silently drops volume writes before its first
          // can-play, leaving the slider stuck at 1.
          const p = playerRef.current
          const persisted = usePreferencesStore.getState().playerVolume
          if (p && Number.isFinite(persisted)) {
            try { p.volume = Math.max(0, Math.min(1, persisted)) } catch { /* noop */ }
          }
          // Apply the saved playback rate (default 1) so users who like
          // 1.5x get it on every episode without re-setting.
          if (p) applyPlaybackRate(playbackRate)
          // Apply any deferred seek queued by the resume prompt.
          if (p && pendingSeekRef.current != null) {
            const target = pendingSeekRef.current
            pendingSeekRef.current = null
            const mediaEl = (p as any).media as HTMLMediaElement | undefined
            const before = mediaEl ? mediaEl.currentTime : p.currentTime
            if (before < target - 0.5 || before > target + 0.5) {
              try {
                if (mediaEl) mediaEl.currentTime = target
                else p.currentTime = target
              } catch { /* ignore */ }
            }
            p.play().catch(() => {})
          }
        }}
        onEnded={() => {
          // Queue takes priority - playlist play/shuffle should always
          // continue through the queue regardless of the global autoplay
          // pref (the user explicitly opted in by hitting Play / Shuffle).
          if (queueNext?.Id) {
            if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
              stillWatchingTargetRef.current = queueNext.Id
              setStillWatchingOpen(true)
              return
            }
            autoAdvanceCountRef.current++
            navigate(`/play/${queueNext.Id}`, { replace: true })
            return
          }
          if (autoplayNext && nextItem?.Id && nextItem.Id !== item?.Id) {
            if (areYouStillWatching && autoAdvanceCountRef.current >= 2) {
              stillWatchingTargetRef.current = nextItem.Id
              setStillWatchingOpen(true)
              return
            }
            autoAdvanceCountRef.current++
            navigate(`/play/${nextItem.Id}`, { replace: true })
            return
          }
          autoAdvanceCountRef.current = 0
          if (usePreferencesStore.getState().endOfVideoCard) {
            setEndCardOpen(true)
          } else {
            navigate(-1)
          }
        }}
        onAutoPlayFail={() => {
          // Browser blocked unmuted autoplay (Chrome's policy after navigation).
          // Mute and retry per vidstack's recommendation - user can unmute with
          // M or the volume slider once playback starts.
          const p = playerRef.current
          if (!p) return
          p.muted = true
          setIsMuted(true)
          try {
            const r = p.play() as Promise<void> | void
            if (r && typeof (r as Promise<void>).catch === 'function') {
              ;(r as Promise<void>).catch(() => {})
            }
          } catch {
            /* ignored */
          }
        }}
      >
        <MediaProvider />
        {/* Subtitle tracks - one Track per subtitle stream. The `default` prop
            sets the initially-engaged track based on subtitleMode + language. */}
        {id && subtitleStreams.map(t => {
          const idx = t.Index
          if (idx == null) return null
          return (
            <Track
              key={String(idx)}
              id={`sub-${idx}`}
              src={getSubtitleUrl(serverUrl, id, mediaSourceId, idx, token)}
              kind="subtitles"
              label={t.Title || t.Language || `Track ${idx}`}
              lang={t.Language || undefined}
              default={idx === subtitleIndex}
            />
          )
        })}

      </MediaPlayer>

      {/* Click-capture: anywhere on the video toggles play/pause; double-
          click toggles fullscreen. Sits above the player but below the
          subtitle layer and the chrome bars (which set their own
          pointer-events to receive their own clicks). */}
      <div
        className="absolute inset-0 z-10"
        onClick={() => {
          const p = playerRef.current
          if (!p) return
          if (p.paused) p.play().catch(() => {})
          else p.pause()
          showControls()
        }}
        onDoubleClick={toggleFullscreen}
      />

      {/* Subtitle rendering. ASS / SSA tracks go through libass-wasm so
          positioning, fonts, karaoke and overlapping cues are preserved
          (Jellyfin would otherwise transcode them to VTT and lose all of
          that). Everything else goes through our VTT overlay, which is
          where the styling controls (size / color / edge / position) live. */}
      {(() => {
        if (!id || subtitleIndex == null) return null
        const activeSub = subtitleStreams.find(s => s.Index === subtitleIndex)
        const codec = (activeSub?.Codec || '').toLowerCase()
        const isAss = codec === 'ass' || codec === 'ssa'
        if (isAss) {
          return (
            <LibAssRenderer
              playerRef={playerRef}
              subtitleUrl={getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token, codec === 'ssa' ? 'ssa' : 'ass')}
            />
          )
        }
        return (
          <SubtitleOverlay
            playerRef={playerRef}
            subtitleUrl={getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token)}
            videoWidth={videoWidth}
            videoHeight={videoHeight}
          />
        )
      })()}

      {/* In-player episodes browser - opens via the list-details button in
          the top bar. Only mounted for episodes since movies have no list. */}
      {seriesId && id && (
        <EpisodesPanel
          open={episodesOpen}
          onClose={() => setEpisodesOpen(false)}
          seriesId={seriesId}
          currentItemId={id}
          initialSeasonId={item?.SeasonId || undefined}
          serverUrl={serverUrl}
        />
      )}

      {/* Stream info overlay */}
      <AnimatePresence>
        <StreamInfo item={item} visible={streamInfoOpen} playMethod={playMethod} />
      </AnimatePresence>

      {/* Are you still watching? - pauses after 3 consecutive auto-advances */}
      <AnimatePresence>
        {stillWatchingOpen && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            className="absolute inset-0 z-40 grid place-items-center bg-black/70 backdrop-blur-sm"
            onClick={() => {
              setStillWatchingOpen(false)
              playerRef.current?.play().catch(() => {})
            }}
          >
            <motion.div
              initial={{ y: 20, scale: 0.96 }}
              animate={{ y: 0, scale: 1 }}
              exit={{ y: 20, scale: 0.96 }}
              transition={{ duration: 0.32, ease: [0.16, 1, 0.3, 1] }}
              className="bg-glass-strong backdrop-blur-2xl border border-white/14 rounded-2xl p-8 text-center shadow-[0_30px_80px_-20px_rgba(0,0,0,0.85)] w-[min(400px,92vw)]"
            >
              <p className="text-[14px] text-white font-medium mb-1">Still watching?</p>
              <p className="text-[12.5px] text-white/55 mb-5">
                Press play to keep going.
              </p>
              <button
                onClick={() => {
                  setStillWatchingOpen(false)
                  autoAdvanceCountRef.current = 0
                  playerRef.current?.play().catch(() => {})
                }}
                className="inline-flex items-center gap-2 h-11 px-6 rounded-full bg-accent hover:bg-accent-hover text-void text-[13px] font-semibold tracking-tight transition focus-ring"
              >
                Yes, keep playing
              </button>
              {stillWatchingTargetRef.current && (
                <button
                  onClick={() => {
                    setStillWatchingOpen(false)
                    autoAdvanceCountRef.current = 0
                    navigate(`/play/${stillWatchingTargetRef.current}`, { replace: true })
                  }}
                  className="mt-2 block mx-auto text-[12px] text-white/50 hover:text-white/80 transition"
                >
                  Skip to next anyway
                </button>
              )}
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Skip intro / credits button - shown when inside a detected marker */}
      <AnimatePresence>
        {currentMarker && (
          <motion.button
            key={`${currentMarker.type}-${currentMarker.startSec}`}
            initial={{ opacity: 0, y: 10, scale: 0.96 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: 10, scale: 0.96 }}
            transition={{ duration: 0.24, ease: [0.16, 1, 0.3, 1] }}
            onClick={() => {
              const target = currentMarker.type === 'credits' && duration > 0
                ? Math.max(currentMarker.endSec, duration - 0.5)
                : currentMarker.endSec
              if (playerRef.current) playerRef.current.currentTime = target
              showControls()
              // Smart skip suggestion: if the user keeps manually skipping
              // intros for this series, offer to auto-skip from now on.
              if (currentMarker.type === 'intro' && seriesId) {
                const r = recordManualSkip(seriesId)
                if (r.shouldPrompt && !skipIntros) {
                  setSkipPromptSeriesId(seriesId)
                }
              }
            }}
            className="absolute bottom-32 right-7 z-20 inline-flex items-center gap-2 h-10 px-4 rounded-md bg-white text-void text-[13px] font-semibold tracking-tight shadow-lg shadow-black/40 hover:scale-[1.02] active:scale-[0.98] transition-transform pointer-events-auto focus-ring"
          >
            <RotateCw size={14} stroke={2.25} />
            Skip {currentMarker.type === 'intro' ? 'intro' : 'credits'}
          </motion.button>
        )}
      </AnimatePresence>

      <PlayerOverlays
        upNext={{
          item: upNextCard,
          visible: !!nextUpVisible,
          countdown: upNextCountdown,
          onSkip: () => {
            if (upNextCard?.Id) navigate(`/play/${upNextCard.Id}`, { replace: true })
          },
          onDismiss: () => setUpNextDismissed(true),
        }}
        resume={{
          open: resumePromptOpen,
          positionTicks: Number(item?.UserData?.PlaybackPositionTicks ?? 0),
          lastPlayedDate: item?.UserData?.LastPlayedDate ?? null,
          onResume: () => {
            setResumePromptOpen(false)
            const p = playerRef.current
            const pos = Number(item?.UserData?.PlaybackPositionTicks ?? 0)
            const target = pos > 0 ? pos / 10_000_000 : 0
            if (typeof window !== 'undefined') {
              console.log('[player] resume prompt clicked', {
                id,
                pos,
                target,
                mediaElementCurrentTime: (p as any)?.media?.currentTime,
                streamUrlStart: streamUrl.includes('StartTimeTicks=') || streamUrl.includes('runtimeTicks='),
              })
            }
            if (p) {
              // Vidstack has a known issue (GH #941) where setting
              // currentTime on the MediaPlayerInstance for a direct-play
              // MP4 source restarts the video from the beginning. The
              // workaround is to set currentTime on the underlying
              // HTMLMediaElement. For HLS it works either way; we use
              // the underlying element for consistency.
              pendingSeekRef.current = target
              const mediaEl = (p as any).media as HTMLMediaElement | undefined
              try {
                if (mediaEl) mediaEl.currentTime = target
                else p.currentTime = target
              } catch { /* ignore */ }
              p.play().catch(() => {})
            }
          },
          onRestart: () => {
            setResumePromptOpen(false)
            const p = playerRef.current
            if (p) {
              pendingSeekRef.current = 0
              const mediaEl = (p as any).media as HTMLMediaElement | undefined
              try {
                if (mediaEl) mediaEl.currentTime = 0
                else p.currentTime = 0
              } catch { /* ignore */ }
              p.play().catch(() => {})
            }
          },
        }}
        recap={{
          open: recapCardOpen,
          previousEpisodes: recapTrigger.previousEpisodes,
          daysSinceLastWatch: recapTrigger.daysSinceLastWatch,
          onDismiss: () => {
            setRecapCardOpen(false)
            usePlayerRuntimeStore.getState().setRecapDismissed(true)
          },
        }}
        chapters={id ? {
          open: chaptersOpen,
          itemId: id,
          chapters,
          serverUrl,
          currentTime,
          onClose: () => setChaptersOpen(false),
          onJump: t => seekToSeconds(t),
        } : null}
        bookmarks={id ? {
          open: bookmarksOpen,
          itemId: id,
          currentTime,
          refreshKey: bookmarksRefreshKey,
          onClose: () => setBookmarksOpen(false),
          onJump: t => seekToSeconds(t),
          onAdd: () => {
            const p = playerRef.current
            if (!p || !id) return
            bookmarksAdd(id, p.currentTime ?? 0)
            setBookmarksRefreshKey(k => k + 1)
          },
        } : null}
        endCard={{
          open: endCardOpen,
          hasEpisodes: !!seriesId,
          item,
          onReplay: () => {
            setEndCardOpen(false)
            const p = playerRef.current
            if (p) p.currentTime = 0
            p?.play().catch(() => {})
          },
          onEpisodes: () => {
            setEndCardOpen(false)
            setEpisodesOpen(true)
          },
          onBack: () => {
            setEndCardOpen(false)
            navigate(-1)
          },
        }}
        nextItem={upNextCard}
        onPlayNext={() => {
          if (upNextCard?.Id) navigate(`/play/${upNextCard.Id}`, { replace: true })
        }}
        skipPrompt={{
          seriesId: skipPromptSeriesId,
          onAccept: () => {
            setPreference('skipIntros', true)
            setSkipPromptSeriesId(null)
            showToast('Auto-skip enabled')
          },
          onNotNow: () => setSkipPromptSeriesId(null),
          onDismiss: () => {
            if (skipPromptSeriesId) dismissSkipPrompt(skipPromptSeriesId)
            setSkipPromptSeriesId(null)
          },
        }}
        hints={{
          open: hintsOpen,
          onClose: () => setHintsOpen(false),
        }}
        subSearch={{
          open: subSearchOpen,
          subtitleUrl: id && subtitleIndex != null
            ? getSubtitleUrl(serverUrl, id, mediaSourceId, subtitleIndex, token, 'vtt')
            : null,
          onClose: () => setSubSearchOpen(false),
          onJump: t => seekToSeconds(t),
        }}
        syncPlay={{
          open: syncPlayOpen,
          onClose: () => setSyncPlayOpen(false),
          currentItemId: id || null,
          currentPositionTicks: Math.round(currentTime * 10_000_000),
        }}
      />

      {/* Transient toast - used by screenshot, etc. */}
      <AnimatePresence>
        {transientToast && (
          <motion.div
            initial={{ opacity: 0, y: 16 }}
            animate={{ opacity: 1, y: 0 }}
            exit={{ opacity: 0, y: 16 }}
            transition={{ duration: 0.2 }}
            className="absolute bottom-32 left-1/2 -translate-x-1/2 z-toast inline-flex items-center h-9 px-4 rounded-full bg-black/85 backdrop-blur-xl border border-white/12 text-[12px] text-white font-medium tracking-tight shadow-lg"
            role="status"
          >
            {transientToast}
          </motion.div>
        )}
      </AnimatePresence>

      {/* Loading spinner */}
      <AnimatePresence>
        {seeking && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2 }}
            className="absolute inset-0 grid place-items-center pointer-events-none"
          >
            <div className="w-14 h-14 rounded-full bg-black/40 backdrop-blur grid place-items-center">
              <div className="w-7 h-7 rounded-full border-2 border-white/20 border-t-accent animate-[spin-soft_0.7s_linear_infinite]" />
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* Seek indicator (10s skip) */}
      <AnimatePresence mode="wait">
        {seekIndicator && (
          <motion.div
            key={seekIndicator.key}
            initial={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}
            animate={reducedMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }}
            exit={reducedMotion ? { opacity: 0 } : { opacity: 0, scale: 1.1 }}
            transition={{ duration: reducedMotion ? 0.1 : 0.4 }}
            className={`absolute top-1/2 -translate-y-1/2 w-32 h-32 rounded-full grid place-items-center bg-black/60 backdrop-blur pointer-events-none ${
              seekIndicator.direction === 'forward' ? 'right-[18%]' : 'left-[18%]'
            }`}
          >
            <div className="flex flex-col items-center gap-1">
              {seekIndicator.direction === 'forward' ? (
                <RotateCw size={28} className="text-white" />
              ) : (
                <RotateCcw size={28} className="text-white" />
              )}
              <span className="text-[11px] font-semibold text-white tabular-nums">10s</span>
            </div>
          </motion.div>
        )}
      </AnimatePresence>

      {/* ── Controls overlay ─────────────────────────────────── */}
      <AnimatePresence>
        {controlsVisible && (
          <motion.div
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
            className="absolute inset-0 z-20 flex flex-col justify-between pointer-events-none"
          >
            <PlayerTopBar
              item={item}
              playbackRate={playbackRate}
              onPlaybackRateChange={applyPlaybackRate}
              qualityKey={qualityKey}
              onQualitySelect={(q: QualityOption) => {
                setQualityKey(q.key)
                setMaxBitrate(q.bitrate || undefined)
              }}
              onPictureInPicture={togglePictureInPicture}
              audioTracks={audioTracks}
              audioIndex={audioIndex}
              onAudioSelect={pickAudio}
              subtitleTracks={subtitleTracks}
              subtitleIndex={subtitleIndex}
              onSubtitleSelect={i => setSubtitleIndex(i)}
              hasSeries={!!seriesId}
              episodesOpen={episodesOpen}
              onToggleEpisodes={() => setEpisodesOpen(o => !o)}
              hasChapters={chapters.length > 0}
              chaptersOpen={chaptersOpen}
              onToggleChapters={() => setChaptersOpen(o => !o)}
              bookmarksOpen={bookmarksOpen}
              onToggleBookmarks={() => setBookmarksOpen(o => !o)}
              subSearchOpen={subSearchOpen}
              onToggleSubSearch={() => setSubSearchOpen(o => !o)}
              syncPlayOpen={syncPlayOpen}
              onToggleSyncPlay={() => setSyncPlayOpen(o => !o)}
              syncPlayActive={syncPlayActive}
              videoBrightness={videoBrightness}
              videoContrast={videoContrast}
              videoSaturation={videoSaturation}
              onPictureChange={(k, v) => {
                if (k === 'brightness') setPreference('videoBrightness', v)
                if (k === 'contrast') setPreference('videoContrast', v)
                if (k === 'saturation') setPreference('videoSaturation', v)
              }}
              onPictureReset={() => {
                setPreference('videoBrightness', 1)
                setPreference('videoContrast', 1)
                setPreference('videoSaturation', 1)
              }}
              streamInfoOpen={streamInfoOpen}
              onToggleStreamInfo={() => setStreamInfoOpen(o => !o)}
              onBack={() => navigate(-1)}
              sleepRemainingSec={sleepRemainingSec}
              onDownload={() => {
                if (!item || !streamUrl) return
                const poster = getBestImage(serverUrl, item, 'primary') || ''
                startDownload({
                  itemId: item.Id!,
                  name: item.Name || 'Untitled',
                  posterUrl: poster,
                  streamUrl,
                })
              }}
              isDownloaded={!!item && useDownloads.getState().items.some(d => d.itemId === item.Id)}
            />

            {/* Center play/pause indicator (only shown briefly when paused) */}
            <AnimatePresence>
              {isPaused && (
                <motion.div
                  initial={{ opacity: 0, scale: 0.9 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0.9 }}
                  transition={{ duration: 0.2 }}
                  className="absolute inset-0 grid place-items-center pointer-events-none"
                >
                  <div className="w-20 h-20 rounded-full bg-black/40 backdrop-blur grid place-items-center ring-1 ring-white/10">
                    <Play size={28} className="text-white translate-x-1" fill="currentColor" />
                  </div>
                </motion.div>
              )}
            </AnimatePresence>

            <PlayerBottomBar
              itemId={id}
              item={item}
              serverUrl={serverUrl}
              token={token}
              duration={duration}
              currentTime={currentTime}
              bufferedPercent={bufferedPercent}
              playedPercent={playedPercent}
              scrubPercent={scrubPercent}
              isPaused={isPaused}
              isMuted={isMuted}
              isFullscreen={isFullscreen}
              volume={volume}
              loopA={loopA}
              loopB={loopB}
              chapters={chapters}
              bookmarksRefreshKey={bookmarksRefreshKey}
              previousItem={previousItem}
              nextItem={nextItem}
              itemType={item?.Type}
              queueActive={queueActive}
              onScrubPointerMove={handleScrubPointer}
              onScrubPointerLeave={handleScrubLeave}
              onScrubPointerDown={handleScrubDown}
              onScrubPointerUp={handleScrubUp}
              onChapterJump={seekToSeconds}
              onTogglePlay={() => {
                const p = playerRef.current
                if (p?.paused) p.play().catch(() => {})
                else p?.pause()
              }}
              onPrevious={() => {
                if (previousItem?.Id) navigate(`/play/${previousItem.Id}`, { replace: true })
              }}
              onNext={() => {
                if (nextItem?.Id) navigate(`/play/${nextItem.Id}`, { replace: true })
              }}
              onBack10={() => {
                const p = playerRef.current
                if (!p) return
                p.currentTime = Math.max(0, (p.currentTime ?? 0) - 10)
                showSeekIndicator('backward')
              }}
              onForward10={() => {
                const p = playerRef.current
                if (!p) return
                p.currentTime = (p.currentTime ?? 0) + 10
                showSeekIndicator('forward')
              }}
              onToggleMute={() => {
                const p = playerRef.current
                if (!p) return
                p.muted = !p.muted
                setIsMuted(p.muted)
              }}
              onVolumeChange={v => {
                setVolume(v)
                if (playerRef.current) {
                  playerRef.current.volume = v
                  playerRef.current.muted = v === 0
                }
              }}
              onToggleFullscreen={toggleFullscreen}
            />
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  )
}


